Merge pull request #1 from gosticks/feature/refactor-graph-state

Major refactor
This commit is contained in:
Wlad Meixner 2024-01-18 09:48:26 +01:00 committed by GitHub
commit fd478d47da
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
91 changed files with 5589 additions and 3092 deletions

View File

@ -1,63 +1,65 @@
{
"name": "visualization",
"version": "0.0.1",
"private": true,
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"test": "playwright test",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"deploy": "pnpm build && pnpx gh-pages -d build -t true",
"test:unit": "vitest",
"lint": "prettier --plugin-search-dir . --check . && eslint .",
"format": "prettier --plugin-search-dir . --write ."
},
"devDependencies": {
"@playwright/test": "^1.40.1",
"@sveltejs/adapter-auto": "^2.1.1",
"@sveltejs/kit": "^1.27.6",
"@typescript-eslint/eslint-plugin": "^6.13.1",
"@typescript-eslint/parser": "^6.13.1",
"eslint": "^8.55.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-svelte3": "^4.0.0",
"gh-pages": "^6.1.0",
"prettier": "^3.1.0",
"prettier-plugin-svelte": "^3.1.2",
"svelte": "^4.2.8",
"svelte-check": "^3.6.2",
"tslib": "^2.6.2",
"typescript": "^5.3.2",
"vite": "^5.0.4",
"vitest": "^0.34.6"
},
"type": "module",
"dependencies": {
"@duckdb/duckdb-wasm": "^1.28.0",
"@fontsource/inter": "^5.0.15",
"@sveltejs/adapter-static": "^2.0.3",
"@tweenjs/tween.js": "^21.0.0",
"@types/d3": "^7.4.3",
"@types/papaparse": "^5.3.14",
"@types/three": "^0.159.0",
"apache-arrow": "^14.0.1",
"autoprefixer": "^10.4.16",
"d3": "^7.8.5",
"deep-object-diff": "^1.1.9",
"feather-icons": "^4.29.1",
"monaco-editor": "^0.44.0",
"monaco-sql-languages": "0.12.0-beta.7",
"papaparse": "^5.4.1",
"postcss": "^8.4.32",
"sass": "^1.69.5",
"stats.js": "^0.17.0",
"svelte-feather-icons": "^4.0.1",
"svelte-icons-pack": "^2.1.0",
"tailwindcss": "^3.3.5",
"three": "^0.159.0",
"three.meshline": "^1.4.0",
"web-worker": "^1.2.0"
}
"name": "visualization",
"version": "0.0.1",
"private": true,
"scripts": {
"dev": "vite dev",
"build": "vite build",
"dataset": "rm -rf ./static/dataset && rm -rf ./static/tmp && git clone git@github.com:tum-db/partitioned-filters.git --no-checkout --depth 1 --sparse ./static/tmp/ && cd ./static/tmp/ && git sparse-checkout add benchmark && git checkout && mv ./benchmark/paper ../dataset && rm -rf ../tmp",
"preview": "vite preview",
"test": "playwright test",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"deploy": "pnpm build && pnpx gh-pages -d build -t true",
"test:unit": "vitest",
"lint": "prettier --plugin-search-dir . --check . && eslint .",
"format": "prettier --plugin-search-dir . --write ."
},
"devDependencies": {
"@playwright/test": "^1.40.1",
"@sveltejs/adapter-auto": "^2.1.1",
"@sveltejs/kit": "^1.27.6",
"@types/d3": "^7.4.3",
"@types/papaparse": "^5.3.14",
"@types/three": "^0.159.0",
"@typescript-eslint/eslint-plugin": "^6.13.1",
"@typescript-eslint/parser": "^6.13.1",
"eslint": "^8.55.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-svelte3": "^4.0.0",
"gh-pages": "^6.1.0",
"prettier": "^3.1.0",
"prettier-plugin-svelte": "^3.1.2",
"sass": "^1.69.5",
"svelte": "^4.2.8",
"svelte-check": "^3.6.2",
"tslib": "^2.6.2",
"typescript": "^5.3.2",
"vite": "^5.0.4",
"vitest": "^0.34.6"
},
"type": "module",
"dependencies": {
"@duckdb/duckdb-wasm": "^1.28.0",
"@fontsource/inter": "^5.0.15",
"@sveltejs/adapter-static": "^2.0.3",
"@tweenjs/tween.js": "^21.0.0",
"apache-arrow": "^14.0.1",
"autoprefixer": "^10.4.16",
"d3": "^7.8.5",
"d3-delaunay": "^6.0.4",
"deep-object-diff": "^1.1.9",
"feather-icons": "^4.29.1",
"monaco-editor": "^0.44.0",
"monaco-sql-languages": "0.12.0-beta.7",
"papaparse": "^5.4.1",
"postcss": "^8.4.32",
"stats.js": "^0.17.0",
"svelte-feather-icons": "^4.0.1",
"svelte-icons-pack": "^2.1.0",
"tailwindcss": "^3.3.5",
"three": "^0.159.0",
"three.meshline": "^1.4.0",
"web-worker": "^1.2.0"
}
}

View File

@ -1,9 +1,5 @@
lockfileVersion: '6.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
dependencies:
'@duckdb/duckdb-wasm':
specifier: ^1.28.0
@ -17,15 +13,6 @@ dependencies:
'@tweenjs/tween.js':
specifier: ^21.0.0
version: 21.0.0
'@types/d3':
specifier: ^7.4.3
version: 7.4.3
'@types/papaparse':
specifier: ^5.3.14
version: 5.3.14
'@types/three':
specifier: ^0.159.0
version: 0.159.0
apache-arrow:
specifier: ^14.0.1
version: 14.0.1
@ -35,6 +22,9 @@ dependencies:
d3:
specifier: ^7.8.5
version: 7.8.5
d3-delaunay:
specifier: ^6.0.4
version: 6.0.4
deep-object-diff:
specifier: ^1.1.9
version: 1.1.9
@ -53,9 +43,6 @@ dependencies:
postcss:
specifier: ^8.4.32
version: 8.4.32
sass:
specifier: ^1.69.5
version: 1.69.5
stats.js:
specifier: ^0.17.0
version: 0.17.0
@ -88,6 +75,15 @@ devDependencies:
'@sveltejs/kit':
specifier: ^1.27.6
version: 1.27.6(svelte@4.2.8)(vite@5.0.4)
'@types/d3':
specifier: ^7.4.3
version: 7.4.3
'@types/papaparse':
specifier: ^5.3.14
version: 5.3.14
'@types/three':
specifier: ^0.159.0
version: 0.159.0
'@typescript-eslint/eslint-plugin':
specifier: ^6.13.1
version: 6.13.1(@typescript-eslint/parser@6.13.1)(eslint@8.55.0)(typescript@5.3.2)
@ -112,6 +108,9 @@ devDependencies:
prettier-plugin-svelte:
specifier: ^3.1.2
version: 3.1.2(prettier@3.1.0)(svelte@4.2.8)
sass:
specifier: ^1.69.5
version: 1.69.5
svelte:
specifier: ^4.2.8
version: 4.2.8
@ -659,147 +658,147 @@ packages:
/@types/d3-array@3.0.8:
resolution: {integrity: sha512-2xAVyAUgaXHX9fubjcCbGAUOqYfRJN1em1EKR2HfzWBpObZhwfnZKvofTN4TplMqJdFQao61I+NVSai/vnBvDQ==}
dev: false
dev: true
/@types/d3-axis@3.0.4:
resolution: {integrity: sha512-ySnjI/7qm+J602VjcejXcqs1hEuu5UBbGaJGp+Cn/yKVc1iS3JueLVpToGdQsS2sqta7tqA/kG4ore/+LH90UA==}
dependencies:
'@types/d3-selection': 3.0.7
dev: false
dev: true
/@types/d3-brush@3.0.4:
resolution: {integrity: sha512-Kg5uIsdJNMCs5lTqeZFsTKqj9lBvpiFRDkYN3j2CDlPhonNDg9/gXVpv1E/MKh3tEqArryIj9o6RBGE/MQe+6Q==}
dependencies:
'@types/d3-selection': 3.0.7
dev: false
dev: true
/@types/d3-chord@3.0.4:
resolution: {integrity: sha512-p4PvN1N+7GL3Y/NI9Ug1TKwowUV6h664kmxL79ctp1HRYCk1mhP0+SXhjRsoWXCdnJfbLLLmpV99rt8dMrHrzg==}
dev: false
dev: true
/@types/d3-color@3.1.1:
resolution: {integrity: sha512-CSAVrHAtM9wfuLJ2tpvvwCU/F22sm7rMHNN+yh9D6O6hyAms3+O0cgMpC1pm6UEUMOntuZC8bMt74PteiDUdCg==}
dev: false
dev: true
/@types/d3-contour@3.0.4:
resolution: {integrity: sha512-B0aeX8Xg3MNUglULxqDvlgY1SVXuN2xtEleYSAY0iMhl/SMVT7snzgAveejjwM3KaWuNXIoXEJ7dmXE8oPq/jA==}
dependencies:
'@types/d3-array': 3.0.8
'@types/geojson': 7946.0.11
dev: false
dev: true
/@types/d3-delaunay@6.0.2:
resolution: {integrity: sha512-WplUJ/OHU7eITneDqNnzK+2pgR+WDzUHG6XAUVo+oWHPQq74VcgUdw8a4ODweaZzF56OVYK+x9GxCyuq6hSu1A==}
dev: false
dev: true
/@types/d3-dispatch@3.0.4:
resolution: {integrity: sha512-NApHpGHRNxUy7e2Lfzl/cwOucmn4Xdx6FdmXzAoomo8T81LyGmlBjjko/vP0TVzawlvEFLDq8OCRLulW6DDzKw==}
dev: false
dev: true
/@types/d3-drag@3.0.4:
resolution: {integrity: sha512-/t53K1erTuUbP7WIX9SE0hlmytpTYRbIthlhbGkBHzCV5vPO++7yrk8OlisWPyIJO5TGowTmqCtGH2tokY5T/g==}
dependencies:
'@types/d3-selection': 3.0.7
dev: false
dev: true
/@types/d3-dsv@3.0.4:
resolution: {integrity: sha512-YxfUVJ55HxR8oq88136w09mBMPNhgH7PZjteq72onWXWOohGif/cLQnQv8V4A5lEGjXF04LhwSTpmzpY9wyVyA==}
dev: false
dev: true
/@types/d3-ease@3.0.0:
resolution: {integrity: sha512-aMo4eaAOijJjA6uU+GIeW018dvy9+oH5Y2VPPzjjfxevvGQ/oRDs+tfYC9b50Q4BygRR8yE2QCLsrT0WtAVseA==}
dev: false
dev: true
/@types/d3-fetch@3.0.4:
resolution: {integrity: sha512-RleYajubALkGjrvatxWhlygfvB1KNF0Uzz9guRUeeA+M/2B7l8rxObYdktaX9zU1st04lMCHjZWe4vbl+msH2Q==}
dependencies:
'@types/d3-dsv': 3.0.4
dev: false
dev: true
/@types/d3-force@3.0.6:
resolution: {integrity: sha512-G9wbOvCxkNlLrppoHLZ6oFpbm3z7ibfkXwLD8g5/4Aa7iTEV0Z7TQ0OL8UxAtvdOhCa2VZcSuqn1NQqyCEqmiw==}
dev: false
dev: true
/@types/d3-format@3.0.2:
resolution: {integrity: sha512-9oQWvKk2qVBo49FQq8yD/et8Lx0W5Ac2FdGSOUecqOFKqh0wkpyHqf9Qc7A06ftTR+Lz13Pi3jHIQis0aCueOA==}
dev: false
dev: true
/@types/d3-geo@3.0.5:
resolution: {integrity: sha512-ysEEU93Wv9p2UZBxTK3kUP7veHgyhTA0qYtI7bxK5EMXb3JxGv0D4IH54PxprAF26n+uHci24McVmzwIdLgvgQ==}
dependencies:
'@types/geojson': 7946.0.11
dev: false
dev: true
/@types/d3-hierarchy@3.1.4:
resolution: {integrity: sha512-wrvjpRFdmEu6yAqgjGy8MSud9ggxJj+I9XLuztLeSf/E0j0j6RQYtxH2J8U0Cfbgiw9ZDHyhpmaVuWhxscYaAQ==}
dev: false
dev: true
/@types/d3-interpolate@3.0.2:
resolution: {integrity: sha512-zAbCj9lTqW9J9PlF4FwnvEjXZUy75NQqPm7DMHZXuxCFTpuTrdK2NMYGQekf4hlasL78fCYOLu4EE3/tXElwow==}
dependencies:
'@types/d3-color': 3.1.1
dev: false
dev: true
/@types/d3-path@3.0.0:
resolution: {integrity: sha512-0g/A+mZXgFkQxN3HniRDbXMN79K3CdTpLsevj+PXiTcb2hVyvkZUBg37StmgCQkaD84cUJ4uaDAWq7UJOQy2Tg==}
dev: false
dev: true
/@types/d3-polygon@3.0.0:
resolution: {integrity: sha512-D49z4DyzTKXM0sGKVqiTDTYr+DHg/uxsiWDAkNrwXYuiZVd9o9wXZIo+YsHkifOiyBkmSWlEngHCQme54/hnHw==}
dev: false
dev: true
/@types/d3-quadtree@3.0.3:
resolution: {integrity: sha512-GDWaR+rGEk4ToLQSGugYnoh9AYYblsg/8kmdpa1KAJMwcdZ0v8rwgnldURxI5UrzxPlCPzF7by/Tjmv+Jn21Dg==}
dev: false
dev: true
/@types/d3-random@3.0.1:
resolution: {integrity: sha512-IIE6YTekGczpLYo/HehAy3JGF1ty7+usI97LqraNa8IiDur+L44d0VOjAvFQWJVdZOJHukUJw+ZdZBlgeUsHOQ==}
dev: false
dev: true
/@types/d3-scale-chromatic@3.0.0:
resolution: {integrity: sha512-dsoJGEIShosKVRBZB0Vo3C8nqSDqVGujJU6tPznsBJxNJNwMF8utmS83nvCBKQYPpjCzaaHcrf66iTRpZosLPw==}
dev: false
dev: true
/@types/d3-scale@4.0.5:
resolution: {integrity: sha512-w/C++3W394MHzcLKO2kdsIn5KKNTOqeQVzyPSGPLzQbkPw/jpeaGtSRlakcKevGgGsjJxGsbqS0fPrVFDbHrDA==}
dependencies:
'@types/d3-time': 3.0.1
dev: false
dev: true
/@types/d3-selection@3.0.7:
resolution: {integrity: sha512-qoj2O7KjfqCobmtFOth8FMvjwMVPUAAmn6xiUbLl1ld7vQCPgffvyV5BBcEFfqWdilAUm+3zciU/3P3vZrUMlg==}
dev: false
dev: true
/@types/d3-shape@3.1.3:
resolution: {integrity: sha512-cHMdIq+rhF5IVwAV7t61pcEXfEHsEsrbBUPkFGBwTXuxtTAkBBrnrNA8++6OWm3jwVsXoZYQM8NEekg6CPJ3zw==}
dependencies:
'@types/d3-path': 3.0.0
dev: false
dev: true
/@types/d3-time-format@4.0.1:
resolution: {integrity: sha512-Br6EFeu9B1Zrem7KaYbr800xCmEDyq8uE60kEU8rWhC/XpFYX6ocGMZuRJDQfFCq6SyakQxNHFqIfJbFLf4x6Q==}
dev: false
dev: true
/@types/d3-time@3.0.1:
resolution: {integrity: sha512-5j/AnefKAhCw4HpITmLDTPlf4vhi8o/dES+zbegfPb7LaGfNyqkLxBR6E+4yvTAgnJLmhe80EXFMzUs38fw4oA==}
dev: false
dev: true
/@types/d3-timer@3.0.0:
resolution: {integrity: sha512-HNB/9GHqu7Fo8AQiugyJbv6ZxYz58wef0esl4Mv828w1ZKpAshw/uFWVDUcIB9KKFeFKoxS3cHY07FFgtTRZ1g==}
dev: false
dev: true
/@types/d3-transition@3.0.5:
resolution: {integrity: sha512-dcfjP6prFxj3ziFOJrnt4W2P0oXNj/sGxsJXH8286sHtVZ4qWGbjuZj+RRCYx4YZ4C0izpeE8OqXVCtoWEtzYg==}
dependencies:
'@types/d3-selection': 3.0.7
dev: false
dev: true
/@types/d3-zoom@3.0.5:
resolution: {integrity: sha512-mIefdTLtxuWUWTbBupCUXPAXVPmi8/Uwrq41gQpRh0rD25GMU1ku+oTELqNY2NuuiI0F3wXC5e1liBQi7YS7XQ==}
dependencies:
'@types/d3-interpolate': 3.0.2
'@types/d3-selection': 3.0.7
dev: false
dev: true
/@types/d3@7.4.3:
resolution: {integrity: sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==}
@ -834,14 +833,14 @@ packages:
'@types/d3-timer': 3.0.0
'@types/d3-transition': 3.0.5
'@types/d3-zoom': 3.0.5
dev: false
dev: true
/@types/estree@1.0.5:
resolution: {integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==}
/@types/geojson@7946.0.11:
resolution: {integrity: sha512-L7A0AINMXQpVwxHJ4jxD6/XjZ4NDufaRlUJHjNIFKYUFBH1SvOW+neaqb0VTRSLW5suSrSu19ObFEFnfNcr+qg==}
dev: false
dev: true
/@types/json-schema@7.0.13:
resolution: {integrity: sha512-RbSSoHliUbnXj3ny0CNFOoxrIDV6SUGyStHsvDqosw6CkdPV8TtWGlfecuK4ToyMEAql6pzNxgCFKanovUzlgQ==}
@ -862,7 +861,7 @@ packages:
resolution: {integrity: sha512-LxJ4iEFcpqc6METwp9f6BV6VVc43m6MfH0VqFosHvrUgfXiFe6ww7R3itkOQ+TCK6Y+Iv/+RnnvtRZnkc5Kc9g==}
dependencies:
'@types/node': 20.8.0
dev: false
dev: true
/@types/pug@2.0.7:
resolution: {integrity: sha512-I469DU0UXNC1aHepwirWhu9YKg5fkxohZD95Ey/5A7lovC+Siu+MCLffva87lnfThaOrw9Vb1DUN5t55oULAAw==}
@ -874,7 +873,7 @@ packages:
/@types/stats.js@0.17.1:
resolution: {integrity: sha512-OgfYE1x2w1jRUXzzKABX+kOdwz2y9PE0uSwnZabkWfJTWOzm7Pvfm4JI2xqRE0q2nwUe2jZLWcrcnhd9lQU63w==}
dev: false
dev: true
/@types/three@0.159.0:
resolution: {integrity: sha512-2gybdh7HtX+rGUgslzK7QEJfzD2I0qrbUGzKk+dK0FDx49UHkNX0rqZVRzIgeFjBd1HzzhNNgwNoMacm3Wyc7w==}
@ -883,11 +882,11 @@ packages:
'@types/webxr': 0.5.5
fflate: 0.6.10
meshoptimizer: 0.18.1
dev: false
dev: true
/@types/webxr@0.5.5:
resolution: {integrity: sha512-HVOsSRTQYx3zpVl0c0FBmmmcY/60BkQLzVnpE9M1aG4f2Z0aKlBWfj4XZ2zr++XNBfkQWYcwhGlmuu44RJPDqg==}
dev: false
dev: true
/@typescript-eslint/eslint-plugin@6.13.1(@typescript-eslint/parser@6.13.1)(eslint@8.55.0)(typescript@5.3.2):
resolution: {integrity: sha512-5bQDGkXaxD46bPvQt08BUz9YSaO4S0fB1LB5JHQuXTfkGPI3+UUeS387C/e9jRie5GqT8u5kFTrMvAjtX4O5kA==}
@ -1964,7 +1963,7 @@ packages:
/fflate@0.6.10:
resolution: {integrity: sha512-IQrh3lEPM93wVCEczc9SaAOvkmcoQn/G8Bo1e8ZPlY3X3bnAxWaBdvTdvM1hP62iZp0BXWDy4vTAy4fF0+Dlpg==}
dev: false
dev: true
/file-entry-cache@6.0.1:
resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==}
@ -2414,7 +2413,7 @@ packages:
/meshoptimizer@0.18.1:
resolution: {integrity: sha512-ZhoIoL7TNV4s5B6+rx5mC//fw8/POGyNxS/DZyCJeiZ12ScLfVwRE/GfsxwiTkMYYD5DmK2/JXnEVXqL4rF+Sw==}
dev: false
dev: true
/micromatch@4.0.5:
resolution: {integrity: sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==}
@ -3530,3 +3529,7 @@ packages:
resolution: {integrity: sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g==}
engines: {node: '>=12.20'}
dev: true
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false

View File

@ -10,7 +10,10 @@ import {
type Dataset
} from './types';
const parseDirectory = (dir: string, stripPrefix: string | undefined = undefined): FSItem[] => {
export const parseDirectory = (
dir: string,
stripPrefix: string | undefined = undefined
): FSItem[] => {
const walkDir = (dir: string): FSItem[] =>
fs.readdirSync(dir).map((f) => {
const dirPath = path.join(dir, f);

View File

@ -1,22 +1,11 @@
<script lang="ts">
import { onMount } from 'svelte';
import { Theme, toggleThemeMode, useSettingsStore } from '$lib/store/SettingsStore';
import settingsStore, { Theme } from '$lib/store/SettingsStore';
let className: string | undefined = '';
export { className as class };
let theme: Theme;
let store = useSettingsStore();
onMount(() => {
store.subscribe((value) => {
theme = value.theme;
});
});
</script>
<div class="sidebar {className} {theme}">
<div class="sidebar {className} {$settingsStore.theme}">
<div class="p-4">
<div>
<!-- Sidebar content goes here -->
@ -26,11 +15,16 @@
<div class="flex items-center mt-4">
<label for="themeToggle" class="flex items-center cursor-pointer">
<span class="mr-2">Dark Mode</span>
<input type="checkbox" id="themeToggle" class="hidden" on:change={toggleThemeMode} />
<input
type="checkbox"
id="themeToggle"
class="hidden"
on:change={settingsStore.toggleThemeMode}
/>
<span class="relative inline-block w-10 h-6 transition bg-gray-300 rounded-full">
<span
class="absolute top-0 left-0 w-6 h-6 transition transform bg-white rounded-full shadow-md"
style="transform: translateX({$store.theme === Theme.Dark
style="transform: translateX({$settingsStore.theme === Theme.Dark
? 'calc(100% - 0.75rem)'
: '0'})"
/>
@ -45,7 +39,9 @@
.sidebar {
height: 100vh;
overflow-y: scroll;
transition: background-color 0.3s, color 0.3s;
transition:
background-color 0.3s,
color 0.3s;
}
.sidebar.light {

View File

@ -9,6 +9,7 @@ export default (node: HTMLElement, options: ActionClickOutsideOptions) => {
const detectClick = (event: MouseEvent) => {
const target = event.target as Node;
if (!node.contains(target) && !whitelist.some((el) => el.isSameNode(target))) {
event.stopPropagation(); // make event opaque
onOutsideClick();
}
return;

View File

@ -8,10 +8,33 @@
<div
class:p-4={!noPad}
class="m-1 mb-4 text-left rounded-xl backdrop-blur-lg bg-background-50/75 dark:bg-background-900/75 ring-1 ring-background-900/5 dark:ring-background-950/5 shadow-lg {className}"
class="m-1 mb-4 relative text-left rounded-xl backdrop-blur-lg bg-background-50 dark:bg-background-900/75 ring-1 ring-background-900/5 dark:ring-background-950/5 shadow-lg {className}"
>
{#if title !== ''}<h2 class:pt-2={noPad} class:px-4={noPad} class="font-bold mb-2">
{title}
</h2>{/if}
<slot />
</div>
<style lang="scss">
div {
// Reset scroll bar styles
&::-webkit-scrollbar {
width: 0.4em;
}
&::-webkit-scrollbar-track {
margin-top: 20px;
margin-bottom: 20px;
@apply dark:bg-slate-900 bg-background-50/75;
}
&::-webkit-scrollbar-thumb {
@apply bg-slate-300 dark:bg-slate-600;
border-radius: 20px;
}
&::-webkit-scrollbar-track-piece {
}
}
</style>

View File

@ -22,7 +22,7 @@
if (!browser) return;
self.MonacoEnvironment = {
getWorker: async function (workerId, label) {
getWorker: async function (_, label) {
// We only support SQL for now
let worker: any;
switch (label) {

View File

@ -0,0 +1,101 @@
<script lang="ts">
import { dataStore as dbStore } from '$lib/store/dataStore/DataStore';
import { positionPortal } from '$lib/actions/portal';
import { draggable } from '$lib/actions/draggable';
import { ButtonSize, ButtonVariant } from './button/type';
import { readable, writable } from 'svelte/store';
import notificationStore from '$lib/store/notificationStore';
import { fade } from 'svelte/transition';
import Button from './button/Button.svelte';
import LoadingOverlay from './LoadingOverlay.svelte';
import { CopyIcon } from 'svelte-feather-icons';
export let absolutePosition: { x: number; y: number };
export let tableName: string;
export let dbEntryId: number;
export const itemColor: string | undefined = undefined;
export let isLocked: boolean = false;
let selectionInfoPromise: Promise<Record<string, any> | undefined> | undefined;
const queryData = async (tableName: string, id: number) => {
return await dbStore.getEntry(tableName, 'id', `${id}`);
};
$: dbEntryId, tableName, (selectionInfoPromise = queryData(tableName, dbEntryId));
const copyValue = (value: string) => {
navigator.clipboard.writeText(value);
notificationStore.info({
message: 'Value copied to clipboard',
dismissDuration: 1000
});
};
</script>
<div
use:draggable={{ enabled: readable(isLocked) }}
use:positionPortal={absolutePosition}
transition:fade={{ duration: 75 }}
class="absolute rounded-lg backdrop-blur-md border shadow-2xl bg-background-100/95 dark:bg-background-900/95 ring-1 ring-background-900/5 dark:ring-background-950/5"
class:shadow-lg={isLocked}
class:border-blue-500={isLocked}
class:border-2={isLocked}
style="font-family: monospace;"
>
<div class="px-3 pt-2">
<slot />
</div>
{#if selectionInfoPromise}
<hr class="border-slate-700 m-2" />
<div class="px-3 pb-2 tooltip-content overflow-auto max-h-96 w-sm max-w-screen-sm">
{#await selectionInfoPromise}
<div class="h-96 w-full w-xl flex items-center">Loading...</div>
{:then result}
{#if result}
{#each Object.entries(result) as [key, value]}
<div class="flex gap-2 justify-between">
<div>
<Button
variant={ButtonVariant.LINK}
on:click={() => copyValue(value)}
size={ButtonSize.SM}><b class="mr-2">{key}</b><CopyIcon size="12" /></Button
>
</div>
<span class="max-w-xs block overflow-hidden text-ellipsis">{value}</span>
</div>
{/each}
{:else}
Result empty
{/if}
{:catch err}
Failed to load info {err}
{/await}
</div>
{/if}
</div>
<style lang="scss">
.tooltip-content {
// Reset scroll bar styles
&::-webkit-scrollbar {
width: 0.4em;
height: 0.4em;
}
&::-webkit-scrollbar-track {
@apply dark:bg-slate-900 bg-slate-300;
opacity: 0.05;
}
&::-webkit-scrollbar-thumb {
@apply bg-slate-300 dark:bg-slate-600;
border-radius: 20px;
}
&::-webkit-scrollbar-corner {
opacity: 0.05;
}
}
</style>

View File

@ -1,5 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import type { ChangeEventHandler } from 'svelte/elements';
type DropHandler = (files: FileList) => void;
@ -30,10 +31,20 @@
onFileDropped(files);
}
}
const onFileInput: ChangeEventHandler<HTMLInputElement> = (event) => {
if (!event.currentTarget || !event.currentTarget.files) {
return;
}
const files = event.currentTarget.files;
if (files && files.length > 0 && onFileDropped) {
onFileDropped(files);
}
};
</script>
<div
class="border-4 min-h-[320px] rounded-xl p-6 border-background-300/70 dark:border-background-700/60 text-center border-dotted"
class="border-4 min-h-[320px] relative rounded-xl p-6 border-background-300/70 dark:border-background-700/60 text-center border-dotted"
class:dragging={isDragging}
on:dragenter={handleDragEnter}
on:dragover={handleDragOver}
@ -47,6 +58,12 @@
<span>Drag and drop files here <br /> or <br /> click to select</span>
{/if}
</span>
<input
on:change={onFileInput}
class="absolute left-0 right-0 top-0 bottom-0 opacity-0"
type="file"
accept="text/csv"
/>
</div>
<style lang="scss">

View File

@ -18,7 +18,7 @@
import { PortalPlacement, relativePortal } from '$lib/actions/portal';
import clickOutside, { type ActionClickOutsideOptions } from '$lib/actions/clickOutside';
import { fadeSlide } from '$lib/transitions/fadeSlide';
import { getContext, setContext } from 'svelte';
import { getContext, setContext, type ComponentType } from 'svelte';
export let placement: PortalPlacement = PortalPlacement.BOTTOM;
export let isOpen: boolean = false;
@ -66,7 +66,7 @@
{#if $$slots.trigger}
<slot name="trigger" />
{:else}
<div class="mb-2">
<div>
<Button
{...$$restProps}
class="flex items-center justify-between gap-2 {buttonClass}"

View File

@ -1,283 +0,0 @@
<script lang="ts" context="module">
export type Option<T> = { label: string; value: T; id?: number; initiallySelected?: boolean };
export type GroupKey = string;
export type GroupOptionOrderer<T> = (a: Option<T>, b: Option<T>) => number;
export type GroupOptionConstructor<R, T> = (
value: R,
index: number,
meta: unknown
) => [GroupKey, Option<T>];
export type GroupDropdownSelectionEvent<T> = CustomEvent<{
selected: Record<GroupKey, Option<T>[]>;
meta?: unknown;
}>;
</script>
<script lang="ts">
import { select } from 'd3';
import { ButtonColor, ButtonSize } from './button/type';
import Label from './base/Label.svelte';
import { createEventDispatcher } from 'svelte';
import { CheckIcon } from 'svelte-feather-icons';
import Dropdown from './Dropdown.svelte';
import Button from './button/Button.svelte';
export let isOpen = false;
export let required = false;
export let singular = false;
export let disabled = false;
export let expand = true;
export let label: string | undefined = undefined;
// Data types
// inner value of options
type T = $$Generic;
// type if items received by OptionConstructor
type R = $$Generic;
// type of meta info assigned to items
type M = $$Generic;
type Groups = Map<GroupKey, Option<T>[]>;
interface $$RestProps {
size?: ButtonSize;
}
interface $$Events {
select: DropdownSelectionEvent<T>;
}
export let selection: Map<GroupKey, Set<T>> = new Map();
export let optionOrderer: GroupOptionOrderer<T> | undefined = undefined;
export let groups: Groups = new Map();
export let meta: M | undefined = undefined;
export let values: R[] | undefined = undefined;
export let optionConstructor: GroupOptionConstructor<R, T> | undefined = undefined;
const selectDispatch = createEventDispatcher();
$: {
selectionLabel = labelForSelection(selection);
}
$: {
if (values && optionConstructor) {
generateOptions(values, optionConstructor);
}
}
$: {
if (optionOrderer) {
groups = sort(groups, optionOrderer);
}
}
function sort(groups: Groups, optionOrderer: GroupOptionOrderer<T>): Groups {
const newGroups = new Map();
for (const [k, v] of groups.entries()) {
newGroups.set(k, v.sort(optionOrderer));
}
return newGroups;
}
function generateOptions(values: R[], optionConstructor: GroupOptionConstructor<R, T>) {
const newGroups: Groups = new Map();
values.forEach((v, i) => {
const [groupKey, option] = optionConstructor!(v, i, meta);
let prevOptions = newGroups.get(groupKey);
if (!prevOptions) {
newGroups.set(groupKey, [option]);
return;
}
newGroups.set(groupKey, [...prevOptions, option]);
});
groups = newGroups;
if (optionOrderer) {
groups = sort(groups, optionOrderer);
}
// Set all items that were initially selected
selection = new Map();
for (const [k, v] of groups) {
const groupSelectionSet = new Set<T>();
v.forEach((opt) => {
if (opt.initiallySelected) {
groupSelectionSet.add(opt.value);
}
});
selection.set(k, groupSelectionSet);
}
const newLabel = labelForSelection(selection);
if (newLabel !== selectionLabel) {
selectionLabel = newLabel;
}
}
let selectionLabel = labelForSelection(selection);
function internalOnSelect(groupKey: string, option: Option<T>) {
// In singular mode we only allow one selection
if (singular) {
const newSelection = new Map();
const newGroupSelection = new Set();
newSelection.set(groupKey, newGroupSelection);
selection = newSelection;
selectDispatch('select', { selected: { [groupKey]: [option] }, meta });
return;
}
const groupSet = selection.get(groupKey);
if (!groupSet) {
selection = new Map(selection);
return;
}
if (groupSet.has(option.value)) {
groupSet.delete(option.value);
} else {
groupSet.add(option.value);
}
selection = new Map(selection);
const selectedOptions: Record<GroupKey, Option<T>[]> = {};
for (const [key, group] of selection.entries()) {
if (group.size === 0) {
continue;
}
selectedOptions[key] = [];
for (const opt of group.entries()) {
// FIXME: filter not updated correcrly
// selectedOptions[key].push(groups.get(key, ))
}
}
selectDispatch('select', {
selected: selectedOptions,
meta
});
}
function clearAll() {
selection = new Map();
selectDispatch('select', {
selected: [],
meta
});
}
function selectAll() {
selection = new Map(groups);
selectDispatch('select', {
selected: selection,
meta
});
}
function numberOfItems(selected: typeof selection) {
let counter = 0;
for (const options of selected.values()) {
counter += options.size;
}
return counter;
}
// Computes the button text based on current selection
function labelForSelection(selected: typeof selection) {
if (selected.size === 0) {
return 'Select';
}
if (singular) {
for (const key of selected.keys()) {
for (const option of selected.get(key)!.values()) {
return option ?? 'Select';
}
}
}
return `(${numberOfItems(selected)}) selected`;
}
</script>
<div class="flex-col" class:flex={expand} class:inline-flex={!expand}>
{#if label !== undefined}
<Label>
{label}{#if required}<sup class="text-red-700">*</sup>{/if}
</Label>
{/if}
<Dropdown
buttonClass={expand ? 'w-full' : undefined}
{isOpen}
disabled={!(groups && groups.size > 0) || disabled}
{...$$restProps}
>
<span slot="button">
{#if $$slots.default}
<slot />
{:else}
<span class="text-sm" class:opacity-30={selection.size === 0}>{selectionLabel}</span>
{/if}
</span>
<div slot="content">
<ul>
{#each groups.entries() as [groupKey, options], i}
{#each options as option, j}
{@const selected = selection.has(option.value)}
<li class="border-spacing-1 border-b dark:border-background-900 last:border-b-0">
<button
on:click={() => internalOnSelect(option)}
class="p-2 hover:bg-primary-100 dark:hover:bg-secondary-700 w-full text-left flex gap-2"
>
<div class="w-6 pt pb">
{#if singular}
<div
class:border-foreground-500={!selected}
class:border-primary-500={selected}
class="rounded-full op w-6 h-6 border-2 flex items-center justify-center"
>
<div hidden={!selected} class="rounded-full w-3 h-3 bg-primary-500" />
</div>
{:else}
<i hidden={!selected}><CheckIcon /></i>
{/if}
</div>
<span class:font-bold={selected} class:opacity-60={!selected}>{option.label}</span
></button
>
</li>
{/each}
{/each}
</ul>
{#if !singular}
<div
class="sticky bottom-0 left-0 right-0 border-t dark:border-t-background-700 bg-background-50 dark:bg-background-800 backdrop-blur-sm"
>
<div class="p-2 flex justify-end gap-2">
<Button size={ButtonSize.SM} color={ButtonColor.PRIMARY} on:click={selectAll}
>Select all</Button
>
<Button size={ButtonSize.SM} on:click={clearAll}>Clear</Button>
</div>
</div>
{/if}
</div>
</Dropdown>
</div>

View File

@ -8,7 +8,7 @@
import { ButtonColor, ButtonSize } from './button/type';
import Label from './base/Label.svelte';
import { createEventDispatcher } from 'svelte';
import { createEventDispatcher, type ComponentType } from 'svelte';
import { CheckIcon } from 'svelte-feather-icons';
import Dropdown from './Dropdown.svelte';
@ -44,9 +44,9 @@
export let meta: M | undefined = undefined;
export let values: R[] | undefined = undefined;
export let optionConstructor: OptionConstructor<R, T> | undefined = undefined;
export let itemRenderer: ComponentType | undefined = undefined;
const selectDispatch = createEventDispatcher();
let listElement: HTMLUListElement;
$: {
selectionLabel = labelForSelection(options.filter((o) => selection.has(o.value)));
@ -86,6 +86,9 @@
if (singular) {
selection = new Set<T>([option.value]);
selectDispatch('select', { selected: [option], meta });
isOpen = false;
return;
}
@ -154,12 +157,35 @@
{#if $$slots.default}
<slot />
{:else}
<span class="text-sm" class:opacity-30={selection.size === 0}>{selectionLabel}</span>
<span
class="text-sm block whitespace-nowrap max-w-sm w-full overflow-hidden text-clip"
class:opacity-30={selection.size === 0}>{selectionLabel}</span
>
{/if}
</span>
<div slot="content">
<ul>
{#if !required}
{@const isEmpty = selection.size === 0}
<li class="border-spacing-1 border-b dark:border-background-900 last:border-b-0">
<button
on:click={clearAll}
class="p-2 hover:bg-primary-100 dark:hover:bg-secondary-700 w-full text-left flex gap-2 focus:bg-primary-200 focus:dark:bg-secondary-700"
>
<div class="w-6 pt pb">
<div
class:border-foreground-500={!isEmpty}
class:border-primary-500={isEmpty}
class="rounded-full op w-6 h-6 border-2 flex items-center justify-center"
>
<div hidden={!isEmpty} class="rounded-full w-3 h-3 bg-primary-500" />
</div>
</div>
<span class:font-bold={isEmpty} class:opacity-60={!isEmpty}>No selection</span>
</button>
</li>
{/if}
{#each options as option, i}
{@const selected = selection.has(option.value)}
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
@ -171,22 +197,25 @@
on:click={() => internalOnSelect(option)}
class="p-2 hover:bg-primary-100 dark:hover:bg-secondary-700 w-full text-left flex gap-2 focus:bg-primary-200 focus:dark:bg-secondary-700"
>
<div class="w-6 pt pb">
{#if singular}
<div
class:border-foreground-500={!selected}
class:border-primary-500={selected}
class="rounded-full op w-6 h-6 border-2 flex items-center justify-center"
>
<div hidden={!selected} class="rounded-full w-3 h-3 bg-primary-500" />
</div>
{:else}
<i hidden={!selected}><CheckIcon /></i>
{/if}
</div>
<span class:font-bold={selected} class:opacity-60={!selected}>{option.label}</span
></button
>
{#if itemRenderer}
<svelte:component this={itemRenderer} {option} index={i} {selected} />
{:else}
<div class="w-6 pt pb">
{#if singular}
<div
class:border-foreground-500={!selected}
class:border-primary-500={selected}
class="rounded-full op w-6 h-6 border-2 flex items-center justify-center"
>
<div hidden={!selected} class="rounded-full w-3 h-3 bg-primary-500" />
</div>
{:else}
<i hidden={!selected}><CheckIcon /></i>
{/if}
</div>
<span class:font-bold={selected} class:opacity-60={!selected}>{option.label}</span>
{/if}
</button>
</li>
{/each}
</ul>

View File

@ -0,0 +1,61 @@
<script lang="ts" context="module">
export type ChangeEvent = CustomEvent<{
change: string;
}>;
</script>
<script lang="ts">
import { CheckCircleIcon, CheckIcon } from 'svelte-feather-icons';
import Button from './button/Button.svelte';
import { ButtonColor, ButtonSize, ButtonVariant } from './button/type';
import Input from './Input.svelte';
import clickOutside, { type ActionClickOutsideOptions } from '$lib/actions/clickOutside';
import { createEventDispatcher } from 'svelte';
interface $$Events {
change: ChangeEvent;
}
const eventDispatcher = createEventDispatcher();
export let value: string = '';
export let buttonWrap = false;
let tmpValue: string = value;
let isEditing = false;
$: tmpValue = value;
const onCancel = () => {
isEditing = false;
tmpValue = value;
};
const onAccept = () => {
isEditing = false;
eventDispatcher('change', {
change: tmpValue
});
};
const outsideClickParams: ActionClickOutsideOptions = {
onClickOutside: onCancel
};
</script>
{#if isEditing}<div class="flex gap-2" use:clickOutside={outsideClickParams}>
<form on:submit={onAccept}><Input bind:value={tmpValue} autofocus /></form>
<Button size={ButtonSize.SM} on:click={onAccept}><CheckIcon size="16" /></Button>
</div>
{:else if buttonWrap}
<Button
variant={ButtonVariant.OUTLINE}
color={ButtonColor.SECONDARY}
size={ButtonSize.MD}
on:click={() => (isEditing = true)}
>
{value}
</Button>
{:else}
<button on:click={() => (isEditing = true)}>
{value}
</button>
{/if}

View File

@ -1,304 +0,0 @@
<script lang="ts">
import { dataStore } from '$lib/store/dataStore/DataStore';
import filterStore from '$lib/store/filterStore/FilterStore';
import settingsStore, { Theme } from '$lib/store/SettingsStore';
import { getContext, onMount } from 'svelte';
import Button from './button/Button.svelte';
import Card from './Card.svelte';
import { GraphOptions, GraphType } from '$lib/store/filterStore/types';
import OptionRenderer from './OptionRenderer.svelte';
import Divider from './base/Divider.svelte';
import {
CameraIcon,
CpuIcon,
InfoIcon,
LayersIcon,
MehIcon,
MoonIcon,
PlusIcon,
RefreshCcwIcon,
SettingsIcon,
SunIcon,
XIcon
} from 'svelte-feather-icons';
import { ButtonColor, ButtonSize, ButtonVariant } from './button/type';
import Dialog, { DialogSize, getDialogContext } from './dialog/Dialog.svelte';
import TableSelection, {
type DatasetSelectionEvent,
type TableSelectionEvent
} from './tableSelection/TableSelection.svelte';
import QueryEditor from './QueryEditor.svelte';
import { fadeSlide } from '$lib/transitions/fadeSlide';
import { get } from 'svelte/store';
import notificationStore from '$lib/store/notificationStore';
let optionsStore: GraphOptions['optionsStore'] | undefined;
let isFilterBarOpen: boolean = true;
onMount(async () => {});
$: if ($filterStore.graphOptions) {
optionsStore = $filterStore.graphOptions.optionsStore;
}
function onTableSelect(evt: TableSelectionEvent) {
const { buildInTables, externalTables } = evt.detail;
if (buildInTables) {
filterStore.selectBuildInTables(
buildInTables.dataset,
buildInTables.paths.map((option) => option.value)
);
}
if (externalTables && externalTables.fileList) {
filterStore.selectTablesFromFiles(externalTables.fileList);
}
if (externalTables && externalTables.url) {
filterStore.selectTableFromURL(externalTables.url);
}
}
function onDatasetSelect(evt: DatasetSelectionEvent) {
filterStore.selectDataset(evt.detail);
}
function toggleFilterBar() {
isFilterBarOpen = !isFilterBarOpen;
}
function canvasFilledRegionBounds(ctx: WebGL2RenderingContext | WebGLRenderingContext) {
const pixels = new Uint8ClampedArray(ctx.drawingBufferWidth * ctx.drawingBufferHeight * 4);
ctx.readPixels(
0,
0,
ctx.drawingBufferWidth,
ctx.drawingBufferHeight,
ctx.RGBA,
ctx.UNSIGNED_BYTE,
pixels
);
let pixelCount = pixels.length;
let bound = {
top: -1,
left: -1,
right: -1,
bottom: -1
};
let x = 0;
let y = 0;
for (let i = 0; i < pixelCount; i += 4) {
if (pixels[i + 3] !== 0) {
x = (i / 4) % ctx.drawingBufferWidth;
y = ~~(i / 4 / ctx.drawingBufferWidth);
if (bound.top === -1) {
bound.top = y;
}
if (bound.left === -1) {
bound.left = x;
} else if (x < bound.left) {
bound.left = x;
}
if (bound.right === -1) {
bound.right = x;
} else if (bound.right < x) {
bound.right = x;
}
if (bound.bottom === -1) {
bound.bottom = y;
} else if (bound.bottom < y) {
bound.bottom = y;
}
}
}
return bound;
}
function drawCanvasToCanvas(
srcCtx: WebGL2RenderingContext | WebGLRenderingContext,
dstCtx: CanvasRenderingContext2D,
bound: ReturnType<typeof canvasFilledRegionBounds>
) {
dstCtx.drawImage(
srcCtx.canvas,
-bound.left,
-(srcCtx.drawingBufferHeight - bound.bottom), // canvas2d is inverted compared to pixels of canvas 3d
srcCtx.drawingBufferWidth,
srcCtx.drawingBufferHeight
);
}
function captureScreenshot(backgroundFill?: string | CanvasGradient | CanvasPattern) {
const canvas = document.getElementById('basic-graph') as HTMLCanvasElement;
if (canvas) {
const copyCtx = document.createElement('canvas').getContext('2d');
if (!copyCtx) {
return;
}
// FIXME: should be linked to THREEJS otherwise ctx ID might differ
// resulting in invalid screenshots
const originalCtx = canvas.getContext('webgl2');
if (!originalCtx) {
return;
}
const bound = canvasFilledRegionBounds(originalCtx);
let trimHeight = bound.bottom - bound.top,
trimWidth = bound.right - bound.left;
copyCtx.canvas.width = trimWidth;
copyCtx.canvas.height = trimHeight;
if (backgroundFill) {
copyCtx.fillStyle = backgroundFill;
copyCtx.fillRect(0, 0, copyCtx.canvas.width, copyCtx.canvas.height);
}
drawCanvasToCanvas(originalCtx, copyCtx, bound);
const imgData = copyCtx.canvas.toDataURL('image/png');
let link = document.createElement('a');
link.href = imgData;
const state = get(filterStore);
let imageName = 'screenshot';
if (state.graphOptions) {
imageName = state.graphOptions.description() ?? imageName;
}
link.download = `${imageName}.png`;
link.click();
}
}
const copyConfigValue = () => {
const value = JSON.stringify(filterStore.toStateObject());
navigator.clipboard.writeText(value);
notificationStore.info({
message: 'Graph State copied to clipboard',
dismissDuration: 1000
});
};
</script>
<div class="absolute right-4 pt-4 t-0 top-0 max-h-full overflow-y-auto">
<div class="mb-4 gap-3 flex justify-end mr-1">
<Button size={ButtonSize.LG} color={ButtonColor.SECONDARY} on:click={() => captureScreenshot()}>
<div class="py">
<CameraIcon size="20" />
</div>
</Button>
<Button
size={ButtonSize.LG}
color={ButtonColor.SECONDARY}
on:click={settingsStore.toggleThemeMode}
>
<div class="py">
{#if $settingsStore.theme === Theme.Dark}
<MoonIcon size="20" />
{:else}
<SunIcon size="20" />
{/if}
</div>
</Button>
<Button on:click={copyConfigValue} color={ButtonColor.SECONDARY} size={ButtonSize.LG}>
<CpuIcon slot="leading" size="20" />
</Button>
<Dialog size={DialogSize.large}>
<Button slot="trigger" color={ButtonColor.SECONDARY} size={ButtonSize.LG}>
<InfoIcon slot="leading" size="20" />
SQL Editor
</Button>
<svelte:fragment slot="title">SQL Query Editor</svelte:fragment>
<QueryEditor />
</Dialog>
<Button
size={ButtonSize.LG}
color={isFilterBarOpen ? ButtonColor.PRIMARY : ButtonColor.SECONDARY}
on:click={toggleFilterBar}
>
<div class="py">
<SettingsIcon size="20" />
</div>
</Button>
</div>
{#if isFilterBarOpen}
<div class="w-96" transition:fadeSlide={{ duration: 100 }}>
<Card>
<div class="flex justify-between items-center">
<h3 class="font-semibold text-lg">Loaded table</h3>
<Button size={ButtonSize.SM} on:click={filterStore.reset}>
<svelte:fragment slot="trailing">
<RefreshCcwIcon size="12" />
</svelte:fragment>
Reset</Button
>
</div>
<ul>
{#each Object.entries($dataStore.tables) as [tableName, table]}
<li class="flex py-1 justify-between items-center">
<div>{table.displayName ?? table.name}</div>
<Button
on:click={() => filterStore.removeTable(tableName)}
variant={ButtonVariant.LINK}
size={ButtonSize.SM}><XIcon size="15" /></Button
>
</li>
{/each}
</ul>
<Dialog size={DialogSize.small}>
<Button slot="trigger" size={ButtonSize.SM}>
<svelte:fragment slot="trailing">
<PlusIcon size="12" />
</svelte:fragment>
Load More</Button
>
{@const dialogCtx = getDialogContext()}
<TableSelection
on:selectTable={(selection) => {
onTableSelect(selection);
dialogCtx.close();
}}
on:selectDataset={onDatasetSelect}
/>
</Dialog>
{#if Object.keys($dataStore.tables).length > 0}
<Divider />
<h3 class="font-semibold text-lg mb-2">Graph Type</h3>
{#each Object.values(GraphType) as graphType}
<Button
color={graphType === $filterStore.graphOptions?.getType()
? ButtonColor.PRIMARY
: ButtonColor.SECONDARY}
on:click={() => filterStore.selectGraphType(graphType)}
>
<div class="flex gap-2 flex-col items-center">
<LayersIcon />
<p class="text-sm">{graphType}</p>
</div>
</Button>
{/each}
{#if optionsStore && $filterStore.graphOptions}
<Divider />
<h3 class="font-semibold text-lg">Visualization options</h3>
<div class="flex flex-col gap-2">
<div class="mb-4">
{#each Object.entries($filterStore.graphOptions.filterOptions ?? {}) as [key, value]}
{#if typeof value !== 'undefined'}
<OptionRenderer
onValueChange={$filterStore.graphOptions.setFilterOption}
option={value}
state={$optionsStore}
{key}
/>
{/if}
{/each}
</div>
<Button color={ButtonColor.SECONDARY}>Reset</Button>
</div>
{/if}
{/if}
</Card>
</div>
{/if}
</div>

View File

@ -0,0 +1,18 @@
<script lang="ts">
export let value: string | undefined;
let className = '';
export { className as class };
export let autofocus: boolean = false;
const init = (el: HTMLInputElement) => {
if (autofocus) {
el.focus();
}
};
</script>
<input
bind:value
use:init
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500 {className}"
{...$$restProps}
/>

View File

@ -1,5 +1,10 @@
<script lang="ts">
let className: string | undefined = '';
export { className as class };
</script>
<div
class="max-w-prose min-w-[300px] bg-background-50 ring-1 ring-background-900/5 dark:bg-background-800 dark:ring-background-950/5 shadow-xl p-6 rounded-2xl"
class="max-w-prose min-w-[300px] bg-background-50 ring-1 ring-background-900/5 dark:bg-background-800 dark:ring-background-950/5 shadow-xl p-6 rounded-xl {className}"
>
<slot />
</div>

View File

@ -0,0 +1,45 @@
<script lang="ts" context="module">
import type { editor } from 'monaco-editor';
export type EditorContentChanged = CustomEvent<{
editorEvent: editor.IModelContentChangedEvent;
content: string;
}>;
</script>
<script lang="ts">
import { createEventDispatcher, onMount } from 'svelte';
import CodeEditor from './CodeEditor.svelte';
interface $$Events {
change: EditorContentChanged;
}
export let initialValue = '';
let className: string = '';
export { className as class };
const eventDispatcher = createEventDispatcher();
export let editor: editor.IStandaloneCodeEditor;
const onContentChanged = (evt: editor.IModelContentChangedEvent) => {
initialValue = editor!.getValue();
eventDispatcher('change', {
editorEvent: evt,
content: initialValue
});
};
const initEditor = () => {
editor!.layout();
editor!.setValue(initialValue);
editor!.onDidChangeModelContent(onContentChanged);
};
$: if (editor) {
initEditor();
}
</script>
<CodeEditor bind:editor class={className} />

View File

@ -26,7 +26,7 @@
<div
transition:fadeSlide={{ duration: 70 }}
class="px-3 py-2 shadow-xl w-72 rounded-xl max-w-full break-words border"
class="px-3 py-2 shadow-xl max-w-xl rounded-xl max-w-full break-words border"
class:bg-red-500={notification.type === NotificationType.error}
class:border-red-600={notification.type === NotificationType.error}
class:text-red-200={notification.type === NotificationType.error}

View File

@ -1,4 +1,6 @@
<script lang="ts">
import Label from './base/Label.svelte';
import type { SliderChangeEvent } from './slider/types';
import Slider from './slider/Slider.svelte';
@ -10,15 +12,14 @@
type T = $$Generic;
export let key: keyof T;
export let option: GraphFilterOption<T>;
export let state: T | undefined = undefined;
export let style: string | undefined = undefined;
export let onValueChange: (key: keyof T, value?: T[keyof T]) => void;
const keyAsString = key.toString();
let disabled = option.type === 'number?' && option.default ? false : true;
const onSliderChange = (evt: SliderChangeEvent) => {
onValueChange(key, evt.detail.value as T[keyof T]);
};
@ -30,6 +31,9 @@
initiallySelected: state?.[meta as keyof T] === value
});
const onColorChange = (evt: Event) =>
onValueChange(key, (evt.target as HTMLInputElement).value as T[keyof T]);
const onOptionSelected = (
evt: CustomEvent<{ selected: { label: string; value: string }[]; meta?: unknown }>
) => {
@ -42,6 +46,18 @@
}
};
const onOptionChanged = (evt: Event) => {
onValueChange(key, (evt.currentTarget as HTMLInputElement).checked as T[keyof T]);
};
const onLinkedOptionChanged = (evt: Event) => {
if (option.type === 'number?') {
let checked = (evt.currentTarget as HTMLInputElement).checked;
disabled = !checked;
onValueChange(key, (checked ? option.default : undefined) as T[keyof T]);
}
};
const sliderDisplay = (value: number) => {
switch (key) {
case 'size':
@ -67,15 +83,67 @@
{:else if option.type === 'number'}
{@const min = Math.min(...option.options)}
{@const max = Math.max(...option.options)}
{@const initialValue = state?.[key]}
{@const initialValue = state?.[key] ?? min}
<Slider
label={option.label}
{initialValue}
{min}
{max}
step={option.step}
displayFunction={sliderDisplay}
on:change={onSliderChange}
/>
{:else if option.type === 'number?'}
{@const min = Math.min(...option.options)}
{@const max = Math.max(...option.options)}
{@const initialValue = state?.[key] ?? min}
<div>
<Label>
<div class="flex justify-between">
<span>{option.toggleLabel}</span>
<input
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
type="checkbox"
on:change={onLinkedOptionChanged}
/>
</div>
</Label>
{#if !disabled}
<Slider
{initialValue}
{disabled}
{min}
{max}
step={option.step}
displayFunction={sliderDisplay}
on:change={onSliderChange}
/>
{/if}
</div>
{:else if option.type === 'boolean'}
<Label>
<div class="flex justify-between">
<span>{option.label}</span>
<input
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
type="checkbox"
checked={option.default}
on:change={onOptionChanged}
/>
</div>
</Label>
{:else if option.type === 'color'}
<Label>
<div class="flex justify-between">
<span>{option.label}</span>
<input
type="color"
value={option.default}
on:input={onColorChange}
on:change={onColorChange}
/>
</div>
</Label>
{:else if option.type === 'row'}
<div class="flex justify-stretch gap-2">
{#each option.items as item, index}

View File

@ -0,0 +1,6 @@
<script lang="ts">
let className: string = '';
export { className as class };
</script>
<h2 class="font-bold text-xl {className}"><slot /></h2>

View File

@ -0,0 +1,6 @@
<script lang="ts">
let className: string = '';
export { className as class };
</script>
<h2 class="font-bold mb-2 text-lg {className}"><slot /></h2>

View File

@ -1,6 +1,22 @@
<script lang="ts" context="module">
export enum TagColor {
default = 'dark:border-background-600 dark:bg-background-800',
orange = 'border-orange-300 bg-orange-200 dark:border-orange-600 dark:bg-orange-800',
green = 'border-green-300 bg-green-200 dark:border-green-600 dark:bg-green-800',
red = 'border-red-300 bg-red-200 dark:border-red-600 dark:bg-red-800',
blue = 'border-blue-300 bg-blue-200 dark:border-blue-600 dark:bg-blue-800'
}
</script>
<script lang="ts">
export let color: TagColor = TagColor.default;
</script>
<span
class="px-2 py-1 dark:border-background-600 dark:bg-background-800 border rounded-lg text-center"
class={`inline-block px-2 py-1 mr-2 mb-2 ${color} border rounded-lg text-center`}
style="font-family: monospace;"
>
<slot />
<span class="whitespace-nowrap">
<slot />
</span>
</span>

View File

@ -52,7 +52,7 @@
switch (color) {
case ButtonColor.PRIMARY:
return 'text-primary-600 border-[3px] border-primary-600 hover:bg-primary-600 hover:text-white dark:hover:text-white dark:border-primary-600 dark:hover:bg-primary-600 dark:hover:border-primary-600';
case ButtonColor.SECONDARY:
default:
return 'text-secondary-600 border-secondary-600 hover:bg-secondary-600 hover:text-white dark:hover:text-white dark:border-secondary-800 dark:hover:bg-secondary-600 dark:hover:border-secondary-600';
}
case ButtonVariant.DEFAULT:
@ -60,17 +60,17 @@
case ButtonVariant.LINK:
switch (color) {
case ButtonColor.PRIMARY:
return 'text-primary-600 hover:text-primary-700 dark:text-primary-100 dark:hover:text-primary-300 border-0';
return 'text-primary-600 shadow-none hover:text-primary-700 dark:text-primary-100 dark:hover:text-primary-300 border-0';
case ButtonColor.SECONDARY:
return 'text-secondary-600 hover:text-secondary-700 dark:text-secondary-100 dark:hover:text-secondary-300 border-0';
return 'text-secondary-600 shadow-none hover:text-secondary-800 dark:text-secondary-100 dark:hover:text-secondary-300 border-0';
case ButtonColor.INVERTED:
return 'text-background-200 hover:text-background-300 dark:text-background-200 dark:hover:text-background-500 border-0';
case ButtonColor.SUCCESS:
return 'text-green-700 hover:text-green-800 dark:hover:text-green-100 dark:text-green-200';
return 'text-green-700 shadow-none hover:text-green-800 dark:hover:text-green-100 dark:text-green-200';
case ButtonColor.INFO:
return 'text-blue-700 hover:text-blue-800 dark:hover:text-blue-100 dark:text-blue-200';
return 'text-blue-700 shadow-none hover:text-blue-800 dark:hover:text-blue-100 dark:text-blue-200';
case ButtonColor.ERROR:
return 'text-red-700 hover:text-red-800 dark:hover:text-red-100 dark:text-red-200';
return 'text-red-700 shadow-none hover:text-red-800 dark:hover:text-red-100 dark:text-red-200';
}
}
}
@ -83,7 +83,7 @@
class:gap-2={hasLeadingSlot || hasTrailingSlot}
class:justify-center={!hasLeadingSlot && !hasTrailingSlot}
class:justify-between={hasLeadingSlot || hasTrailingSlot}
class="inline-flex items-center {colorClasses} {sizeClasses} font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 {className ??
class="inline-flex items-center {sizeClasses} {colorClasses} font-semibold focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 {className ??
''}"
>
<slot name="leading" />

View File

@ -19,6 +19,7 @@
import { onMount, onDestroy, setContext, getContext } from 'svelte';
import { fade, fly } from 'svelte/transition';
import { defaultPortalRootClass, portal } from '$lib/actions/portal';
import H2 from '../base/H2.svelte';
export let size: DialogSize = DialogSize.medium;
export let dialogOpen = false;
@ -26,7 +27,7 @@
export { className as class };
const preventBodyScroll = (event: Event) => {
if (dialogOpen) {
if (dialogOpen && event.target === document.body) {
event.preventDefault();
}
};
@ -59,12 +60,17 @@
};
</script>
<span class={className} on:keypress={toggleDialog} on:click={toggleDialog}
><slot name="trigger" /></span
<span
role="button"
tabindex="-1"
class={className}
on:keypress={toggleDialog}
on:click={toggleDialog}><slot name="trigger" /></span
>
{#if dialogOpen}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<div
role="dialog"
use:portal
@ -79,7 +85,7 @@
{#if $$slots.title}<div
class="pb-4 mb-2 border-b border-background-100 dark:border-background-800"
>
<h2 class="font-bold text-xl"><slot name="title" /></h2>
<H2><slot name="title" /></H2>
</div>{/if}
<slot />
{#if $$slots.footer}
@ -96,7 +102,7 @@
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
z-index: 20;
}
.modal {
padding: 20px;
@ -115,7 +121,9 @@
}
@media (max-width: 768px) {
width: 90%;
&:not(.small) {
width: 100%;
}
}
}
</style>

View File

@ -0,0 +1,97 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import { browser } from '$app/environment';
import {
getGraphContext,
type GraphService,
type GraphUnsubscribe
} from '$lib/views/CoreGraph.svelte';
import { AxesRenderer } from '$lib/rendering/AxesRenderer';
import { Axis, type AxisLabelRenderer } from '$lib/rendering/AxisRenderer';
export let scale = 1;
export let xDivisions: number = 10;
export let yDivisions: number = 10;
export let zDivisions: number = 10;
export let xLabel: string = 'x';
export let yLabel: string = 'y';
export let zLabel: string = 'z';
export let xSegmentLabeler: AxisLabelRenderer | undefined = undefined;
export let zSegmentLabeler: AxisLabelRenderer | undefined = undefined;
export let ySegmentLabeler: AxisLabelRenderer | undefined = undefined;
const graphService: GraphService = getGraphContext();
const renderer = new AxesRenderer();
const update = () => {
const { domElement } = graphService.getValues();
renderer.update({});
const bounds = domElement.getBoundingClientRect();
const size = Math.min(bounds.width, bounds.height) * scale;
renderer.position.set(-0.5 * size, -0.5 * size, -0.5 * size);
renderer.scale.set(size, size, size);
};
const valueForAxis = (axis: Axis) => {
switch (axis) {
case Axis.X:
return xDivisions;
case Axis.Y:
return yDivisions;
case Axis.Z:
return zDivisions;
}
};
const segmentLabelerForAxis = (axis: Axis) => {
switch (axis) {
case Axis.X:
return xSegmentLabeler;
case Axis.Y:
return ySegmentLabeler;
case Axis.Z:
return zSegmentLabeler;
}
};
const labelForAxis = (axis: Axis) => {
switch (axis) {
case Axis.X:
return xLabel;
case Axis.Y:
return yLabel;
case Axis.Z:
return zLabel;
}
};
const updateAxis = (axis: Axis) => {
renderer.updateAxis(axis, {
segments: valueForAxis(axis),
labelText: labelForAxis(axis),
labelForSegment: segmentLabelerForAxis(axis)
});
};
let renderUnsubscriber: GraphUnsubscribe | undefined = undefined;
onMount(() => {
if (!browser) return;
const { scene } = graphService.getValues();
renderUnsubscriber = graphService.registerOnBeforeRender(renderer.onBeforeRender);
update();
scene.add(renderer);
});
$: xDivisions, xLabel, xSegmentLabeler, updateAxis(Axis.X);
$: yDivisions, yLabel, ySegmentLabeler, updateAxis(Axis.Y);
$: zDivisions, zLabel, zSegmentLabeler, updateAxis(Axis.Z);
onDestroy(() => {
renderUnsubscriber?.();
renderer.clear();
renderer.removeFromParent();
});
</script>

View File

@ -8,6 +8,8 @@
yAxisLabel?: string;
xRange: ValueRange;
yRange: ValueRange;
xScale: DataScaling;
yScale: DataScaling;
points: {
color?: string;
name?: string;
@ -23,8 +25,6 @@
export let data: IGraph2dData;
export let height = 200;
export let width = 300;
export let xScale: DataScaling = DataScaling.LINEAR;
export let yScale: DataScaling = DataScaling.LINEAR;
export let xAxisOffset = 30;
export let yAxisOffset = 20;
@ -36,6 +36,8 @@
let xAxisTitle: d3.Selection<SVGTextElement, number[][], null, undefined>;
let yAxisTitle: d3.Selection<SVGTextElement, number[][], null, undefined>;
let curves: d3.Selection<SVGPathElement, number[][], null, undefined>[] | undefined = undefined;
let points: d3.Selection<SVGCircleElement, number[][], null, undefined>[] | undefined = undefined;
function setupGraph() {
svg = d3
.select(graphElement)
@ -66,25 +68,30 @@
case DataScaling.LINEAR:
return d3.scaleLinear().range(range).domain(domain);
case DataScaling.LOG:
return d3.scaleLog().range(range).domain(domain);
const d = [
scale === DataScaling.LOG ? Math.exp(domain[0]) : domain[0],
scale === DataScaling.LOG ? Math.exp(domain[1]) : domain[1]
];
return d3.scaleLog().range(range).domain(d);
}
}
function renderData(data: IGraph2dData, xScale: DataScaling, yScale: DataScaling) {
const onMouseOver = (index: number) => (evt: MouseEvent) => {
console.log({ evt, idx: index });
};
function renderData(data: IGraph2dData) {
if (!data || data.points.length === 0) {
return;
}
const [minX, maxX] = data.xRange;
const [minY, maxY] = data.yRange;
const xAxisScale = getScale([0, width], [0, maxX], xScale);
const xAxisScale = getScale([0, width], data.xRange, data.xScale);
xAxis
.attr('transform', 'translate(0,' + (height - yAxisOffset) + ')')
.call(d3.axisBottom(xAxisScale));
// add the y Axis
const yAxisScale = getScale([height - yAxisOffset, 0], [0, maxY], yScale);
yAxis.call(d3.axisLeft(yAxisScale));
const yAxisScale = getScale([height - yAxisOffset, 0], data.yRange, data.yScale);
yAxis.call(d3.axisLeft(yAxisScale).tickFormat(d3.format('~s')));
svg.selectAll('circle').remove();
// remove all curves if number changes
@ -96,13 +103,23 @@
if (!curves) {
curves = data.points.map(() => svg.append('path'));
}
if (points && points.length !== data.points.length) {
points.forEach((point) => point.remove());
points = undefined;
}
if (!points) {
points = data.points.map(() => svg.append('circle'));
}
xAxisTitle.text(data.xAxisLabel ?? 'X');
yAxisTitle.text(data.yAxisLabel ?? 'Y');
// Update curves
data.points.forEach((container, idx) => {
(curves?.[idx] as any)
if (!curves?.at(idx) || !points?.at(idx)) {
return;
}
(curves![idx] as any)
.datum(container.data)
.attr('class', 'line')
.attr('fill', container.color ?? '#69b3a2')
@ -117,26 +134,25 @@
'd',
d3
.line()
.x((d) => xAxisScale(d[0]))
.y((d) => yAxisScale(d[1]))
.x((d) => xAxisScale(Number(d[0])))
.y((d) => yAxisScale(Number(d[1])))
.curve(d3.curveLinear) as any
);
svg
.selectAll('circle')
.data(container.data)
.enter()
.append('circle')
.attr('cx', (d) => xAxisScale(d[0]))
.attr('cy', (d) => yAxisScale(d[1]))
points![idx]
.datum(container.data)
.attr('idx', idx)
.attr('cx', (d) => xAxisScale(Number(d[0])))
.attr('cy', (d) => yAxisScale(Number(d[1])))
.on('mouseover', onMouseOver(idx))
.transition()
.attr('r', 2) // Radius of the circle
.attr('r', 4) // Radius of the circle
.attr('fill', container.color ?? 'yellow'); // Color of the circle
});
}
$: if (svg) {
renderData(data, xScale, yScale);
renderData(data);
}
onMount(async () => {

View File

@ -0,0 +1,132 @@
<script context="module" lang="ts">
import { Group, Vector3 } from 'three';
import EnhancedGridHelper from '$lib/rendering/geometry/GridGeometry';
class GridHelperRender extends Group {
private ranges: Vector3 = new Vector3(1, 1, 1);
constructor() {
super();
this.setupGridHelper();
}
updateDivisions(x: number, y: number, z: number) {
this.ranges = new Vector3(x, y, z);
this.setupGridHelper();
}
private createGrid(range: [number, number]) {
return new EnhancedGridHelper(1, range[0], range[1]);
}
public onBeforeRender = (
renderer: THREE.WebGLRenderer,
scene: THREE.Scene,
camera: THREE.Camera
) => {
const cameraDirection = new Vector3();
camera.getWorldDirection(cameraDirection);
const defaultNormal = new Vector3(0, 1, 0);
const grids = this.children as THREE.GridHelper[];
// compute distance to camera and select 3th closest sides
const closestGrids = grids
.map((grid, idx) => [idx, grid.position.distanceTo(camera.position)])
.sort(([, a], [, b]) => a - b);
const gridsToHide = 3;
// hide two closest grids
for (let i = 0; i < grids.length; i++) {
const [idx, distance] = closestGrids[i];
const grid = grids[idx];
const material = grid.material;
let opacity = 0;
if (i >= gridsToHide) {
const gridNormal = defaultNormal.clone().transformDirection(grid.matrixWorld);
const dot = cameraDirection.dot(gridNormal);
opacity = Math.max(Math.abs(dot), 0);
}
if (Array.isArray(material)) {
material.forEach((mat) => {
mat.opacity = opacity;
mat.transparent = true;
mat.needsUpdate = true;
});
} else {
material.opacity = opacity;
material.transparent = true;
material.needsUpdate = true;
}
}
};
private setupGridHelper() {
this.clear();
// Draw a grid for each side
const orientations: [Vector3, Vector3, [number, number]][] = [
// top
[new Vector3(0, 1, 0), new Vector3(0, 1, 0), [this.ranges.x, this.ranges.z]],
// bottom
[new Vector3(0, 0, 0), new Vector3(0, 0, 0), [this.ranges.z, this.ranges.x]],
[new Vector3(1, 0, 0), new Vector3(0, 0.5, -0.5), [this.ranges.y, this.ranges.x]],
[new Vector3(0, 0, 1), new Vector3(-0.5, 0.5, 0), [this.ranges.z, this.ranges.y]],
[new Vector3(-1, 0, 0), new Vector3(0, 0.5, 0.5), [this.ranges.y, this.ranges.x]],
[new Vector3(0, 0, 1), new Vector3(0.5, 0.5, 0), [this.ranges.z, this.ranges.y]]
];
for (const [orientation, offset, range] of orientations) {
const grid = this.createGrid(range);
grid.setRotationFromAxisAngle(orientation, Math.PI / 2);
grid.position.set(offset.x, offset.y, offset.z);
this.add(grid);
}
}
}
</script>
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import { browser } from '$app/environment';
import {
getGraphContext,
type GraphService,
type GraphUnsubscribe
} from '$lib/views/CoreGraph.svelte';
export let scale = 1;
export let xDivisions: number = 10;
export let yDivisions: number = 10;
export let zDivisions: number = 10;
const graphService: GraphService = getGraphContext();
const renderer = new GridHelperRender();
const update = () => {
const { domElement } = graphService.getValues();
renderer.updateDivisions(xDivisions, yDivisions, zDivisions);
const bounds = domElement.getBoundingClientRect();
const size = Math.min(bounds.width, bounds.height) * scale;
renderer.position.set(0, -0.5 * size, 0);
renderer.scale.set(size, size, size);
};
let renderUnsubscriber: GraphUnsubscribe | undefined = undefined;
onMount(() => {
if (!browser) return;
const { scene } = graphService.getValues();
renderUnsubscriber = graphService.registerOnBeforeRender(renderer.onBeforeRender);
update();
scene.add(renderer);
});
$: xDivisions, zDivisions, yDivisions, update();
onDestroy(() => {
renderUnsubscriber?.();
renderer.clear();
renderer.removeFromParent();
});
</script>

View File

@ -1,18 +1,34 @@
<script lang="ts">
import { getContext, onDestroy, onMount } from 'svelte';
import type { GraphService } from './types';
import { onDestroy, onMount } from 'svelte';
import { Minimap as MinimapRenderer } from '$lib/rendering/Minimap';
import { browser } from '$app/environment';
import { getGraphContext } from '../BasicGraph.svelte';
import {
getGraphContext,
type CameraState,
type GraphService
} from '$lib/views/CoreGraph.svelte';
import SettingsStore from '$lib/store/SettingsStore';
const graphService: GraphService = getGraphContext();
let minimalRenderer: MinimapRenderer | undefined;
let renderTargetEl: HTMLDivElement;
$: $SettingsStore, updateColor();
export const setCameraState = (state: CameraState) => {
minimalRenderer?.setCameraState(state);
};
const updateColor = () => {
if (!minimalRenderer) {
return;
}
minimalRenderer.updateColors = $SettingsStore.colors;
};
const updateMinimap = () => {
const { camera: graphCameral } = graphService.getValues();
updateColor();
// minimalRenderer = new MinimapRenderer(renderTargetEl);
minimalRenderer?.setCurrentCamera(graphCameral);
};
@ -21,7 +37,7 @@
if (!browser) return;
console.log('Setting up minimap renderer');
minimalRenderer = new MinimapRenderer(renderTargetEl);
minimalRenderer = new MinimapRenderer(renderTargetEl, $SettingsStore.colors);
updateMinimap();
});
@ -31,6 +47,6 @@
</script>
<div
class="minimap absolute isolate z-10 right-0 bottom-0 w-[190px] h-[190px]"
class="minimap absolute isolate right-0 bottom-0 w-[190px] h-[190px]"
bind:this={renderTargetEl}
/>

View File

@ -1,5 +1,6 @@
<script lang="ts">
import { getContext, onDestroy, onMount } from 'svelte';
import DbDataTooltip from '../DbDataTooltip.svelte';
import { onDestroy, onMount } from 'svelte';
import { browser } from '$app/environment';
import {
PlaneRenderer,
@ -8,48 +9,52 @@
type IPlaneData
} from '$lib/rendering/PlaneRenderer';
import Card from '../Card.svelte';
import type { PlaneGraphOptions } from '$lib/store/filterStore/graphs/plane';
import { writable, type Unsubscriber } from 'svelte/store';
import { dataStore as dbStore } from '$lib/store/dataStore/DataStore';
import type { PlaneGraphModel } from '$lib/store/filterStore/graphs/plane';
import { type Unsubscriber, get, writable } from 'svelte/store';
import Button from '../button/Button.svelte';
import { Axis } from '$lib/rendering/AxisRenderer';
import { ButtonColor, ButtonSize, ButtonVariant } from '../button/type';
import { Vector2, Vector3 } from 'three';
import { fade } from 'svelte/transition';
import LayerGroup, { type LayerSelectionEvent } from '../layerLegend/LayerGroup.svelte';
import { getGraphContext, type GraphService } from '../BasicGraph.svelte';
import { Axis, type AxisLabelRenderer } from '$lib/rendering/AxisRenderer';
import { ButtonColor, ButtonSize } from '../button/type';
import { Vector2 } from 'three';
import LayerGroup, {
type LayerColorSelectionEvent,
type LayerSelectionEvent
} from '../layerLegend/LayerGroup.svelte';
import { getGraphContext, type GraphService } from '$lib/views/CoreGraph.svelte';
import SliceGraph from './SliceGraph.svelte';
import type { ITiledDataRow } from '$lib/store/dataStore/filterActions';
import { positionPortal } from '$lib/actions/portal';
import { draggable } from '$lib/actions/draggable';
import { CopyIcon, LayersIcon, LockIcon } from 'svelte-feather-icons';
import notificationStore from '$lib/store/notificationStore';
import { LayersIcon, LockIcon } from 'svelte-feather-icons';
export let options: PlaneGraphOptions;
import Grid from './Grid.svelte';
import AxisRenderer from './AxisRenderer.svelte';
import { DataScaling } from '$lib/store/dataStore/types';
import { labelSkipFactor, scaleDecoder } from '$lib/util';
import Selection3D from './Selection3D.svelte';
export let options: PlaneGraphModel;
const graphService: GraphService = getGraphContext();
export let graphScale: number;
let threeDomContainer: HTMLElement;
let dataStore = options.dataStore;
let graphOptionStore = options.optionsStore;
let dataRenderer: PlaneRenderer = new PlaneRenderer();
let unsubscriber: Unsubscriber | undefined;
let layerVisibility: [boolean, boolean[]][] = [];
let isSelectionLocked = writable(false);
let isSelectionDisplayLocked = writable(false);
let selection: IPlaneSelection | undefined;
let mousePosition: Vector2 = new Vector2();
let mouseClientPosition: THREE.Vector2 = new Vector2(0, 0);
let selectionInfoPromise: Promise<Record<string, any> | undefined> | undefined;
let xSlice: number = 0;
let ySlice: number = 0;
const bootstrap = () => {
const { camera: graphCamera, scene, domElement } = graphService.getValues();
threeDomContainer = domElement;
dataRenderer?.setup(domElement, scene, graphCamera);
// Listen to mouse move events on the domElement
dataRenderer?.setup(domElement, scene, graphCamera, graphScale);
scene.add(dataRenderer);
@ -57,22 +62,14 @@
graphService.registerOnBeforeRender(dataRenderer.onBeforeRender.bind(dataRenderer));
};
const updateWithData = (data?: IPlaneRendererData) => {
const update = (data?: IPlaneRendererData) => {
if (!data || !dataRenderer) return;
dataRenderer.setAxisLabelRenderer(labelForAxis);
dataRenderer.updateWithData(data);
dataRenderer.update(data, get(options.renderStore));
layerVisibility = dataRenderer.getLayerVisibility();
};
const findRowData = async (tableName: string, x: number, z: number, rows: ITiledDataRow[]) => {
const row = rows.find((row) => row.x === x && row.z === z);
if (row && row.name) {
return await dbStore.getEntry(tableName, 'name', `'${row.name}'`);
}
};
const onMouseMove = (event: MouseEvent) => {
if ($isSelectionLocked) {
if ($isSelectionDisplayLocked) {
return;
}
@ -83,7 +80,7 @@
mousePosition.x = (mouseClientPosition.x / bounds.width) * 2 - 1;
mousePosition.y = -(mouseClientPosition.y / bounds.height) * 2 + 1.0;
const newSelection = dataRenderer?.getInfoAtPoint(mousePosition);
const newSelection = dataRenderer?.selectionAtPoint(mousePosition);
if (!newSelection) {
selection = newSelection;
@ -93,26 +90,12 @@
const selectionChanged =
!selection ||
newSelection.layer !== selection?.layer ||
newSelection.x !== selection?.x ||
newSelection.z !== selection?.z;
newSelection.point !== selection?.point;
if (!selectionChanged) {
return;
}
selection = newSelection;
if (selection.layer.meta) {
if (selectionInfoPromise) {
selectionInfoPromise;
}
selectionInfoPromise = findRowData(
selection.parent ? selection.parent.name : selection.layer.name,
selection.x,
selection.z,
selection.layer.meta.rows as any
);
}
};
onMount(() => {
@ -121,7 +104,7 @@
threeDomContainer.addEventListener('mousemove', onMouseMove);
let dataUnsub = options.dataStore.subscribe(updateWithData);
let dataUnsub = options.dataStore.subscribe(update);
unsubscriber = () => {
dataUnsub();
@ -134,44 +117,6 @@
unsubscriber?.();
});
function formatPowerOfTen(num: number) {
if (num === 0) return '0';
let exponent = Math.floor(Math.log10(Math.abs(num)));
return `10^${exponent}`;
}
const labelForAxis = (axis: Axis, segment: number, numSegments: number) => {
const store = dataStore;
if (!store) {
return;
}
const range = $dataStore!.ranges[axis];
if (Axis.Y == axis && range) {
console.log({ range, segment });
return ((range[1] / numSegments) * segment).toFixed(2);
}
const tileRange = $dataStore!.tileRange[axis as keyof IPlaneRendererData['tileRange']];
if (!range || !tileRange) {
return segment.toFixed(4);
}
const [min, max] = range;
// Skip every second value
if (segment % 2 === 0) {
return null;
}
const value = (segment / tileRange) * max;
if (Math.abs(value) < 0.01 || Math.abs(value) > 1000) {
return formatPowerOfTen(value);
}
return value.toFixed(3).toString();
};
const onLayerSelected = (evt: CustomEvent<LayerSelectionEvent<IPlaneData, any>>) => {
if (evt.detail.subIndex !== undefined) {
dataRenderer.toggleSublayerVisibility(evt.detail.index, evt.detail.subIndex);
@ -181,6 +126,12 @@
layerVisibility = dataRenderer.getLayerVisibility();
};
const onColorSelected = (evt: CustomEvent<LayerColorSelectionEvent<IPlaneData, any>>) => {
if (evt.detail.color) {
options.setColorForLayer(evt.detail.color, evt.detail.index, evt.detail.subIndex);
}
};
const showAllLayers = () => {
dataRenderer.showAllLayers();
layerVisibility = dataRenderer.getLayerVisibility();
@ -191,161 +142,185 @@
layerVisibility = dataRenderer.getLayerVisibility();
};
const copyValue = (value: string) => {
navigator.clipboard.writeText(value);
notificationStore.info({
message: 'Value copied to clipboard',
dismissDuration: 1000
});
const axisValueDecoder = (axis: Axis) => {
switch (axis) {
case Axis.X:
return scaleDecoder($graphOptionStore.scaleX ?? DataScaling.LINEAR);
case Axis.Y:
return scaleDecoder($graphOptionStore.scaleY ?? DataScaling.LINEAR);
case Axis.Z:
return scaleDecoder($graphOptionStore.scaleZ ?? DataScaling.LINEAR);
}
};
const selectionValueForAxis = (axis: Axis) => {
switch (axis) {
case Axis.X:
return () => selection?.layer.meta?.rows[selection.dataIndex].rawX ?? '-';
case Axis.Y:
return () => selection?.layer.meta?.rows[selection.dataIndex].rawY ?? '-';
case Axis.Z:
return () => selection?.layer.meta?.rows[selection.dataIndex].rawZ ?? '-';
}
};
const labelRenderer = (axis: Axis) => {
if (!$dataStore) {
return undefined;
}
let range = [0, 1];
let valueDecoder = axisValueDecoder(axis);
switch (axis) {
case Axis.X:
range = $dataStore.ranges.x;
break;
case Axis.Y:
range = $dataStore.ranges.y;
break;
case Axis.Z:
range = $dataStore.ranges.z;
break;
}
const formatter: AxisLabelRenderer = (axis, segment, total) => {
if (segment % labelSkipFactor(total)) {
return null;
}
return `${valueDecoder(range[0] + ((range[1] - range[0]) / total) * segment).toPrecision(2)}`;
};
return formatter;
};
let xLabelRenderer = labelRenderer(Axis.X);
let zLabelRenderer = labelRenderer(Axis.Z);
let yLabelRenderer = labelRenderer(Axis.Y);
$: $dataStore, $graphOptionStore, (xLabelRenderer = labelRenderer(Axis.X));
$: $dataStore, $graphOptionStore, (yLabelRenderer = labelRenderer(Axis.Y));
$: $dataStore, $graphOptionStore, (zLabelRenderer = labelRenderer(Axis.Z));
</script>
<div class="absolute bottom-0 left-2">
<Card noPad class="py-2 px-4"><SliceGraph bind:slice={xSlice} {options} {layerVisibility} /></Card
<div class="absolute flex flex-col items-stretch md:min-w-132 bottom-0 left-2">
{#if $dataStore?.layers}
<Card noPad>
<details open class="md:min-w-132">
<summary class="px-4 pt-2 mb-2">
<div class="inline-block font-bold">
<h2>Layers</h2>
</div>
</summary>
<div class="max-h-96 px-4 py-2 border-b dark:border-background-800 border-t overflow-auto">
{#if $dataStore}
<LayerGroup
selection={selection?.layer}
on:select={onLayerSelected}
on:color={onColorSelected}
{layerVisibility}
layers={$dataStore.layers}
/>
{/if}
</div>
<div class="px-4 pb-2">
<Button
size={ButtonSize.SM}
color={ButtonColor.SECONDARY}
class="mt-2"
disabled={layerVisibility.every(
([l, children]) => l === true && children.every((l) => l === true)
)}
on:click={showAllLayers}>Show all</Button
>
<Button
size={ButtonSize.SM}
color={ButtonColor.SECONDARY}
class="mt-2"
disabled={layerVisibility.every(
([l, children]) => l !== true && children.every((l) => l !== true)
)}
on:click={hideAllLayers}>Hide all</Button
>
</div>
</details>
</Card>
{/if}
<Card noPad class="py-2 px-4"
><SliceGraph bind:slice={xSlice} isCollapsed {options} {layerVisibility} /></Card
>
<Card noPad class="py-2 px-4"
><SliceGraph bind:slice={ySlice} axis={Axis.Z} {options} {layerVisibility} /></Card
><SliceGraph bind:slice={ySlice} axis={Axis.Z} isCollapsed {options} {layerVisibility} /></Card
>
</div>
<div class="plane-graph-ui legend absolute isolate left-2 top-16 w-[250px]">
{#if $dataStore?.layers}
<Card title="Layers" noPad>
<div class="max-h-96 px-4 py-2 border-b dark:border-background-800 border-t overflow-auto">
{#if $dataStore}
<LayerGroup
selection={selection?.layer}
on:select={onLayerSelected}
{layerVisibility}
layers={$dataStore.layers}
/>
{/if}
</div>
<div class="px-4 pb-2">
<Button
size={ButtonSize.SM}
color={ButtonColor.SECONDARY}
class="mt-2"
disabled={layerVisibility.every(
([l, children]) => l === true && children.every((l) => l === true)
)}
on:click={showAllLayers}>Show all</Button
>
<Button
size={ButtonSize.SM}
color={ButtonColor.SECONDARY}
class="mt-2"
disabled={layerVisibility.every(
([l, children]) => l !== true && children.every((l) => l !== true)
)}
on:click={hideAllLayers}>Hide all</Button
>
</div>
</Card>
{/if}
{#if selection && $dataStore}
<div
use:draggable={{ enabled: isSelectionLocked }}
use:positionPortal={mouseClientPosition}
transition:fade={{ duration: 75 }}
class="absolute rounded-lg border backdrop-blur-md border-slate-900 bg-slate-700/80 text-slate-100"
class:shadow-lg={$isSelectionLocked}
class:border-blue-500={$isSelectionLocked}
class:border-2={$isSelectionLocked}
style="font-family: monospace;"
>
<div class="px-3 pt-2">
<div class="flex pb-2 justify-between items-center whitespace-nowrap gap-2 flex-nowrap">
<div>
<div
class="flex-shrink-0 rounded-full border border-slate-800"
style={`background-color: ${selection.layer.color}; width: 12px; height:12px; display: inline-block;`}
/>
<span class="font-bold">{selection.layer.name}</span>
</div>
<div>
<Button
size={ButtonSize.SM}
color={ButtonColor.INVERTED}
on:click={() => {
if (selection) {
xSlice = selection.x;
ySlice = selection.z;
}
}}><LayersIcon slot="leading" size="10" />Show</Button
>
<Button
size={ButtonSize.SM}
color={!$isSelectionLocked ? ButtonColor.INVERTED : ButtonColor.SECONDARY}
on:click={() => ($isSelectionLocked = !$isSelectionLocked)}
><LockIcon slot="leading" size="10" />x:{selection.x} z:{selection.z}</Button
>
</div>
</div>
<div class="flex justify-between gap-2">
<span>[x]{$dataStore.labels.x}:</span>
<span>{selection.normalizedCoords.x * $dataStore.ranges.x[1]}</span>
</div>
<div class="flex justify-between gap-2">
<span>[y]{$dataStore.labels.y}:</span>
<span>{selection.y}</span>
</div>
<div class="flex justify-between gap-2">
<span>[z]{$dataStore.labels.z}:</span>
<span>{selection.normalizedCoords.z * $dataStore.ranges.z[1]}</span>
</div>
</div>
{#if selectionInfoPromise}
<hr class="border-slate-700 m-2" />
<div class="px-3 pb-2 tooltip-content overflow-auto max-h-96 max-w-sm">
{#await selectionInfoPromise}
Loading info...
{:then result}
{#if result}
{#each Object.entries(result) as [key, value]}
<div class="flex gap-2 justify-between">
<div>
<Button
variant={ButtonVariant.LINK}
on:click={() => copyValue(value)}
size={ButtonSize.SM}><b class="mr-2">{key}</b><CopyIcon size="12" /></Button
>
</div>
{#if $dataStore}
<Grid xDivisions={$dataStore.tileRange.x} zDivisions={$dataStore.tileRange.z} scale={0.6} />
<span>{value}</span>
</div>
{/each}
{:else}
Result empty
{/if}
{:catch err}
Failed to load info {err}
{/await}
<AxisRenderer
xDivisions={$dataStore.tileRange.x}
zDivisions={$dataStore.tileRange.z}
xLabel={$dataStore.labels.x}
yLabel={$dataStore.labels.y}
zLabel={$dataStore.labels.z}
xSegmentLabeler={xLabelRenderer}
ySegmentLabeler={yLabelRenderer}
zSegmentLabeler={zLabelRenderer}
scale={0.6}
/>
{/if}
<Selection3D {selection} scale={0.6} />
{#if selection && $dataStore}
<DbDataTooltip
absolutePosition={mouseClientPosition}
tableName={selection.layer.table.tableName}
dbEntryId={selection.dbEntryId}
isLocked={$isSelectionDisplayLocked}
>
<div class="flex pb-2 justify-between items-center whitespace-nowrap gap-2 flex-nowrap">
<div>
<div
class="flex-shrink-0 rounded-full border border-slate-800"
style={`background-color: ${selection.layer.color}; width: 12px; height:12px; display: inline-block;`}
/>
<span class="font-bold"
>{selection.layer.table.displayName}{#if selection.layer.isChild}
- {selection.layer.groupByValue}{/if}</span
>
</div>
{/if}
</div>
<div>
<Button
size={ButtonSize.SM}
on:click={() => {
if (selection) {
console.log(selection);
xSlice = selection.point[0];
ySlice = selection.point[1];
}
}}><LayersIcon slot="leading" size="10" />Show</Button
>
<Button
size={ButtonSize.SM}
color={!$isSelectionDisplayLocked ? ButtonColor.SECONDARY : ButtonColor.PRIMARY}
on:click={() => ($isSelectionDisplayLocked = !$isSelectionDisplayLocked)}
><LockIcon slot="leading" size="10" />x:{selection.point[0]} z:{selection
.point[1]}</Button
>
</div>
</div>
<div class="flex justify-between gap-2">
<span>[x]{$dataStore.labels.x}:</span>
<span>{selectionValueForAxis(Axis.X)()}</span>
</div>
<div class="flex justify-between gap-2">
<span>[y]{$dataStore.labels.y}:</span>
<span>{selectionValueForAxis(Axis.Y)()}</span>
</div>
<div class="flex justify-between gap-2">
<span>[z]{$dataStore.labels.z}:</span>
<span>{selectionValueForAxis(Axis.Z)()}</span>
</div>
</DbDataTooltip>
{/if}
</div>
<style lang="scss">
.tooltip-content {
// Reset scroll bar styles
&::-webkit-scrollbar {
width: 0.4em;
}
&::-webkit-scrollbar-track {
@apply dark:bg-slate-900 bg-slate-300;
opacity: 0.01;
}
&::-webkit-scrollbar-thumb {
@apply bg-slate-300 dark:bg-slate-600;
border-radius: 20px;
}
&::-webkit-scrollbar-corner {
opacity: 0.01;
}
}
</style>

View File

@ -0,0 +1,190 @@
<script lang="ts" context="module">
import type { IPlaneSelection } from '$lib/rendering/PlaneRenderer';
import { MeshLine, MeshLineMaterial } from 'three.meshline';
import {
BufferGeometry,
SphereGeometry,
Mesh,
MeshBasicMaterial,
Group,
Vector3,
type ColorRepresentation,
Color
} from 'three';
class Selection3D extends Group {
private selectionMesh?: Mesh;
private selectionMeshX?: Mesh;
private selectionMeshZ?: Mesh;
private selectionMeshX2?: Mesh;
private selectionMeshZ2?: Mesh;
public set updateColors(themeColors: ThemeColors) {
this.themeColors = themeColors;
(this.selectionMesh!.material as MeshBasicMaterial).color = new Color(
this.themeColors.selectionColor
);
}
constructor(private themeColors: ThemeColors) {
super();
this.setupSelection();
}
private renderLine(start: Vector3, end: Vector3, color: ColorRepresentation, width = 2) {
const geometry = new BufferGeometry().setFromPoints([start, end]);
const meshLine = new MeshLine();
meshLine.setGeometry(geometry);
const material = new MeshLineMaterial({
color: color,
lineWidth: width
});
return new Mesh(meshLine.geometry, material);
}
setupSelection() {
const geo = new SphereGeometry(0.02);
const mat = new MeshBasicMaterial({
color: this.themeColors.selectionColor,
transparent: true,
opacity: 0.8
});
this.selectionMesh = new Mesh(geo, mat);
// this.selectionMesh.visible = false;
this.add(this.selectionMesh);
}
update(selection?: IPlaneSelection) {
if (!selection) {
if (this.selectionMesh) {
this.selectionMesh.visible = false;
}
this.renderSelectionLines();
return;
}
// const matrix = new Matrix4();
// Update local selection
if (this.selectionMesh) {
this.selectionMesh.visible = true;
this.selectionMesh.position.copy(this.worldToLocal(selection.position.clone()));
this.renderSelectionLines(selection, this.selectionMesh);
}
}
private renderSelectionLines(selection?: IPlaneSelection, selectionMesh?: Mesh) {
// cleanup old selection
this.selectionMeshX?.removeFromParent();
this.selectionMeshX = undefined;
this.selectionMeshZ?.removeFromParent();
this.selectionMeshZ = undefined;
this.selectionMeshX2?.removeFromParent();
this.selectionMeshX2 = undefined;
this.selectionMeshZ2?.removeFromParent();
this.selectionMeshZ2 = undefined;
if (!selection || !selectionMesh) {
return;
}
this.selectionMeshZ = this.renderLine(
new Vector3(selectionMesh.position.x, selectionMesh.position.y, -0.5),
selectionMesh.position.clone(),
this.themeColors.selectionColor
);
this.selectionMeshZ2 = this.renderLine(
new Vector3(selectionMesh.position.x, selectionMesh.position.y, -0.5),
new Vector3(selectionMesh.position.x, -0.5, -0.5),
this.themeColors.selectionColor
);
this.selectionMeshX = this.renderLine(
new Vector3(-0.5, selectionMesh.position.y, selectionMesh.position.z),
selectionMesh.position.clone(),
this.themeColors.selectionColor
);
this.selectionMeshX2 = this.renderLine(
new Vector3(-0.5, selectionMesh.position.y, selectionMesh.position.z),
new Vector3(-0.5, -0.5, selectionMesh.position.z),
this.themeColors.selectionColor
);
this.add(
this.selectionMeshX,
this.selectionMeshX2,
this.selectionMeshZ,
this.selectionMeshZ2
);
}
}
</script>
<script lang="ts">
import {
getGraphContext,
type GraphService,
type GraphUnsubscribe
} from '$lib/views/CoreGraph.svelte';
import { browser } from '$app/environment';
import { onDestroy, onMount } from 'svelte';
import type { ThemeColors } from '$lib/store/SettingsStore';
import SettingsStore from '$lib/store/SettingsStore';
export let selection: IPlaneSelection | undefined = undefined;
export let scale = 0.6;
const graphService: GraphService = getGraphContext();
let renderer: Selection3D | undefined = undefined;
let renderUnsubscriber: GraphUnsubscribe | undefined = undefined;
$: $SettingsStore, updateColor();
$: selection, update(selection);
onMount(() => {
if (!browser) return;
const { scene } = graphService.getValues();
// renderUnsubscriber = graphService.registerOnBeforeRender(renderer.onBeforeRender);
setup();
update(selection);
scene.add(renderer!);
});
onDestroy(() => {
renderUnsubscriber?.();
renderer?.clear();
renderer?.removeFromParent();
});
const updateColor = () => {
if (!renderer) {
return;
}
renderer.updateColors = $SettingsStore.colors;
};
const setup = () => {
renderer = new Selection3D($SettingsStore.colors);
const { domElement } = graphService.getValues();
// renderer.update({});
const bounds = domElement.getBoundingClientRect();
const size = Math.min(bounds.width, bounds.height) * scale;
renderer.position.set(0, 0, 0);
renderer.scale.set(size, size, size);
};
const update = (selection?: IPlaneSelection) => {
renderer?.update(selection);
};
</script>

View File

@ -1,16 +1,14 @@
<script lang="ts">
import { PortalPlacement } from '$lib/actions/portal';
import { Axis } from '$lib/rendering/AxisRenderer';
import type {
IPlaneChildData,
IPlaneData,
IPlaneRendererData,
PlaneRenderer
IPlaneRendererData
} from '$lib/rendering/PlaneRenderer';
import Slider, { type SliderInputEvent } from '../slider/Slider.svelte';
import type { PlaneGraphOptions } from '$lib/store/filterStore/graphs/plane';
import SliceSelection from './SliceSelection.svelte';
import Dropdown, { getDropdownCtx } from '../Dropdown.svelte';
import Button from '../button/Button.svelte';
import { ButtonColor, ButtonSize, ButtonVariant } from '../button/type';
import { DataScaling } from '$lib/store/dataStore/types';
import type { PlaneGraphModel } from '$lib/store/filterStore/graphs/plane';
import { scaleDecoder } from '$lib/util';
import {
ChevronDownIcon,
ChevronUpIcon,
@ -18,22 +16,20 @@
EyeOffIcon,
InfoIcon,
Maximize2Icon,
MehIcon,
MoveIcon
} from 'svelte-feather-icons';
import { PortalPlacement } from '$lib/actions/portal';
import Dropdown, { getDropdownCtx } from '../Dropdown.svelte';
import Button from '../button/Button.svelte';
import { ButtonColor, ButtonSize, ButtonVariant } from '../button/type';
import Dialog, { DialogSize } from '../dialog/Dialog.svelte';
import type { LayerVisibilityList } from '../layerLegend/LayerGroup.svelte';
import Slider, { type SliderInputEvent } from '../slider/Slider.svelte';
import type { IGraph2dData } from './Graph2D.svelte';
import Graph2D from './Graph2D.svelte';
import type { LayerVisibilityList } from '../layerLegend/LayerGroup.svelte';
import { Axis } from '$lib/rendering/AxisRenderer';
import Dialog, { DialogSize } from '../dialog/Dialog.svelte';
import type { ITableReference } from '$lib/store/filterStore/types';
import type { ITiledDataRow } from '$lib/store/dataStore/filterActions';
import DropdownSelect, { type DropdownSelectionEvent } from '../DropdownSelect.svelte';
import BasicGraph from '../BasicGraph.svelte';
import { DataScaling } from '$lib/store/dataStore/types';
import SliceSelection from './SliceSelection.svelte';
import type { DropdownSelectionEvent } from '../DropdownSelect.svelte';
export let options: PlaneGraphOptions;
export let options: PlaneGraphModel;
export let layerVisibility: LayerVisibilityList;
export let axis: Axis = Axis.X;
// If enabled adds an expand button that rerenders the slice graph into a dialog
@ -43,15 +39,16 @@
export let xAxisOffset = 30;
export let yAxisOffset = 20;
export let slice = 0;
export let selected: number | undefined = undefined;
let data: IGraph2dData | undefined;
let dataStore = options.dataStore;
let graphOptionStore = options.optionsStore;
let visibleLayers: (IPlaneData | IPlaneChildData)[] = [];
let isSelectingSlice = false;
let isSliceShown = false;
let isCollapsed = false;
export let isSliceShown = false;
export let isCollapsed = false;
let scale: DataScaling = DataScaling.LINEAR;
function getVisibleLayers(data: IPlaneRendererData | undefined, visibility: LayerVisibilityList) {
@ -72,27 +69,39 @@
});
}
function mapData(points: number[][], sliceIndex: number, axis: Axis): number[][] {
console.log(points);
const axisValueDecoder = (axis: Axis) => {
switch (axis) {
case Axis.X:
return points[sliceIndex].map((y: number, idx) => [idx, y]);
return scaleDecoder($graphOptionStore.scaleX ?? DataScaling.LINEAR);
case Axis.Y:
throw Error('Y slice rendering not supported');
return scaleDecoder($graphOptionStore.scaleY ?? DataScaling.LINEAR);
case Axis.Z:
const res = points.map((ys, idx) => [idx, ys[sliceIndex]]);
return res;
return scaleDecoder($graphOptionStore.scaleZ ?? DataScaling.LINEAR);
}
}
};
function mapRowData(rows: ITiledDataRow[], sliceIndex: number, axis: Axis): number[][] {
function mapRowData(
data: IPlaneData | IPlaneChildData,
sliceIndex: number,
axis: Axis
): number[][] {
switch (axis) {
case Axis.X:
return rows.filter((row) => row.z === sliceIndex).map((row) => [row.rawX, row.y]);
case Axis.X: {
return (
data.meta?.rows
.filter((row, i) => data.points[i][0] === sliceIndex)
.map((row) => [row.rawZ, row.rawY]) ?? []
);
}
case Axis.Y:
throw Error('Y slice rendering not supported');
case Axis.Z:
return rows.filter((row) => row.x === sliceIndex).map((row) => [row.rawZ, row.y]);
case Axis.Z: {
return (
data.meta?.rows
.filter((row, i) => data.points[i][1] === sliceIndex)
.map((row) => [row.rawX, row.rawY]) ?? []
);
}
}
}
@ -106,9 +115,7 @@
const data = layers.map((layer) => ({
name: layer.name,
color: layer.color,
data: layer.meta?.rows
? mapRowData(layer.meta.rows as ITiledDataRow[], sliceIndex, axis)
: mapData(layer.points as number[][], sliceIndex, axis)
data: mapRowData(layer, sliceIndex, axis)
}));
if (!data || data.length === 0) {
@ -119,25 +126,28 @@
return;
}
console.log($dataStore.ranges);
console.log($dataStore);
switch (axis) {
case Axis.X:
return {
xRange: $dataStore.ranges.x,
xRange: $dataStore.ranges.z,
yRange: $dataStore.ranges.y,
xAxisLabel: $dataStore.labels.x,
xAxisLabel: $dataStore.labels.z,
yAxisLabel: $dataStore.labels.y,
xScale: $dataStore.scales.z,
yScale: $dataStore.scales.y,
points: data
};
case Axis.Y:
throw new Error('Y axis not supported');
case Axis.Z:
return {
xRange: $dataStore.ranges.z,
xRange: $dataStore.ranges.x,
yRange: $dataStore.ranges.y,
xAxisLabel: $dataStore.labels.z,
xAxisLabel: $dataStore.labels.x,
yAxisLabel: $dataStore.labels.y,
xScale: $dataStore.scales.x,
yScale: $dataStore.scales.y,
points: data
};
}
@ -210,7 +220,7 @@
</Dialog>
{/if}
<DropdownSelect
<!-- <DropdownSelect
values={Object.values(DataScaling)}
singular
expand={false}
@ -224,7 +234,7 @@
id: index
};
}}
/>
/> -->
<Dropdown placement={PortalPlacement.TOP}>
<svelte:fragment slot="trigger">
{@const dropCtx = getDropdownCtx()}
@ -255,7 +265,7 @@
<div>
{#if data}
<div class="pr-2">
<Graph2D {width} {height} {xAxisOffset} {yAxisOffset} {data} xScale={scale} />
<Graph2D {width} {height} {xAxisOffset} {yAxisOffset} {data} />
</div>
{:else}
<div class="w-52 h-52 flex flex-col gap-2 justify-center items-center">

View File

@ -1,22 +1,26 @@
<script lang="ts">
import { getContext, onDestroy, onMount } from 'svelte';
import * as THREE from 'three';
import { browser } from '$app/environment';
import { Axis } from '$lib/rendering/AxisRenderer';
import { colorBrewer } from '$lib/rendering/colors';
import { getGraphContext, type GraphService } from '../BasicGraph.svelte';
<script lang="ts" context="module">
import {
AddEquation,
DoubleSide,
Group,
Mesh,
MeshBasicMaterial,
PlaneGeometry,
Vector3,
type ColorRepresentation
} from 'three';
class SliceSelectionRenderer extends THREE.Group {
private meshes = new Map<Axis, THREE.Mesh<THREE.PlaneGeometry, THREE.MeshBasicMaterial>>();
private colors: Record<Axis, THREE.ColorRepresentation> = {
[Axis.X]: colorBrewer.Oranges[3][0],
class SliceSelectionRenderer extends Group {
private meshes = new Map<Axis, Mesh<PlaneGeometry, MeshBasicMaterial>>();
private colors: Record<Axis, ColorRepresentation> = {
[Axis.X]: colorBrewer.Oranges[3][2],
[Axis.Y]: colorBrewer.Oranges[3][1],
[Axis.Z]: colorBrewer.Oranges[3][2]
[Axis.Z]: colorBrewer.Oranges[3][0]
};
private movementDirection: Record<Axis, THREE.Vector3> = {
[Axis.X]: new THREE.Vector3(0, 0, 1),
[Axis.Y]: new THREE.Vector3(0, 1, 0),
[Axis.Z]: new THREE.Vector3(1, 0, 0)
private movementDirection: Record<Axis, Vector3> = {
[Axis.X]: new Vector3(1, 0, 0),
[Axis.Y]: new Vector3(0, 1, 0),
[Axis.Z]: new Vector3(0, 0, 1)
};
constructor() {
@ -29,25 +33,25 @@
}
}
private createPlane(color: THREE.ColorRepresentation, axis: Axis) {
const geometry = new THREE.PlaneGeometry(1, 1);
const material = new THREE.MeshBasicMaterial({
private createPlane(color: ColorRepresentation, axis: Axis) {
const geometry = new PlaneGeometry(1, 1);
const material = new MeshBasicMaterial({
transparent: true,
opacity: 0.5,
color: color,
blendEquation: THREE.AddEquation,
side: THREE.DoubleSide
blendEquation: AddEquation,
side: DoubleSide
});
const mesh = new THREE.Mesh(geometry, material);
const mesh = new Mesh(geometry, material);
switch (axis) {
case Axis.X:
mesh.rotateOnAxis(new Vector3(0, 1, 0), Math.PI / 2);
break;
case Axis.Y:
mesh.rotateOnAxis(new THREE.Vector3(1, 0, 0), Math.PI / 2);
mesh.rotateOnAxis(new Vector3(1, 0, 0), Math.PI / 2);
break;
case Axis.Z:
mesh.rotateOnAxis(new THREE.Vector3(0, 1, 0), Math.PI / 2);
break;
}
@ -70,11 +74,20 @@
}
}
}
</script>
export let scale = 1;
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import { browser } from '$app/environment';
import { Axis } from '$lib/rendering/AxisRenderer';
import { colorBrewer } from '$lib/rendering/colors';
import { getGraphContext, type GraphService } from '$lib/views/CoreGraph.svelte';
const graphService: GraphService = getGraphContext();
export let scale = 1;
// normalized value between 0 and 1 indicating where to render x slice
export let x: number | undefined = undefined;
// normalized value between 0 and 1 indicating where to render y slice
@ -82,10 +95,6 @@
// normalized value between 0 and 1 indicating where to render y slice
export let z: number | undefined = undefined;
export let opacity = 0.5;
let xColor: THREE.ColorRepresentation = 0xff0000;
let yColor: THREE.ColorRepresentation = 0xff0000;
let sliceRenderer = new SliceSelectionRenderer();
$: sliceRenderer.setAxisValue(Axis.X, x);

View File

@ -1,9 +1,12 @@
<script lang="ts">
import { getContext, onDestroy, onMount } from 'svelte';
import type { GraphService, GraphUnsubscribe } from './types';
import { onDestroy, onMount } from 'svelte';
import { browser } from '$app/environment';
import Stats from 'stats.js';
import { getGraphContext } from '../BasicGraph.svelte';
import {
getGraphContext,
type GraphService,
type GraphUnsubscribe
} from '$lib/views/CoreGraph.svelte';
const graphService: GraphService = getGraphContext();
let stats: Stats;

View File

@ -0,0 +1,15 @@
<script lang="ts">
import type { Option } from '../DropdownSelect.svelte';
export let selected: boolean;
export let option: Option<{}>;
export let index: number;
</script>
<div class="inline-flex gap-2">
<div
class="flex-shrink-0 rounded-full border border-slate-800"
style={`background-color: ${option.label}; width: 17px; height: 17px`}
/>
{option.label}
</div>

View File

@ -6,6 +6,14 @@
parentLayer?: A;
}
export interface LayerColorSelectionEvent<A = object, B = object> {
layer: A | B;
index: number;
subIndex?: number;
parentLayer?: A;
color?: string;
}
interface BasicLayer {
name: string;
color?: string;
@ -18,6 +26,8 @@
</script>
<script lang="ts">
import type { DropdownSelectionEvent } from '../DropdownSelect.svelte';
import LayerItem from './LayerItem.svelte';
import { createEventDispatcher } from 'svelte';
@ -27,6 +37,7 @@
interface $$Events {
select: CustomEvent<LayerSelectionEvent<ParentLayer, ChildLayer>>;
color: CustomEvent<LayerColorSelectionEvent<ParentLayer, ChildLayer>>;
}
export var layerVisibility: LayerVisibilityList;
@ -34,6 +45,16 @@
export var selection: ParentLayer | ChildLayer | undefined = undefined;
const changeDispatch = createEventDispatcher();
const onColorSelection =
(index: number, layer: ChildLayer | ParentLayer, subLayer?: number) =>
(evt: DropdownSelectionEvent<unknown>) => {
changeDispatch('color', {
index,
layer,
color: evt.detail.selected.at(0)?.value as string | undefined
});
};
</script>
<ul>
@ -44,6 +65,7 @@
{visible}
selected={layer === selection}
name={layer.name}
on:select={onColorSelection(index, layer)}
color={layer.color}
on:click={() =>
changeDispatch('select', {

View File

@ -1,4 +1,12 @@
<script lang="ts">
import { PortalPlacement } from '$lib/actions/portal';
import { colorBrewer } from '$lib/rendering/colors';
import { EyeIcon, EyeOffIcon } from 'svelte-feather-icons';
import DropdownSelect, { type OptionConstructor } from '../DropdownSelect.svelte';
import { ButtonSize, ButtonVariant } from '../button/type';
import ColorDropdownItem from './ColorDropdownItem.svelte';
import Button from '../button/Button.svelte';
export let nested = false;
export let name: string;
export let color: string = '#eeeeee';
@ -6,34 +14,59 @@
export let selected = false;
let className: string | undefined = undefined;
export { className as class };
const colorOptionConstructor: OptionConstructor<string, string> = (value, index, meta) => {
return {
label: value,
value: value,
id: index
};
};
</script>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<li class={className}>
<!-- svelte-ignore a11y-no-static-element-interactions -->
<!-- svelte-ignore a11y-missing-attribute -->
<div
on:click|stopPropagation
class="flex gap-2 justify-between items-center cursor-pointer"
class:opacity-30={!visible}
class="flex justify-between items-center gap-2 px-1 rounded-md text-sm flex-shrink text-ellipsis overflow-hidden"
class:bg-orange-400={selected}
class:text-orange-800={selected}
>
<div class="flex gap-1 flex-shrink items-center">
{#if nested}<p
class="w-6 h-8 -mt-7 border-b border-l border-slate-300 pointer-events-none dark:border-background-700"
/>
{/if}
<p
class=" px-1 rounded-md text-sm flex-shrink text-ellipsis overflow-hidden"
class:bg-orange-400={selected}
class:text-orange-800={selected}
<div class="flex">
<DropdownSelect
singular
optionConstructor={colorOptionConstructor}
values={colorBrewer.Spectral[11]}
itemRenderer={ColorDropdownItem}
on:select
placement={PortalPlacement.TRAILING}
variant={ButtonVariant.LINK}
size={ButtonSize.SM}
>
{name}
</p>
<div
class="flex-shrink-0 rounded-full border border-slate-800"
style={`background-color: ${color}; width: ${nested ? 12 : 17}px; height: ${
nested ? 12 : 17
}px`}
/>
</DropdownSelect>
<a class="flex gap-2 justify-between items-center cursor-pointer" class:opacity-30={!visible}>
<div class="flex gap-1 flex-shrink items-center">
{#if nested}<p
class="w-6 h-8 -mt-7 border-b border-l border-slate-300 pointer-events-none dark:border-background-700"
/>
{/if}
<p>
{name}
</p>
</div>
</a>
</div>
<div
class="flex-shrink-0 rounded-full border border-slate-800"
style={`background-color: ${color}; width: ${nested ? 12 : 17}px; height: ${
nested ? 12 : 17
}px`}
/>
<Button on:click size={ButtonSize.SM} variant={ButtonVariant.LINK}>
{#if visible}<EyeIcon size="16" />{:else}<EyeOffIcon size="16" />{/if}
</Button>
</div>
<div>
<slot />

View File

@ -14,9 +14,12 @@
import { createEventDispatcher } from 'svelte';
import Label from '../base/Label.svelte';
import Tag from '../base/Tag.svelte';
import EditableText from '../EditableText.svelte';
export let disabled: boolean = false;
export let min: number = 0;
export let max: number = 100;
export let step: number = 1;
export let label: string | undefined = undefined;
export let displayFunction: SliderDisplayFunction = (v: number) => v + '';
@ -58,26 +61,38 @@
$: value = Math.max(min, Math.min(max, value)); // Ensure value stays within the min-max range
</script>
<div class="slider mb-4 mt-2">
<div class="flex justify-between items-center mb-2">
<Label
>{#if label !== undefined}{label}:
{/if}
</Label>
<Tag><span class="font-black">{displayFunction(value)}</span></Tag>
</div>
<div class="slider">
{#if label}<div class="flex justify-between items-center mb-2">
<Label
>{#if label !== undefined}{label}:
{/if}
</Label>
<EditableText
buttonWrap
on:change={(evt) => {
const v = parseInt(evt.detail.change);
if (Number.isNaN(v)) {
return;
}
value = Math.max(min, Math.min(max, v));
_onChange();
}}
value={displayFunction(value)}
/>
</div>
{/if}
<div class="flex items-center gap-1">
<Tag>{min}</Tag>
<input
{disabled}
type="range"
class="slider-input appearance-none w-full h-2 rounded border border-slate-300 bg-slate-200 dark:border-background-600 dark:bg-background-800 transition-opacity"
bind:value
{min}
{max}
on:selectstart={() => console.log('start')}
{step}
on:input={_onInput}
on:change={_onChange}
step="1"
/>
<Tag>{max}</Tag>
</div>

View File

@ -0,0 +1,211 @@
import { SingleAxis, Axis, type AxisLabelRenderer, type AxisOptions } from './AxisRenderer';
import * as THREE from 'three';
export interface AxisRendererOptions {
size: THREE.Vector3;
labelScale: number;
origin: THREE.Vector3;
labelForSegment?: AxisLabelRenderer;
segments?: number;
x: AxisOptions;
y: AxisOptions;
z: AxisOptions;
}
export const defaultAxisLabelOptions = {
color: '#cccccc',
font: 'Courier New',
fontSize: 100,
fontLineHeight: 1.2,
text: ''
};
export const defaultAxisOptions = {
lineWidth: 2,
lineColor: 0xcccccc,
label: defaultAxisLabelOptions
};
export const defaultAxisRendererOptions: AxisRendererOptions = {
size: new THREE.Vector3(1, 1, 1),
labelScale: 0.1,
origin: new THREE.Vector3(0, 0, 0),
x: {
...defaultAxisOptions,
textOptions: {
...defaultAxisLabelOptions
},
labelText: 'X',
segments: 10,
segmentSize: 0.5
},
y: {
...defaultAxisOptions,
textOptions: {
...defaultAxisLabelOptions
},
labelText: 'X',
labelRotation: Math.PI / 2
},
z: {
...defaultAxisOptions,
textOptions: {
...defaultAxisLabelOptions
},
labelText: 'Z'
}
};
export class AxesRenderer extends THREE.Object3D {
private options: AxisRendererOptions;
private mapAxis = new Map<Axis, SingleAxis[]>();
private axesSets: Record<Axis, THREE.Vector3[]> = {
[Axis.X]: [new THREE.Vector3(0, 0, 0), new THREE.Vector3(0, 0, 1)],
[Axis.Y]: [
new THREE.Vector3(1, 0, 0),
new THREE.Vector3(0, 0, 0),
new THREE.Vector3(1, 0, 1),
new THREE.Vector3(0, 0, 1)
],
[Axis.Z]: [new THREE.Vector3(0, 0, 0), new THREE.Vector3(1, 0, 0)]
};
private labelOffset: THREE.Vector2 = new THREE.Vector2(0.2, 0.2);
private sectionLabelOffset: THREE.Vector2 = new THREE.Vector2(0.05, 0.05);
constructor(options: Partial<AxisRendererOptions> = {}) {
super();
const initialOptions: AxisRendererOptions = {
...defaultAxisRendererOptions,
...options
};
this.options = initialOptions;
this.update(this.options);
}
onBeforeRender = (renderer: THREE.WebGLRenderer, scene: THREE.Scene, camera: THREE.Camera) => {
// hide axes that are in the background
// TODO: double loop can be avoided
this.mapAxis.forEach((axisGroup, axisDirection) => {
const cameraMatrix = camera.matrixWorldInverse;
let axisByDistance = axisGroup;
switch (axisDirection) {
case Axis.Z:
case Axis.X:
case Axis.Y: {
axisByDistance = axisGroup.sort(
(b, a) =>
a.position.clone().applyMatrix4(cameraMatrix).z -
b.position.clone().applyMatrix4(cameraMatrix).z
);
break;
}
}
axisByDistance.forEach((axis, index, list) => {
if (list.length < 2) {
return;
}
if (index !== (Axis.Y === axisDirection ? 1 : 0)) {
axis.visible = false;
return;
}
axis.visible = true;
axis.onBeforeRender(renderer, scene, camera);
});
});
};
private optionsForAxis(axis: Axis): AxisOptions {
return {
...defaultAxisRendererOptions[axis],
labelForSegment: this.options.labelForSegment ?? defaultAxisRendererOptions.labelForSegment,
labelScale: this.options.labelScale ?? defaultAxisRendererOptions.labelScale,
...(this.options[axis] ?? {})
};
}
update(_options: Partial<AxisLabelRenderer>) {
this.clear();
const options: AxisRendererOptions = {
...this.options,
..._options
};
this.options = options;
for (const axis of [Axis.X, Axis.Y, Axis.Z]) {
this.updateAxis(axis, this.optionsForAxis(axis));
// options[axis] = axisOptions;
// const singleAxis = new SingleAxis(axis, axisOptions);
// this.mapAxis.set(axis, singleAxis);
// this.add(singleAxis);
}
}
private getLabelOffset(axis: Axis, labelOffset: THREE.Vector2, edgeIndex: number): THREE.Vector3 {
switch (axis) {
case Axis.X:
return new THREE.Vector3(
0, //labelOffset.x * Math.pow(-1, edgeIndex),
-labelOffset.y,
-labelOffset.x * Math.pow(-1, edgeIndex)
);
case Axis.Y:
return new THREE.Vector3(
labelOffset.x * Math.pow(-1, edgeIndex) * 1,
0,
labelOffset.y * (edgeIndex < 2 ? -1 : 1) * 1
);
case Axis.Z:
return new THREE.Vector3(
-labelOffset.x * Math.pow(-1, edgeIndex),
-labelOffset.y,
0 //labelOffset.x * Math.pow(-1, edgeIndex),
);
}
}
updateAxis(axis: Axis, options: Partial<AxisOptions>) {
this.options[axis] = {
...this.options[axis],
labelScale: options.labelScale ?? defaultAxisRendererOptions.labelScale,
...options
};
if (this.mapAxis.has(axis)) {
this.mapAxis.get(axis)?.forEach((axis) => axis.removeFromParent());
}
this.mapAxis.set(
axis,
this.axesSets[axis].map((pos, edgeIdx) => {
const singleAxis = new SingleAxis(
axis,
this.getLabelOffset(axis, this.labelOffset, edgeIdx),
this.getLabelOffset(axis, this.sectionLabelOffset, edgeIdx).multiplyScalar(
axis === Axis.Y ? 1.5 : 1
),
{
...this.options[axis],
labelRotation: (this.options[axis].labelRotation ?? 0) * (2 * edgeIdx + 1)
}
);
singleAxis.position.set(pos.x, pos.y, pos.z);
this.add(singleAxis);
return singleAxis;
})
);
}
setup(): void {
this.clear();
}
destroy(): void {
this.removeFromParent();
this.remove();
this.clear();
}
}

View File

@ -1,4 +1,3 @@
import type { DeepPartial } from '$lib/store/filterStore/types';
import * as THREE from 'three';
import { MeshLine, MeshLineMaterial } from 'three.meshline';
import { TextTexture, type TextTextureOptions } from './textures/TextTexture';
@ -17,23 +16,14 @@ export type AxisLabelRenderer = (
export interface AxisOptions {
lineWidth: number;
lineColor: THREE.ColorRepresentation;
textOptions: TextTextureOptions;
textOptions?: Partial<TextTextureOptions>;
labelText: string;
segments?: number;
labelScale?: number;
labelForSegment?: AxisLabelRenderer;
segmentSize?: number;
}
export interface AxisRendererOptions {
size: THREE.Vector3;
labelScale: number;
origin: THREE.Vector3;
labelForSegment?: AxisLabelRenderer;
segments?: number;
x: AxisOptions;
y: AxisOptions;
z: AxisOptions;
labelRotation?: number;
segmentLabelRotation?: number;
}
export enum Axis {
@ -42,109 +32,183 @@ export enum Axis {
Z = 'z'
}
export const defaultAxisLabelOptions = {
color: 0xcccccc,
font: 'Courier New',
fontSize: 100,
fontLineHeight: 1.2,
text: ''
};
export const defaultAxisOptions = {
lineWidth: 2,
lineColor: 0xcccccc,
label: defaultAxisLabelOptions
};
const defaultAxisRendererOptions: AxisRendererOptions = {
size: new THREE.Vector3(1, 1, 1),
labelScale: 0.1,
origin: new THREE.Vector3(0, 0, 0),
x: {
...defaultAxisOptions,
textOptions: {
...defaultAxisLabelOptions
},
labelText: 'X',
segments: 10
},
y: {
...defaultAxisOptions,
textOptions: {
...defaultAxisLabelOptions
},
labelText: 'X'
},
z: {
...defaultAxisOptions,
textOptions: {
...defaultAxisLabelOptions
},
labelText: 'Z'
}
};
export class SingleAxis extends THREE.Group {
private static defaultSegmentSize = 0.0005;
private static defaultLabelSize = 0.001;
private static fontAspectRation = 3 / 4;
private options: AxisOptions;
private direction: THREE.Vector3;
private axis: Axis;
static directionForAxis(axis: Axis) {
switch (axis) {
case Axis.X:
return new THREE.Vector3(1, 0, 0);
case Axis.Y:
return new THREE.Vector3(0, 1, 0);
case Axis.Z:
return new THREE.Vector3(0, 0, 1);
}
}
public direction: THREE.Vector3;
get fontAspectRation() {
return SingleAxis.fontAspectRation;
}
axisMesh?: THREE.Mesh;
label?: THREE.Sprite;
segmentLines: THREE.Group = new THREE.Group();
segmentLabels: THREE.Group = new THREE.Group();
constructor(axis: Axis, options: AxisOptions) {
// edges are indices in clockwise direction starting
// Axis=x -> back, bottom
// Axis=y -> left, back (when looking at face X/Y)
constructor(
public axis: Axis,
public labelOffset: THREE.Vector3,
public segmentLabelOffset: THREE.Vector3,
public options: AxisOptions
) {
super();
this.axis = axis;
this.options = options;
switch (axis) {
case Axis.X:
this.direction = new THREE.Vector3(1, 0, 0);
break;
case Axis.Y:
this.direction = new THREE.Vector3(0, 1, 0);
break;
case Axis.Z:
this.direction = new THREE.Vector3(0, 0, 1);
break;
}
this.createAxis();
this.renderAxisSegments();
this.direction = SingleAxis.directionForAxis(axis);
this.update();
}
onBeforeRender = (
renderer: THREE.WebGLRenderer,
scene: THREE.Scene,
camera: THREE.Camera,
geometry: THREE.BufferGeometry<THREE.NormalBufferAttributes>,
material: THREE.Material,
group: THREE.Group
) => {
onBeforeRender = (renderer: THREE.WebGLRenderer, scene: THREE.Scene, camera: THREE.Camera) => {
const cameraDirection = new THREE.Vector3();
camera.getWorldDirection(cameraDirection);
const defaultNormal = this.direction;
// make labels transparent when angle is too sharp
const gridNormal = defaultNormal.clone().transformDirection(this.matrixWorld);
const dot = cameraDirection.dot(gridNormal);
const opacity = Math.max(1 - Math.pow(Math.abs(dot), 2), 0);
// TODO: if the leads to performance issues use other method
// represents the angle to the camera
const dot = cameraDirection.dot(this.direction.clone().transformDirection(this.matrixWorld));
// set opacity transparent if too steep
const opacity = Math.max(1 - Math.pow(Math.abs(dot), 4), 0);
if (this.label) {
this.label.material.opacity = opacity;
}
this.segmentLabels.children.forEach(
(child) => ((child as THREE.Sprite).material.opacity = opacity)
);
};
get fontAspectRation() {
return SingleAxis.fontAspectRation;
update() {
this.renderAxisLine();
this.renderAxisLabel();
this.renderAxisSegments();
}
private createAxis() {
private renderSegmentLabel(text: string, segmentIndex: number): THREE.Sprite {
const textWidth = text.length * this.fontAspectRation;
const numSegments = this.options.segments!;
const label = new THREE.Sprite(
new THREE.SpriteMaterial({
transparent: true,
depthWrite: false,
// rotation: Math.PI / 2,
map: new TextTexture(text ?? '', {
rotation: this.options.segmentLabelRotation,
...this.options.textOptions,
fontSize: (this.options.textOptions?.fontSize ?? SingleAxis.defaultSegmentSize) * 1
})
})
);
const labelOffset = this.direction.clone().multiplyScalar(segmentIndex / numSegments);
const labelScale = this.options.labelScale ?? SingleAxis.defaultSegmentSize;
const sizeScale = Math.min(16 / numSegments, 0.4);
label.position.copy(this.segmentLabelOffset).add(labelOffset);
label.scale.set(labelScale * textWidth, labelScale, labelScale).multiplyScalar(sizeScale);
return label;
}
private renderAxisSegments() {
if (!this.options.segments) {
return;
}
// remove old segments
this.segmentLabels.clear();
this.segmentLines.clear();
const segmentLineLength = 0.01;
const segmentLineDirection = new THREE.Vector3(
this.segmentLabelOffset.x < 0 ? -1 : this.segmentLabelOffset.x > 0 ? 1 : 0,
this.segmentLabelOffset.y < 0 ? -1 : this.segmentLabelOffset.y > 0 ? 1 : 0,
this.segmentLabelOffset.z < 0 ? -1 : this.segmentLabelOffset.z > 0 ? 1 : 0
).multiplyScalar(segmentLineLength);
const segmentStep = 1 / this.options.segments;
for (let i = 0; i <= this.options.segments; i++) {
// Render segment
const segmentLabelText = this.labelFormatter(i);
const geometry = new THREE.BufferGeometry().setFromPoints([
new THREE.Vector3(0, 0, 0),
// Get 90 deg angle to direction vector
segmentLineDirection.clone().multiplyScalar(segmentLabelText !== null ? 1.5 : 1)
]);
const meshLine = new MeshLine();
meshLine.setGeometry(geometry);
// Render segments
const material = new MeshLineMaterial({
color: this.options.lineColor,
lineWidth: this.options.lineWidth * (segmentLabelText !== null ? 1 : 0.5)
});
const segmentLine = new THREE.Mesh(meshLine.geometry, material);
const pos = this.direction.clone().multiplyScalar(segmentStep * i);
segmentLine.position.set(pos.x, pos.y, pos.z);
this.segmentLines.add(segmentLine);
// if label renderer returns undefined
if (segmentLabelText === null) {
continue;
}
const segmentLabel = this.renderSegmentLabel(segmentLabelText, i);
this.segmentLabels.add(segmentLabel);
}
this.add(this.segmentLabels);
this.add(this.segmentLines);
}
private renderAxisLabel() {
if (this.label) {
this.label.removeFromParent();
}
const labelText = this.options.labelText;
const spriteMaterial = new THREE.SpriteMaterial({
transparent: true,
depthWrite: false,
rotation: this.options.labelRotation,
map: new TextTexture(labelText ?? '', {
...this.options.textOptions
})
});
const label = new THREE.Sprite(spriteMaterial);
const labelScale = this.options.labelScale ?? SingleAxis.defaultLabelSize;
const labelOffset = this.direction.clone().multiplyScalar(0.5);
label.position.set(this.labelOffset.x, this.labelOffset.y, this.labelOffset.z).add(labelOffset);
const textWidth = labelText.length * this.fontAspectRation;
label.scale.set(labelScale * textWidth, labelScale, labelScale);
this.label = label;
this.add(label);
}
private renderAxisLine() {
if (this.axisMesh) {
this.axisMesh.removeFromParent();
}
const geometry = new THREE.BufferGeometry().setFromPoints([
new THREE.Vector3(0, 0, 0),
this.direction
@ -160,61 +224,6 @@ export class SingleAxis extends THREE.Group {
this.axisMesh = line;
this.add(line);
const labelText = `${this.options.labelText} [${this.axis}]`;
const spriteMaterial = new THREE.SpriteMaterial({
transparent: true,
depthWrite: false,
map: new TextTexture(labelText, this.options.textOptions)
});
const label = new THREE.Sprite(spriteMaterial);
const labelScale = this.options.labelScale ?? SingleAxis.defaultLabelSize;
const labelOffset = this.direction.clone().multiplyScalar(0.5);
label.position.set(
labelOffset.x === 0 ? -labelScale : labelOffset.x,
labelOffset.y === 0 ? -labelScale : labelOffset.y,
labelOffset.z === 0 ? -labelScale : labelOffset.z
);
const textWidth = labelText.length * this.fontAspectRation;
if (this.axis === Axis.Y) {
// FIXME: use rotation instead of magic constant
label.position.x = -0.1 - textWidth * 0.05;
}
label.scale.set(labelScale * textWidth, labelScale, labelScale);
this.label = label;
this.add(label);
}
renderSegmentLabel(text: string, segmentIndex: number): THREE.Sprite {
const textWidth = text.length * this.fontAspectRation;
const numSegments = this.options.segments!;
const label = new THREE.Sprite(
new THREE.SpriteMaterial({
transparent: true,
depthWrite: false,
map: new TextTexture(text, {
...this.options.textOptions,
fontSize: this.options.textOptions.fontSize * 0.5
})
})
);
const labelOffset = this.direction.clone().multiplyScalar(segmentIndex / numSegments);
const labelScale = this.options.labelScale ?? SingleAxis.defaultSegmentSize;
const sizeScale = 4 / numSegments;
const nonMainAxisOffset = (this.axis === Axis.Y ? 0.7 : 0.2) + 0.1 * sizeScale;
label.position.set(
labelOffset.x === 0 ? -nonMainAxisOffset * labelScale : labelOffset.x,
labelOffset.y === 0 ? -nonMainAxisOffset * labelScale : labelOffset.y,
labelOffset.z === 0 ? -nonMainAxisOffset * labelScale : labelOffset.z
);
label.scale.set(labelScale * textWidth, labelScale, labelScale).multiplyScalar(sizeScale);
return label;
}
private labelFormatter(segmentIndex: number) {
@ -231,97 +240,4 @@ export class SingleAxis extends THREE.Group {
return (segmentIndex / this.options.segments).toPrecision(2).toString();
}
renderAxisSegments() {
if (!this.options.segments) {
return;
}
// remove old segments
this.segmentLabels.clear();
this.segmentLines.clear();
const geometry = new THREE.BufferGeometry().setFromPoints([
new THREE.Vector3(0, 0, 0),
// Get 90 deg angle to direction vector
new THREE.Vector3(1, 0, 0)
]);
for (let i = 0; i <= this.options.segments; i++) {
// Render segments
const material = new MeshLineMaterial({
color: this.options.lineColor,
lineWidth: this.options.lineWidth
});
const segmentLine = new THREE.Mesh(geometry, material);
segmentLine.position.set(0, 0, 0);
// segmentLine.scale.set(scale.x, scale.y, scale.z);
this.segmentLines.add(segmentLine);
// Render segment
const segmentLabelText = this.labelFormatter(i);
// if label renderer returns undefined
if (segmentLabelText === null) {
continue;
}
const segmentLabel = this.renderSegmentLabel(segmentLabelText, i);
this.segmentLabels.add(segmentLabel);
}
this.add(this.segmentLabels);
this.add(this.segmentLines);
}
}
export class AxisRenderer extends THREE.Object3D {
private options: AxisRendererOptions;
private mapAxis = new Map<Axis, SingleAxis>();
constructor(options: Partial<AxisRendererOptions> = {}) {
super();
const initialOptions: AxisRendererOptions = {
...defaultAxisRendererOptions
};
for (const axis of [Axis.X, Axis.Y, Axis.Z]) {
const axisOptions: AxisOptions = {
...defaultAxisRendererOptions[axis],
labelForSegment: options.labelForSegment ?? defaultAxisRendererOptions.labelForSegment,
labelScale: options.labelScale ?? defaultAxisRendererOptions.labelScale,
...(options[axis] ?? {})
};
initialOptions[axis] = axisOptions;
const singleAxis = new SingleAxis(axis, axisOptions);
this.mapAxis.set(axis, singleAxis);
this.add(singleAxis);
}
this.options = initialOptions;
}
onBeforeRender = (
renderer: THREE.WebGLRenderer,
scene: THREE.Scene,
camera: THREE.Camera,
geometry: THREE.BufferGeometry<THREE.NormalBufferAttributes>,
material: THREE.Material,
group: THREE.Group
) => {
this.mapAxis.forEach((axisObj) =>
axisObj.onBeforeRender(renderer, scene, camera, geometry, material, group)
);
};
setup(): void {
this.clear();
}
destroy(): void {
this.removeFromParent();
this.remove();
this.clear();
}
}

View File

@ -72,7 +72,7 @@ export class BarRenderer extends GraphRenderer<BarData> {
return raycaster.intersectObjects(this.bars, true);
}
updateWithData(data: BarData) {
update(data: BarData) {
if (this.barGroup) {
this.scene?.remove(this.barGroup);
}

View File

View File

@ -1,6 +1,6 @@
import { Object3D, Vector2 } from 'three';
export abstract class GraphRenderer<T = unknown, InstanceMetaInfo = any> extends Object3D {
export abstract class GraphRenderer<T = unknown, InstanceMetaInfo = unknown> extends Object3D {
public scene: THREE.Scene | undefined = undefined;
public camera: THREE.Camera | undefined = undefined;
// public size: THREE.Vector3 = new Vector3(1, 1, 1);
@ -8,7 +8,12 @@ export abstract class GraphRenderer<T = unknown, InstanceMetaInfo = any> extends
abstract destroy(): void;
setup(renderContainer: HTMLElement, scene: THREE.Scene, camera: THREE.Camera) {
setup(
renderContainer: HTMLElement,
scene: THREE.Scene,
camera: THREE.Camera,
scale: number = 0.6
) {
this.scene = scene;
this.camera = camera;
this.renderContainer = renderContainer;
@ -16,7 +21,7 @@ export abstract class GraphRenderer<T = unknown, InstanceMetaInfo = any> extends
// Set initial size
const bounds = renderContainer.getBoundingClientRect();
// NOTE: apply reasonable scaling for graph may differ per graph implementation
const size = Math.min(bounds.width, bounds.height) * 0.6;
const size = Math.min(bounds.width, bounds.height) * scale;
this.scale.set(size, size, size);
// Center in screen
this.position.y = -0.5 * size;
@ -27,7 +32,7 @@ export abstract class GraphRenderer<T = unknown, InstanceMetaInfo = any> extends
* Used to update rendering based on data changes
* @param data
*/
abstract updateWithData(data: T, colorPalette?: THREE.ColorRepresentation[]): void;
abstract update(data: T, options: unknown, colorPalette?: THREE.ColorRepresentation[]): void;
abstract getInfoAtPoint(glPoint: Vector2): InstanceMetaInfo | undefined;
abstract selectionAtPoint(glPoint: Vector2): InstanceMetaInfo | undefined;
}

View File

@ -1,5 +1,4 @@
import {
BoxGeometry,
Mesh,
MeshBasicMaterial,
OrthographicCamera,
@ -7,29 +6,24 @@ import {
Scene,
Vector2,
WebGLRenderer,
Color,
Vector3,
DirectionalLight
EdgesGeometry,
LineSegments,
LineBasicMaterial,
Color,
ExtrudeGeometry,
Camera,
Group,
PlaneGeometry,
Shape,
DoubleSide
} from 'three';
import { AxisRenderer } from './AxisRenderer';
import * as THREE from 'three';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { Easing, Tween } from '@tweenjs/tween.js';
import { colorBrewer } from './colors';
const grayColorList = [
'#2B2B2B', // Charcoal Gray
'#3E3E3E', // Dark Gray
'#515151', // Gunmetal Gray
'#646464', // Medium Dark Gray
'#777777', // True Gray
'#8A8A8A', // Medium Gray
'#9D9D9D', // Silver Gray
'#B0B0B0', // Light Gray
'#C3C3C3', // Off White Gray
'#D6D6D6' // Very Light Gray
];
import type { CameraState } from '$lib/views/CoreGraph';
import { TextTexture } from './textures/TextTexture';
import type { ThemeColors } from '$lib/store/SettingsStore';
enum SelectionType {
side,
@ -38,11 +32,11 @@ enum SelectionType {
}
export class Minimap {
private scene!: Scene;
private orientationCube?: Mesh<BoxGeometry, MeshBasicMaterial[]>;
private orientationEdges?: THREE.Group;
private renderer: THREE.WebGLRenderer;
private camera: THREE.Camera;
private trackedCamera: THREE.Camera | undefined = undefined;
private orientationCube?: Group;
private orientationEdges?: Group;
private renderer: WebGLRenderer;
private camera: Camera;
private trackedCamera: Camera | undefined = undefined;
private raycaster = new Raycaster();
private stopped = false;
@ -50,15 +44,18 @@ export class Minimap {
private cubeSize = 20;
private bevelSize = 0.1;
public selectionColor = new Color(colorBrewer.RdYlBu[4][1]);
public color = new Color(grayColorList[9]);
public borderColor = new Color(grayColorList[3]);
public set updateColors(themeColors: ThemeColors) {
this.themeColors = themeColors;
this.update();
}
private mousePosition: THREE.Vector2 = new Vector2(0, 0);
private mouseClientPosition: THREE.Vector2 = new Vector2(0, 0);
private mousePosition: Vector2 = new Vector2(0, 0);
private mouseClientPosition: Vector2 = new Vector2(0, 0);
private mouseInside = false;
private mouseDownPos = { x: 0, y: 0 };
private mouseUpPos = { x: 0, y: 0 };
private sideNames = ['Z+', 'Z-', 'Y+', 'Y-', 'X+', 'X-'];
private controls!: OrbitControls;
private selection?: {
@ -66,7 +63,22 @@ export class Minimap {
index: number;
};
constructor(element: HTMLElement) {
private get selectedMesh(): Mesh<PlaneGeometry, MeshBasicMaterial> | null {
if (!this.selection) {
return null;
}
return (
(this.orientationCube?.children.at(this.selection.index) as Mesh<
PlaneGeometry,
MeshBasicMaterial
>) ?? null
);
}
constructor(
element: HTMLElement,
private themeColors: ThemeColors
) {
const bounds = element.getBoundingClientRect();
this.camera = new OrthographicCamera(
bounds.width / -8,
@ -80,8 +92,7 @@ export class Minimap {
this.setupEvents(element);
this.setupScene();
this.renderCube();
this.renderCubeEdges();
this.update();
// Setup renderer
this.renderer = new WebGLRenderer({ antialias: true, alpha: true });
@ -99,6 +110,17 @@ export class Minimap {
this.stopped = true;
}
public update() {
this.renderCube();
this.renderCubeEdges();
}
public setCameraState(state: CameraState) {
this.camera.position.copy(state.position);
this.camera.rotation.copy(state.rotation);
this.controls.update();
}
private onCanvasHover(event: MouseEvent) {
// Normalize mouse position
const bounds = this.renderer.domElement.getBoundingClientRect();
@ -113,7 +135,7 @@ export class Minimap {
this.lookAtSelection();
}
setupControls() {
private setupControls() {
this.controls = new OrbitControls(this.camera, this.renderer.domElement);
this.controls.rotateSpeed = 1;
this.controls.zoomSpeed = 1;
@ -154,34 +176,59 @@ export class Minimap {
private setupScene() {
this.scene = new Scene();
// side lines
// TODO: fixme render lines on top of cube
// Add light to scene
// const light = new DirectionalLight(0xffffff, 1);
// light.position.set(0, 0, 1);
}
private renderCube() {
const cubeGeometry = new BoxGeometry(1, 1, 1);
// let sideNames = ['Z+', 'Z-', 'Y+', 'Y-', 'X+', 'X-'];
const offset = 0.5 + this.bevelSize;
const planeDefinitions = [
[new Vector3(0, 0, offset), new Vector3(0, 0, 0), this.sideNames[0]],
[new Vector3(0, 0, -offset), new Vector3(0, Math.PI, 0), this.sideNames[1]],
[new Vector3(0, offset, 0), new Vector3(-Math.PI / 2, 0, 0), this.sideNames[2]],
[new Vector3(0, -offset, 0), new Vector3(Math.PI / 2, 0, 0), this.sideNames[3]],
[new Vector3(offset, 0, 0), new Vector3(0, -Math.PI / 2, 0), this.sideNames[4]],
[new Vector3(-offset, 0, 0), new Vector3(0, Math.PI / 2, 0), this.sideNames[5]]
] as [Vector3, Vector3, string][];
this.orientationCube?.clear();
this.orientationCube?.removeFromParent();
this.orientationCube = new Group();
const color = new Color(this.themeColors.surfaceColor);
const labelColor = new Color(this.themeColors.textSecondaryColor);
planeDefinitions.forEach(([position, orientation, label]) => {
const plane = new PlaneGeometry(1, 1);
const mesh = new Mesh(
plane,
new MeshBasicMaterial({
side: DoubleSide,
transparent: true,
map: new TextTexture(label, {
color: labelColor.getStyle(),
font: 'monospace',
width: 128,
height: 128,
fontSize: 50,
backgroundColor: color.getStyle()
})
})
);
mesh.userData = {
label: label
};
mesh.rotation.set(orientation.x, orientation.y, orientation.x);
mesh.position.set(position.x, position.y, position.z);
this.orientationCube?.add(mesh);
});
const materials = [
new MeshBasicMaterial({ color: this.color }),
new MeshBasicMaterial({ color: this.color }),
new MeshBasicMaterial({ color: this.color }),
new MeshBasicMaterial({ color: this.color }),
new MeshBasicMaterial({ color: this.color }),
new MeshBasicMaterial({ color: this.color })
];
this.orientationCube = new Mesh(cubeGeometry, materials);
this.orientationCube.scale.set(this.cubeSize, this.cubeSize, this.cubeSize);
this.scene.add(this.orientationCube);
}
private renderCubeEdges() {
// Create beveled edge for one edge (as an example)
const edgeShape = new THREE.Shape();
const edgeShape = new Shape();
edgeShape.moveTo(0, 0);
edgeShape.lineTo(this.bevelSize, 0);
edgeShape.lineTo(0.0, this.bevelSize);
@ -213,12 +260,24 @@ export class Minimap {
{ pos: [-0.5, -0.5, 0.5], rot: [-Math.PI / 2, 0, Math.PI] } // Front top edge
];
const orientationEdges = new THREE.Group();
const orientationEdges = new Group();
const color = new Color(this.themeColors.surfaceSecondaryColor);
const borderColor = new Color(this.themeColors.border);
const labelColor = new Color(this.themeColors.textSecondaryColor);
edgesPositionsRotations.forEach((edgeInfo, index) => {
const edgeGeometry = new THREE.ExtrudeGeometry(edgeShape, extrudeSettings);
const edgeMaterial = new THREE.MeshBasicMaterial({ color: this.color });
const edge = new THREE.Mesh(edgeGeometry, edgeMaterial);
const edgeGeometry = new ExtrudeGeometry(edgeShape, extrudeSettings);
const edgeMaterial = new MeshBasicMaterial({
// color: this._color,
map: new TextTexture('', {
color: labelColor.getStyle(),
width: 1,
height: 1,
fontSize: 1,
backgroundColor: color.getStyle()
})
});
const edge = new Mesh(edgeGeometry, edgeMaterial);
edge.position.set(...edgeInfo.pos).multiplyScalar(this.cubeSize);
edge.scale.multiplyScalar(this.cubeSize);
@ -228,10 +287,10 @@ export class Minimap {
orientationEdges.add(edge);
// Add outline
const edgeOutline = new THREE.EdgesGeometry(edgeGeometry);
const edgeOutlineMesh = new THREE.LineSegments(
const edgeOutline = new EdgesGeometry(edgeGeometry);
const edgeOutlineMesh = new LineSegments(
edgeOutline,
new THREE.LineBasicMaterial({ color: this.borderColor })
new LineBasicMaterial({ color: borderColor })
);
edgeOutlineMesh.position.copy(edge.position);
edgeOutlineMesh.scale.copy(edge.scale);
@ -254,108 +313,52 @@ export class Minimap {
// new Vector2(triangleSideSize, 0)
// ];
// const triangleShape = new THREE.Shape(points);
// const triangleGeo = new THREE.ShapeGeometry(triangleShape);
// const triangleShape = new Shape(points);
// const triangleGeo = new ShapeGeometry(triangleShape);
// const triangleMaterial = new THREE.MeshBasicMaterial({
// const triangleMaterial = new MeshBasicMaterial({
// color: 0x0edfee,
// side: THREE.DoubleSide
// side: DoubleSide
// });
// const triangleMesh = new THREE.Mesh(triangleGeo, triangleMaterial);
// const triangleMesh = new Mesh(triangleGeo, triangleMaterial);
// this.scene.add(triangleMesh);
}
private lookAtFaceDirection(cubeFaceIndex: number): THREE.Vector3 | null {
console.log('looking at side', cubeFaceIndex);
let lookDirection: THREE.Vector3 | null = null;
switch (cubeFaceIndex) {
case 0:
lookDirection = new Vector3(1, 0, 0);
break;
case 1:
lookDirection = new Vector3(-1, 0, 0);
break;
case 2:
lookDirection = new Vector3(0, 1, 0);
break;
case 3:
lookDirection = new Vector3(0, -1, 0);
break;
case 4:
lookDirection = new Vector3(0, 0, 1);
break;
case 5:
lookDirection = new Vector3(0, 0, -1);
break;
}
return lookDirection;
}
//
// Animation loop setup
//
private lookAtEdgeDirection(edgeIndex: number) {
console.log('looking at edge', edgeIndex);
switch (edgeIndex) {
case 0:
return new Vector3(0, 1, 1);
case 1:
return new Vector3(0, 1, -1);
case 2:
return new Vector3(-1, 1, 0);
case 3:
return new Vector3(1, 1, 0);
case 4:
return new Vector3(0, -1, 1);
case 5:
return new Vector3(0, -1, -1);
case 6:
return new Vector3(-1, -1);
case 7:
return new Vector3(1, -1);
}
return null;
}
private lookAtSelection() {
if (!this.selection) {
private startAnimationLoop() {
if (this.stopped) {
return;
}
let lookDirection: Vector3 | null = null;
if (this.trackedCamera) {
this.controls.update();
switch (this.selection.type) {
case SelectionType.side:
lookDirection = this.lookAtFaceDirection(this.selection.index);
break;
case SelectionType.edge:
lookDirection = this.lookAtEdgeDirection(this.selection.index);
break;
case SelectionType.corner:
// FIXME: implement corner selection
console.error('Not implemented');
// Update raycaster but only if mouse moved
this.raycaster.setFromCamera(this.mousePosition, this.camera);
this.updateSelection();
this.renderer.render(this.scene, this.camera);
// Set target camera to match the one we're tracking
this.trackedCamera.position.copy(this.camera.position);
this.trackedCamera.quaternion.copy(this.camera.quaternion);
}
if (lookDirection === null) {
return;
}
const initialLookAt = this.camera.position.clone();
const cameraTarget = lookDirection.multiplyScalar(300);
// Compute distance between current camera position and target to compute animation duration
const distance = initialLookAt.distanceTo(cameraTarget);
const duration = Math.min(200, distance * 2);
// Animate camera
new Tween(initialLookAt)
.to(cameraTarget, duration) // 2000 milliseconds
.easing(Easing.Cubic.In) // Easing type
.onUpdate(() => {
this.camera.position.set(initialLookAt.x, initialLookAt.y, initialLookAt.z);
// Called during the update of the tween. Useful if you need to perform actions during the animation.
})
.start();
requestAnimationFrame(this.startAnimationLoop.bind(this));
}
public setCurrentCamera(camera: Camera) {
this.trackedCamera = camera;
}
//
// Selection handling
//
private handleEdgeSelection(): boolean {
if (!this.orientationEdges) {
return false;
@ -366,7 +369,7 @@ export class Minimap {
}
const object = intersections.find((el) => el.object.userData.index !== undefined)
?.object as THREE.Mesh;
?.object as Mesh;
if (!object) {
return false;
}
@ -408,13 +411,15 @@ export class Minimap {
if (intersects.length === 0) {
return false;
}
const faceIndex = intersects[0].faceIndex;
if (faceIndex === undefined) {
const faceName = intersects[0].object.userData['label'];
if (faceName === undefined) {
return false;
}
const cubeFaceIndex = Math.floor(faceIndex / 2);
const cubeFaceIndex = this.sideNames.indexOf(faceName);
if (cubeFaceIndex === -1) {
return false;
}
// If selection matches current element do nothing just mark event as handled
if (
@ -436,11 +441,11 @@ export class Minimap {
}
private clearSelection() {
this.applyColorToSelectedObject(this.color);
this.applyColorToSelectedObject(new Color());
this.selection = undefined;
}
private applyColorToSelectedObject(color: THREE.Color) {
private applyColorToSelectedObject(color: Color) {
if (!this.selection) {
return;
}
@ -451,9 +456,11 @@ export class Minimap {
if (!this.orientationCube) {
break;
}
const material = this.orientationCube.material[this.selection.index];
material.color = color;
material.needsUpdate = true;
const material = this.selectedMesh?.material;
if (material) {
material.color = color;
material.needsUpdate = true;
}
break;
}
@ -464,11 +471,10 @@ export class Minimap {
const mesh = this.orientationEdges.children.find(
(el) => el.userData.index === this.selection?.index
) as Mesh<THREE.ExtrudeGeometry, MeshBasicMaterial> | undefined;
) as Mesh<ExtrudeGeometry, MeshBasicMaterial> | undefined;
if (!mesh) {
break;
}
mesh.material.color = color;
mesh.material.needsUpdate = true;
@ -482,7 +488,8 @@ export class Minimap {
}
private renderSelection() {
this.applyColorToSelectedObject(this.selectionColor);
const color = new Color(this.themeColors.selectionColor);
this.applyColorToSelectedObject(color);
}
private updateSelection() {
@ -504,29 +511,111 @@ export class Minimap {
this.clearSelection();
}
private startAnimationLoop() {
if (this.stopped) {
private lookAtFaceDirection(cubeFaceIndex: number): Vector3 | null {
// console.log('looking at side', cubeFaceIndex);
let lookDirection: Vector3 | null = null;
switch (cubeFaceIndex) {
case 0:
lookDirection = new Vector3(0, 0, 1);
break;
case 1:
lookDirection = new Vector3(0, 0, -1);
break;
case 2:
lookDirection = new Vector3(0, 1, 0);
break;
case 3:
lookDirection = new Vector3(0, -1, 0);
break;
case 4:
lookDirection = new Vector3(1, 0, 0);
break;
case 5:
lookDirection = new Vector3(-1, 0, 0);
break;
}
return lookDirection;
}
private lookAtEdgeDirection(edgeIndex: number) {
switch (edgeIndex) {
case 0:
return new Vector3(0, 1, 1);
case 1:
return new Vector3(0, 1, -1);
case 2:
return new Vector3(-1, 1, 0);
case 3:
return new Vector3(1, 1, 0);
case 4:
return new Vector3(0, -1, 1);
case 5:
return new Vector3(0, -1, -1);
case 6:
return new Vector3(-1, -1, 0);
case 7:
return new Vector3(1, -1, 0);
case 8:
return new Vector3(1, 0, 1);
case 9:
return new Vector3(1, 0, -1);
case 10:
return new Vector3(-1, 0, -1);
case 11:
return new Vector3(-1, 0, 1);
}
return null;
}
private lookAtSelection() {
if (!this.selection) {
return;
}
const initialDirection = new Vector3(0, 0, 0); // Default camera looking direction
initialDirection.applyQuaternion(this.camera.quaternion);
if (this.trackedCamera) {
this.controls.update();
let lookDirection: Vector3 | null = null;
// Update raycaster but only if mouse moved
this.raycaster.setFromCamera(this.mousePosition, this.camera);
this.updateSelection();
this.renderer.render(this.scene, this.camera);
// Set target camera to match the one we're tracking
this.trackedCamera.position.copy(this.camera.position);
this.trackedCamera.quaternion.copy(this.camera.quaternion);
switch (this.selection.type) {
case SelectionType.side:
lookDirection = this.lookAtFaceDirection(this.selection.index);
break;
case SelectionType.edge:
lookDirection = this.lookAtEdgeDirection(this.selection.index);
break;
case SelectionType.corner:
// FIXME: implement corner selection
console.error('Not implemented');
}
requestAnimationFrame(this.startAnimationLoop.bind(this));
}
if (lookDirection === null) {
return;
}
const initialPosition = this.camera.position.clone();
const cameraTargetPosition = lookDirection.multiplyScalar(300);
// const cameraTargetOrientation = new Quaternion().setFromEuler(new Euler(0, 0.5, 0));
// const initialOrientation = this.camera.quaternion.clone();
public setCurrentCamera(camera: THREE.Camera) {
this.trackedCamera = camera;
// Compute distance between current camera position and target to compute animation duration
const distance = initialPosition.distanceTo(cameraTargetPosition);
const duration = Math.min(Math.max(100, distance * 2), 200);
// Animate camera
new Tween(initialPosition)
.to(cameraTargetPosition, duration)
.easing(Easing.Cubic.In)
.onUpdate((value) => {
this.camera.position.set(value.x, value.y, value.z);
})
.start();
// new Tween(initialOrientation)
// .to(cameraTargetOrientation, duration)
// .easing(Easing.Cubic.In)
// .onUpdate((value) => {
// this.camera.quaternion.copy(value);
// })
// .start();
}
}

View File

@ -1,29 +1,48 @@
import * as THREE from 'three';
import type { ITiledDataRow } from '$lib/store/dataStore/filterActions';
import type { DataScaling, ILoadedTable } from '$lib/store/dataStore/types';
import {
Camera,
Color,
DoubleSide,
Group,
Mesh,
MeshStandardMaterial,
Raycaster,
Scene,
Vector2,
Vector3,
type ColorRepresentation
} from 'three';
import { GraphRenderer } from './GraphRenderer';
import { DataPlaneShapeGeometry } from './geometry/DataPlaneGeometry';
import { colorBrewer, graphColors } from './colors';
import { AxisRenderer, type AxisLabelRenderer } from './AxisRenderer';
import { MeshLine, MeshLineMaterial } from 'three.meshline';
import { Vector } from 'apache-arrow';
import { Theme } from '$lib/store/SettingsStore';
import { graphColors } from './colors';
import { DensePlaneGeometry } from './geometry/DensePlaneGeometry';
import { SelectablePointCloud } from './geometry/PointCloudGeometry';
import { SparsePlaneGeometry, type Point3D } from './geometry/SparsePlaneGeometry';
export interface IPlaneData {
points: number[][];
points: Point3D[];
min: number;
max: number;
name: string;
meta?: Record<string, unknown>;
// reference back to the original table
table: Readonly<ILoadedTable>;
meta?: Record<string, unknown> & {
rows: ITiledDataRow[];
};
color?: string;
// if set allows to render an additional set of layers
// belonging to this layers e.g. top layer: filter (bloom,...), child layers: mode (Naive, Sectorized,...)
layers?: IPlaneChildData[];
}
export type IPlaneChildData = Omit<IPlaneData, 'layers'> & { isChild: boolean };
export type IPlaneChildData = Omit<IPlaneData, 'layers'> & {
groupByValue: string;
isChild: boolean;
};
export type IChildPlaneData = Omit<IPlaneData, 'layers'>[];
const INTERSECTION_CHECK_LAYER = 1;
export const INTERSECTION_CHECK_LAYER = 1;
export interface IPlaneRendererData {
// A list of ordered planes (e.g. bottom to top)
@ -43,523 +62,294 @@ export interface IPlaneRendererData {
y: [number, number];
z: [number, number];
};
scales: {
x: DataScaling;
y: DataScaling;
z: DataScaling;
};
}
export enum PlaneTriangulation {
grid = 'grid',
delaunay = 'delaunay'
}
export enum DataDisplayType {
memory = 'memory',
cycled = 'cycles',
time = 'time',
percentage = 'percentage',
number = 'number'
}
export type IPlaneRenderOptions = {
xAxisDataType?: DataDisplayType;
yAxisDataType?: DataDisplayType;
zAxisDataType?: DataDisplayType;
triangulation: PlaneTriangulation;
showSelection: boolean;
pointCloudColor: ColorRepresentation;
pointCloudSize?: number;
} & Record<string, string>;
export interface IPlaneSelection {
x: number;
y: number;
z: number;
dataIndex: number;
layer: IPlaneData;
parent?: IPlaneData;
normalizedCoords: THREE.Vector3;
dbEntryId: number;
point: [number, number, number];
position: Vector3; // reference (for performance) to position of currently hovered point, must be cloned if altered
}
export class PlaneRenderer extends GraphRenderer<IPlaneRendererData, IPlaneSelection> {
public data?: IPlaneRendererData;
private grids?: THREE.Group;
private dataDepth = 0;
private dataWidth = 0;
private planeGroup: THREE.Group = new THREE.Group();
public static defaultRenderOptions(): IPlaneRenderOptions {
return {
pointCloudSize: 0.005,
triangulation: PlaneTriangulation.delaunay,
showSelection: true,
pointCloudColor: 0xeeeeee
};
}
private colorPalette: ColorRepresentation[] = [];
private planeGroup: Group = new Group();
private data?: IPlaneRendererData;
private raycaster = new Raycaster();
private get planes() {
return this.planeGroup.children as THREE.Group[];
return this.planeGroup.children as Group[];
}
private selectionMesh?: THREE.Mesh;
private selectionMeshX?: THREE.Mesh;
private selectionMeshZ?: THREE.Mesh;
private selectionMeshX2?: THREE.Mesh;
private selectionMeshZ2?: THREE.Mesh;
private min = 0;
private max = 0;
private axisLabelRenderer?: AxisLabelRenderer;
// factor used for normalizing data into range from 0, 1 along Y axis
// 1 / [maximum Y axis value in data]
// is NaN if used before data is loaded
private get yAxisNormalizationFactor() {
if (this.max === 0) {
return NaN;
}
return 1 / this.max;
}
private raycaster = new THREE.Raycaster();
private axisRenderer?: AxisRenderer;
constructor() {
constructor(private options: IPlaneRenderOptions = PlaneRenderer.defaultRenderOptions()) {
super();
console.log('Setup complete');
this.raycaster.layers.set(INTERSECTION_CHECK_LAYER);
}
onResize(evt: UIEvent): void {
console.log('Resizing plane renderer');
}
destroy(): void {
console.log('Destroying plane renderer');
this.cleanup();
this.scene?.remove(this);
}
setAxisLabelRenderer(renderer?: AxisLabelRenderer): void {
this.axisLabelRenderer = renderer;
cleanup(): void {
this.planeGroup.clear();
this.clear();
}
setup(renderContainer: HTMLElement, scene: THREE.Scene, camera: THREE.Camera): void {
super.setup(renderContainer, scene, camera);
setup(renderContainer: HTMLElement, scene: Scene, camera: Camera, scale: number): void {
super.setup(renderContainer, scene, camera, scale);
}
onBeforeRender = (
renderer: THREE.WebGLRenderer,
scene: THREE.Scene,
camera: THREE.Camera,
geometry: THREE.BufferGeometry<THREE.NormalBufferAttributes>,
material: THREE.Material,
group: THREE.Group
) => {
onBeforeRender = () => {
// Update axis renderer
this.axisRenderer?.onBeforeRender(renderer, scene, camera, geometry, material, group);
if (!this.grids) {
return;
}
const cameraDirection = new THREE.Vector3();
camera.getWorldDirection(cameraDirection);
const defaultNormal = new THREE.Vector3(0, 1, 0);
const grids = this.grids.children as THREE.GridHelper[];
// compute distance to camera and select 3th closest sides
const closestGrids = grids
.map((grid, idx) => [idx, grid.position.distanceTo(camera.position)])
.sort(([, a], [, b]) => a - b);
const gridsToHide = 3;
// hide two closest grids
for (let i = 0; i < grids.length; i++) {
const [idx, distance] = closestGrids[i];
const grid = grids[idx];
const material = grid.material;
let opacity = 0;
if (i >= gridsToHide) {
const gridNormal = defaultNormal.clone().transformDirection(grid.matrixWorld);
const dot = cameraDirection.dot(gridNormal);
opacity = Math.max(Math.abs(dot), 0);
}
if (Array.isArray(material)) {
material.forEach((mat) => {
mat.opacity = opacity;
mat.transparent = true;
mat.needsUpdate = true;
});
} else {
material.opacity = opacity;
material.transparent = true;
material.needsUpdate = true;
}
}
};
private currentSelection?: IPlaneSelection & {
mesh: THREE.InstancedMesh;
};
private renderLine(
start: THREE.Vector3,
end: THREE.Vector3,
color: THREE.ColorRepresentation,
width = 2
) {
const geometry = new THREE.BufferGeometry().setFromPoints([start, end]);
const meshLine = new MeshLine();
meshLine.setGeometry(geometry);
const material = new MeshLineMaterial({
color: color,
lineWidth: width
});
return new THREE.Mesh(meshLine.geometry, material);
onResize(): void {
console.log('Resizing plane renderer');
}
private renderSelectionLines(
selection?: PlaneRenderer['currentSelection'],
selectionMesh?: THREE.Mesh
) {
// cleanup old selection
this.selectionMeshX?.removeFromParent();
this.selectionMeshX = undefined;
this.selectionMeshZ?.removeFromParent();
this.selectionMeshZ = undefined;
this.selectionMeshX2?.removeFromParent();
this.selectionMeshX2 = undefined;
this.selectionMeshZ2?.removeFromParent();
this.selectionMeshZ2 = undefined;
if (!selection || !selectionMesh) {
return;
}
this.selectionMeshZ = this.renderLine(
new THREE.Vector3(selectionMesh.position.x, selectionMesh.position.y, -0.5),
selectionMesh.position.clone(),
0x00ff00
);
this.selectionMeshZ2 = this.renderLine(
new THREE.Vector3(selectionMesh.position.x, selectionMesh.position.y, -0.5),
new THREE.Vector3(selectionMesh.position.x, 0, -0.5),
0x00ff00
);
this.selectionMeshX = this.renderLine(
new THREE.Vector3(-0.5, selectionMesh.position.y, selectionMesh.position.z),
selectionMesh.position.clone(),
0xff00ff
);
this.selectionMeshX2 = this.renderLine(
new THREE.Vector3(-0.5, selectionMesh.position.y, selectionMesh.position.z),
new THREE.Vector3(-0.5, 0, selectionMesh.position.z),
0xff00ff
);
this.add(this.selectionMeshX, this.selectionMeshX2, this.selectionMeshZ, this.selectionMeshZ2);
}
getInfoAtPoint(glPoint: THREE.Vector2): IPlaneSelection | undefined {
if (!this.camera || !this.planeGroup) {
this.currentSelection = undefined;
if (this.selectionMesh) {
this.selectionMesh.visible = false;
}
this.renderSelectionLines();
return;
selectionAtPoint(glPoint: Vector2): IPlaneSelection | undefined {
if (!this.camera || !this.data) {
return undefined;
}
this.raycaster.setFromCamera(glPoint, this.camera);
this.raycaster.layers.set(INTERSECTION_CHECK_LAYER);
const intersection = this.raycaster.intersectObjects(this.planeGroup.children, true);
if (intersection.length === 0) {
this.currentSelection = undefined;
if (this.selectionMesh) {
this.renderSelectionLines();
this.selectionMesh.visible = false;
}
return;
return undefined;
}
const parent = intersection[0].object.parent;
if (!(parent instanceof SelectablePointCloud)) {
return undefined;
}
const item = intersection[0];
const instanceId = item.instanceId as number;
const mesh = item.object as THREE.InstancedMesh;
const pointCloud = intersection[0].object.parent as SelectablePointCloud;
// if selection did not change return previous selection
if (this.currentSelection && this.currentSelection.mesh === mesh) {
return this.currentSelection;
const data =
pointCloud.childIndex === undefined
? this.data.layers[pointCloud.index]
: this.data.layers[pointCloud.index].layers?.[pointCloud.childIndex!];
if (!data) {
// sanity check
return undefined;
}
const meshIndex = mesh.userData.index;
// if set layer is a child layer
const meshChildIndex = mesh.userData.childIndex;
const dataLayer = meshChildIndex
? this.data?.layers[meshIndex]?.layers?.[meshChildIndex]
: this.data?.layers[meshIndex];
if (!dataLayer) {
this.currentSelection = undefined;
return;
}
const x = instanceId % dataLayer.points.length;
const z = Math.floor(instanceId / dataLayer.points[0].length);
const y = dataLayer.points[z][x];
const normalizedCoords = new THREE.Vector3(
x / (dataLayer.points[0].length - 1),
y / this.max,
z / (dataLayer.points.length - 1)
);
const instanceId = intersection[0].instanceId!;
this.currentSelection = {
layer: dataLayer,
x,
y,
z,
mesh,
normalizedCoords,
parent: meshChildIndex ? this.data?.layers[meshIndex] : undefined
return {
dataIndex: instanceId,
point: data.points[instanceId],
layer: data,
parent: pointCloud.childIndex ? this.data.layers[pointCloud.index] : undefined,
dbEntryId: data.meta?.rows[instanceId].id ?? -1,
position: pointCloud.globalPositionOfInstance(instanceId)
};
// Update local selection
if (this.selectionMesh) {
this.selectionMesh.visible = true;
this.selectionMesh.position.set(
-0.5 + normalizedCoords.x,
normalizedCoords.y,
-0.5 + normalizedCoords.z
);
this.renderSelectionLines(this.currentSelection, this.selectionMesh);
}
return this.currentSelection;
}
private renderPlane(
planeData: IPlaneData,
index: number,
color: THREE.Color,
childIndex?: number
) {
const plane = planeData.points;
const geo = new DataPlaneShapeGeometry(plane, undefined, true);
const mat = new THREE.MeshLambertMaterial({
color: color,
opacity: 1,
depthWrite: true,
// clipIntersection: true,
// clipShadows: true,
side: THREE.DoubleSide
});
const mesh = new THREE.Mesh(geo, mat);
// Add metadata to mesh
mesh.userData = { index, name: planeData.name, meta: planeData.meta, childIndex };
this.dataDepth = geo.planeDims.depth;
this.dataWidth = geo.planeDims.width;
return mesh;
}
/**
* Renders visible Dots on data points and render invisible hit area
* @param layerGeometry
* @param index
* @param color
* @param subIndex
* @returns
*/
private renderPlaneDots(
layerGeometry: DataPlaneShapeGeometry,
index: number,
childIndex?: number,
color: THREE.ColorRepresentation = 0xeeeeff
): THREE.Group {
const pointBuffer = layerGeometry.buffer;
if (!pointBuffer) {
throw new Error(
'Cannot render layer dots without previously buffered DataPlaneShapeGeometry'
);
}
const group = new THREE.Group();
const sphereSize = 0.008;
const sphereGeo = new THREE.SphereGeometry(sphereSize);
const hitSphereGeo = new THREE.SphereGeometry(sphereSize * 2);
const sphereMat = new THREE.MeshPhongMaterial({
color: color,
depthWrite: false,
transparent: true,
opacity: 0.4
});
const hitSphereMat = new THREE.MeshBasicMaterial({ color: color, depthWrite: true });
const dotMesh = new THREE.InstancedMesh(sphereGeo, sphereMat, layerGeometry.pointsPerPlane);
const hitDotMesh = new THREE.InstancedMesh(
hitSphereGeo,
hitSphereMat,
layerGeometry.pointsPerPlane
);
// Set position of each dot
const matrix = new THREE.Matrix4();
const transparent = new THREE.Color(0x00000000);
const mainColor = new THREE.Color(0xffffff);
const yAxisScaleFactor = this.yAxisNormalizationFactor;
for (let i = 0; i < layerGeometry.pointsPerPlane; i++) {
const idx = i * DataPlaneShapeGeometry.pointComponentSize;
// Apply scale
// matrix.scale(one);
// if (pointBuffer[idx + 1] == 0) {
// matrix.scale(zero);
// }
matrix.setPosition(
pointBuffer[idx],
pointBuffer[idx + 1] * yAxisScaleFactor,
pointBuffer[idx + 2]
);
if (pointBuffer[idx + 1] == 0) {
dotMesh.setColorAt(i, transparent);
continue;
} else {
dotMesh.setColorAt(i, mainColor);
}
dotMesh.setMatrixAt(i, matrix);
hitDotMesh.setMatrixAt(i, matrix);
}
hitDotMesh.visible = false;
hitDotMesh.layers.set(INTERSECTION_CHECK_LAYER);
hitDotMesh.userData = { index, childIndex };
group.add(hitDotMesh, dotMesh);
return group;
}
setupSelection() {
const geo = new THREE.SphereGeometry(0.02);
const mat = new THREE.MeshBasicMaterial({
color: colorBrewer.Oranges[4][2],
transparent: true,
opacity: 0.8
});
this.selectionMesh = new THREE.Mesh(geo, mat);
this.selectionMesh.visible = false;
this.add(this.selectionMesh);
}
updateWithData(
update(
data: IPlaneRendererData,
colorPalette: THREE.ColorRepresentation[] = graphColors
options: IPlaneRenderOptions,
colorPalette: ColorRepresentation[] = graphColors
) {
this.options = options;
// Validate data
if (!data.layers.length) {
console.warn('No data provided');
return;
}
this.cleanup();
this.planeGroup = new THREE.Group();
this.setupSelection();
this.colorPalette = colorPalette;
this.data = data;
let globalMin = Infinity;
let globalMax = -Infinity;
const meshes: ReturnType<PlaneRenderer['renderPlane']>[] = new Array(data.layers.length);
const childLayers: ReturnType<PlaneRenderer['renderPlane']>[][] = new Array(data.layers.length);
const yAxisMaxRange = this.data!.ranges.y[1];
this.planeGroup = new Group();
for (const [index, planeData] of data.layers.entries()) {
globalMax = Math.max(globalMax, planeData.max);
globalMin = Math.min(globalMin, planeData.min);
const color = new THREE.Color(planeData.color ?? colorPalette[index % colorPalette.length]);
meshes[index] = this.renderPlane(planeData, index, color);
const layerGroup = new Group();
const parentLayerGroup = this.renderPlane(
planeData,
index,
data.tileRange.x,
data.tileRange.z,
yAxisMaxRange,
undefined,
options.showSelection
);
childLayers[index] =
planeData.layers?.map((childData, childIndex) => {
const color = new THREE.Color(
childData.color ?? colorPalette[index % colorPalette.length]
// render sub-layers
const childLayerGroup = new Group();
if (planeData.layers) {
for (const [childIndex, subPlaneData] of planeData.layers.entries()) {
const subLayerGroup = this.renderPlane(
subPlaneData,
index,
data.tileRange.x,
data.tileRange.z,
yAxisMaxRange,
childIndex,
options.showSelection
);
return this.renderPlane(childData, index, color, childIndex);
}) ?? [];
}
this.min = globalMin;
this.max = globalMax;
const dataScaleFactor = 1 / globalMax;
// Render dots and scale layers
const dotMeshes = meshes.map((planeMesh, index) => {
const geo = planeMesh.geometry as DataPlaneShapeGeometry;
if (!geo) {
throw Error('Plane mesh be initialized before dot geometry can be created');
childLayerGroup.add(subLayerGroup);
}
}
layerGroup.add(parentLayerGroup);
layerGroup.add(childLayerGroup);
return this.renderPlaneDots(geo, index);
});
// Combine meshes and dotmeshes to create layer groups
for (const [i, mesh] of meshes.entries()) {
const group = new THREE.Group();
const parentLayerGroup = new THREE.Group();
// set scaling
// - only scale layers
mesh.scale.y = dataScaleFactor;
parentLayerGroup.add(mesh);
parentLayerGroup.add(dotMeshes[i]);
// add reference to hit test dot mesh to simplify structure changes
parentLayerGroup.userData['hitMesh'] = dotMeshes[i].children[0];
group.add(parentLayerGroup);
// render and scale child layers
const childLayerGroup = new THREE.Group();
if (childLayers[i].length !== 0) {
// Render selection dots for child layers
childLayerGroup.add(
...childLayers[i].map((childMesh, subIndex) => {
const childGroup = new THREE.Group();
const geo = childMesh.geometry as DataPlaneShapeGeometry;
if (!geo) {
throw Error('Plane mesh must be initialized before dot geometry can be created');
}
const dotMesh = this.renderPlaneDots(geo, i, subIndex);
childMesh.scale.y = dataScaleFactor;
childGroup.add(childMesh);
childGroup.add(dotMesh);
// Hide initially
childGroup.visible = false;
dotMesh.children[0].layers.disable(INTERSECTION_CHECK_LAYER);
childGroup.userData['hitMesh'] = dotMesh.children[0];
return childGroup;
})
);
}
group.add(childLayerGroup);
this.planeGroup.add(group);
// insert layer construct into parent layer group
this.planeGroup.add(layerGroup);
}
// Move plane group to be centered at 0,0,0
this.planeGroup.position.set(-0.5, 0, -0.5);
this.add(this.planeGroup);
this.setupGridHelper();
this.setupAxisRenderer();
// this.setScale(this.scale);
}
cleanup(): void {
// Remove axis renderer
if (this.axisRenderer) {
this.axisRenderer.destroy();
this.axisRenderer = undefined;
private planeGeometry(plane: IPlaneData) {
switch (this.options.triangulation) {
case 'grid':
return new DensePlaneGeometry(plane.points);
case 'delaunay':
return new SparsePlaneGeometry(plane.points);
}
this.planeGroup.clear();
this.grids?.clear();
this.clear();
}
private renderPlane(
planeData: IPlaneData,
index: number,
width: number,
depth: number,
maxHeight: number,
childIndex?: number,
renderPointCloud: boolean = false
) {
const group = new Group();
group.renderOrder = index * 100 - (childIndex ?? 0);
const geo = this.planeGeometry(planeData);
const normFactorX = 1 / width;
const normFactorZ = 1 / depth;
const normFactorY = 1 / maxHeight;
const mat = new MeshStandardMaterial({
color: this.colorForPlane(planeData, childIndex ?? index),
depthWrite: true,
// wireframe: true,
// clipIntersection: true,
// clipShadows: true,
side: DoubleSide
});
const mesh = new Mesh(geo, mat);
mesh.scale.multiply(new Vector3(normFactorX, normFactorY, normFactorZ));
// Add metadata to mesh
mesh.userData = { index, name: planeData.name, meta: planeData.meta, childIndex };
group.add(mesh);
if (renderPointCloud) {
const pointMesh = this.renderSelectionPoints(
planeData.points,
this.options.pointCloudColor,
maxHeight,
index,
childIndex
);
if (pointMesh) {
group.add(pointMesh);
}
group.userData['hitMesh'] = pointMesh;
}
return group;
}
private renderSelectionPoints(
points: Point3D[],
color: ColorRepresentation = 0xeeeeff,
yAxisRange: number,
layerIndex: number,
childLayerIndex?: number
): SelectablePointCloud | null {
if (!this.data) {
return null;
}
// compute x and z scales since we cannot use
// non uniform scaling -> affects circle proportions
const xScaler = (x: number) => x / this.data!.tileRange.x;
const yScaler = (y: number) => y / yAxisRange;
const zScaler = (z: number) => z / this.data!.tileRange.z;
const visibleRadius =
this.options.pointCloudSize ??
Math.max((Math.min(Math.min(this.data.tileRange.x) / 4), 0.01), 0.001);
return new SelectablePointCloud(
points,
new Color(color),
visibleRadius,
1 / this.data.tileRange.x / 4,
layerIndex,
childLayerIndex,
xScaler,
yScaler,
zScaler
);
}
////////////////////////////////
// PlaneRenderer specific methods
/////////////////////////////////
private setLayerHitTest(enabled: boolean, layer: Object3D) {
const hitMesh = layer.userData['hitMesh'] as unknown as SelectablePointCloud;
if (!hitMesh || !(hitMesh instanceof SelectablePointCloud)) {
return;
}
hitMesh.setHitTest(enabled);
}
toggleLayerVisibility(layerIndex: number): boolean {
const layer = this.planes[layerIndex].children[0];
layer.visible = !layer.visible;
const hitMesh = layer.userData?.['hitMesh'] as THREE.Mesh | undefined;
if (hitMesh) {
if (layer.visible) {
hitMesh.layers.enable(INTERSECTION_CHECK_LAYER);
} else {
hitMesh.layers.disable(INTERSECTION_CHECK_LAYER);
}
}
this.setLayerHitTest(layer.visible, layer);
return layer.visible;
}
@ -570,15 +360,8 @@ export class PlaneRenderer extends GraphRenderer<IPlaneRendererData, IPlaneSelec
}
const layer = group.children[sublayerIndex];
layer.visible = !layer.visible;
const hitMesh = layer.userData?.['hitMesh'] as THREE.Mesh | undefined;
if (hitMesh) {
if (layer.visible) {
hitMesh.layers.enable(INTERSECTION_CHECK_LAYER);
} else {
hitMesh.layers.disable(INTERSECTION_CHECK_LAYER);
}
}
this.setLayerHitTest(layer.visible, layer);
return layer.visible;
}
@ -593,14 +376,13 @@ export class PlaneRenderer extends GraphRenderer<IPlaneRendererData, IPlaneSelec
showAllLayers(): void {
this.planes.forEach((plane) => {
plane.children[0].visible = true;
// prevent layer from being hit by hit-test
plane.layers.enable(INTERSECTION_CHECK_LAYER);
this.setLayerHitTest(true, plane.children[0]);
// Hide all sublayers
plane.children[1].children.forEach((plane) => {
plane.visible = true;
// prevent layer from being hit by hit-test
plane.layers.enable(INTERSECTION_CHECK_LAYER);
this.setLayerHitTest(true, plane.children[0]);
});
});
}
@ -608,8 +390,7 @@ export class PlaneRenderer extends GraphRenderer<IPlaneRendererData, IPlaneSelec
hideAllLayers(): void {
this.planes.forEach((plane) => {
plane.children[0].visible = false;
// prevent layer from being hit by hit-test
plane.layers.disable(INTERSECTION_CHECK_LAYER);
this.setLayerHitTest(false, plane.children[0]);
// Hide all sublayers
plane.children[1].children.forEach((plane) => {
@ -620,96 +401,7 @@ export class PlaneRenderer extends GraphRenderer<IPlaneRendererData, IPlaneSelec
});
}
////////////////////////////////
// Private helpers
/////////////////////////////////
private setupAxisRenderer() {
this.axisRenderer = new AxisRenderer({
// labelScale: 10,
size: new THREE.Vector3(1, 1, 1),
labelScale: 0.075,
labelForSegment: this.axisLabelRenderer,
x: {
labelText: this.data?.labels?.x ?? 'x',
segments: this.dataWidth - 1
},
y: {
labelText: this.data?.labels?.y ?? 'y',
segments: this.dataWidth - 1
},
z: {
labelText: this.data?.labels?.z ?? 'z',
segments: this.dataDepth - 1
}
});
// center axis ar (0,0,0)
this.axisRenderer.position.set(-0.5, 0, -0.5);
this.add(this.axisRenderer);
}
private createGrid(baseScale = 1, overlapFactor = 1) {
const numWidthTiles = this.dataWidth - 1;
const numDepthTiles = this.dataDepth - 1;
const isWidthSmaller = numWidthTiles < numDepthTiles;
const largerSide = isWidthSmaller ? numDepthTiles : numWidthTiles;
const gridHelper = new THREE.GridHelper(
baseScale * overlapFactor,
largerSide * overlapFactor,
0x888888,
0x888888
);
// Offset grid by half of the size
// this.gridHelper.position.x = 1 / numWidthTiles;
// this.gridHelper.position.z = -1 / numDepthTiles
// FIXME: random missalignment with some X/Z proportions
// Scale other axis to match
if (isWidthSmaller) {
const zSegmentSize = baseScale / largerSide / 2;
const xSegmentSize = zSegmentSize * (numDepthTiles / numWidthTiles);
gridHelper.scale.x = numDepthTiles / numWidthTiles;
// this.gridHelper.position.x = -xSegmentSize;
// this.gridHelper.position.z = -zSegmentSize;
} else {
const xSegmentSize = baseScale / largerSide;
const zSegmentSize = xSegmentSize * (numWidthTiles / numDepthTiles);
gridHelper.scale.z = numWidthTiles / numDepthTiles;
// this.gridHelper.position.z = -zSegmentSize;
// this.gridHelper.position.x = -xSegmentSize;
}
return gridHelper;
}
private setupGridHelper() {
if (this.grids) {
this.grids.clear();
} else {
this.grids = new THREE.Group();
}
// Draw a grid for each side
const orientations = [
[new THREE.Vector3(0, 1, 0), new THREE.Vector3(0, 1, 0)],
[new THREE.Vector3(0, 0, 0), new THREE.Vector3(0, 0, 0)],
[new THREE.Vector3(1, 0, 0), new THREE.Vector3(0, 0.5, -0.5)],
[new THREE.Vector3(0, 0, 1), new THREE.Vector3(-0.5, 0.5, 0)],
[new THREE.Vector3(-1, 0, 0), new THREE.Vector3(0, 0.5, 0.5)],
[new THREE.Vector3(0, 0, 1), new THREE.Vector3(0.5, 0.5, 0)]
];
for (const [orientation, offset] of orientations) {
const grid = this.createGrid();
grid.setRotationFromAxisAngle(orientation, Math.PI / 2);
grid.position.set(offset.x, offset.y, offset.z);
this.grids.add(grid);
}
this.add(this.grids);
colorForPlane(planeData: IPlaneData, index: number): Color {
return new Color(planeData.color ?? this.colorPalette[index % this.colorPalette.length]);
}
}

View File

@ -1,17 +1,19 @@
import * as THREE from 'three';
import { BufferAttribute, BufferGeometry, Uint32BufferAttribute } from 'three';
type Data = number[][];
export class DataPlaneShapeGeometry extends THREE.BufferGeometry {
export class DataPlaneShapeGeometry extends BufferGeometry {
static readonly pointComponentSize = 3;
private normalizedData: Data;
private previousNormalizedData: Data | undefined = undefined;
private width: number;
private depth: number;
private dataWidth: number;
private dataDepth: number;
public width: number;
public depth: number;
// tesselation used to interpolate between data points
// e.g. is set to 2 num polygonPoints = (2 * width - 1) * (2 * height - 1)
private tesselationFactor = 1;
// number of points to insert between two points in input data range
private tesselationFactor = 0;
private indexBuffer: Uint32Array | undefined = undefined;
private pointBuffer: Float32Array | undefined = undefined;
@ -68,12 +70,13 @@ export class DataPlaneShapeGeometry extends THREE.BufferGeometry {
this.previousNormalizedData = previousData;
}
}
this.depth = this.normalizedData[0].length;
this.width = this.normalizedData.length;
this.width = 0;
this.depth = 0;
this.dataDepth = this.normalizedData[0].length;
this.dataWidth = this.normalizedData.length;
if (previousData) {
if (previousData.length !== this.depth || previousData[0].length !== this.width) {
if (previousData.length !== this.dataDepth || previousData[0].length !== this.dataWidth) {
throw new Error('Previous data has different dimensions');
}
}
@ -184,8 +187,61 @@ export class DataPlaneShapeGeometry extends THREE.BufferGeometry {
}
}
private interpolate2DArray(
originalArray: number[][],
pointsX: number,
pointsY: number
): number[][] {
if (pointsX == 0 && pointsY == 0) {
return originalArray;
}
const originalHeight = originalArray.length;
const originalWidth = originalArray[0].length;
const interpolatedHeight = originalHeight + (originalHeight - 1) * pointsY;
const interpolatedWidth = originalWidth + (originalWidth - 1) * pointsX;
// Initialize the interpolated array with predefined size
const interpolatedArray: number[][] = new Array(interpolatedHeight)
.fill(null)
.map(() => new Array(interpolatedWidth).fill(0));
// Function to linearly interpolate between two values
function linearInterpolate(value1: number, value2: number, factor: number): number {
return value1 + (value2 - value1) * factor;
}
for (let y = 0; y < originalHeight - 1; y++) {
for (let x = 0; x < originalWidth - 1; x++) {
for (let dy = 0; dy <= pointsY; dy++) {
for (let dx = 0; dx <= pointsX; dx++) {
const interpolatedValue = linearInterpolate(
linearInterpolate(originalArray[y][x], originalArray[y][x + 1], dx / pointsX),
linearInterpolate(originalArray[y + 1][x], originalArray[y + 1][x + 1], dx / pointsX),
dy / pointsY
);
if (!interpolatedArray[y * (pointsY + 1) + dy]) {
interpolatedArray[y * (pointsY + 1) + dy] = [];
}
interpolatedArray[y * (pointsY + 1) + dy][x * (pointsX + 1) + dx] = interpolatedValue;
}
}
}
}
return interpolatedArray;
}
private computeGeometry() {
const { normalizedData, width, depth, pointsPerPlane } = this;
const { normalizedData: origData, width: origWidth, depth: origDepth } = this;
const tesselationFactor = Math.max(Math.round(this.tesselationFactor), 0);
const normalizedData = this.interpolate2DArray(origData, tesselationFactor, tesselationFactor);
this.width = normalizedData.length;
this.depth = normalizedData[0].length;
this.setupBuffers();
@ -194,15 +250,14 @@ export class DataPlaneShapeGeometry extends THREE.BufferGeometry {
throw new Error('Buffers not initialized');
}
const width = this.width;
const depth = this.depth;
const pointsPerPlane = width * depth;
const vertices = this.pointBuffer;
const indices = this.indexBuffer;
const bufferElementSize = 3;
const tesselationFactor = Math.max(Math.round(this.tesselationFactor), 1);
const numPolygons =
(width * tesselationFactor - 1) *
(depth * tesselationFactor - 1) *
(this.drawsBottom ? 2 : 1);
const numPolygons = (width - 1) * (depth - 1) * (this.drawsBottom ? 2 : 1);
const hasBottomLayer = this.previousNormalizedData !== undefined;
@ -317,9 +372,9 @@ export class DataPlaneShapeGeometry extends THREE.BufferGeometry {
}
this.setAttribute(
'position',
new THREE.BufferAttribute(vertices, DataPlaneShapeGeometry.pointComponentSize)
new BufferAttribute(vertices, DataPlaneShapeGeometry.pointComponentSize)
);
this.setIndex(new THREE.Uint32BufferAttribute(indices, 1));
this.setIndex(new Uint32BufferAttribute(indices, 1));
this.computeVertexNormals();
}
}

View File

@ -0,0 +1,85 @@
import { BufferAttribute, BufferGeometry, Uint32BufferAttribute } from 'three';
import { identity } from './transformers';
export type Point2D = [number, number];
export type Point3D = [number, number, number];
export class DensePlaneGeometry extends BufferGeometry {
static readonly pointComponentSize = 3;
private pointBuffer: Float32Array;
constructor(
values: [number, number, number][],
scaleX: (x: number) => number = identity,
scaleY: (y: number) => number = identity,
scaleZ: (z: number) => number = identity,
skipsEmptyCells: boolean = true
) {
super();
const [width, depth] = values.reduce(
([w, d], [x, z]) => [Math.max(w, x + 1), Math.max(d, z + 1)],
[0, 0]
);
// Transform rows into a 2D array for display
// const data: number[][] = new Array(width).fill(-1).map(() => new Array(depth).fill(-1));
this.pointBuffer = new Float32Array(width * depth * 3).fill(-1);
// set points in dense arr
values.forEach(([x, z, y]) => {
const pointIdx = (z * width + x) * DensePlaneGeometry.pointComponentSize;
this.pointBuffer[pointIdx] = scaleX(x);
this.pointBuffer[pointIdx + 1] = scaleY(y);
this.pointBuffer[pointIdx + 2] = scaleZ(z);
});
// TODO: interpolate holes in data
// construct polygons
const numPolygons = (width - 1) * (depth - 1) * 2 * 3;
const triangleIndexBuffer = new Uint32Array(numPolygons);
for (let z = 0; z < depth; z++) {
for (let x = 0; x < width; x++) {
const pointIdx = z * width + x;
const vertexIdx = pointIdx * DensePlaneGeometry.pointComponentSize;
const indexIdx = (z * (width - 1) + x) * 6;
// console.log({x, z, pointIdx, vertexIdx, indexIdx});
// Add top plane coordinates
this.pointBuffer[vertexIdx] = scaleX(x); //x
// this.pointBuffer[vertexIdx + 1] = 5;//normalizedData[z][x]; //y
this.pointBuffer[vertexIdx + 2] = scaleZ(z); //z
if (this.pointBuffer[vertexIdx + 1] == -1) {
this.pointBuffer[vertexIdx + 1] = 0;
if (skipsEmptyCells) {
triangleIndexBuffer[indexIdx] = 0;
triangleIndexBuffer[indexIdx + 1] = 0;
triangleIndexBuffer[indexIdx + 2] = 0;
triangleIndexBuffer[indexIdx + 3] = 0;
triangleIndexBuffer[indexIdx + 4] = 0;
triangleIndexBuffer[indexIdx + 5] = 0;
continue;
}
}
triangleIndexBuffer[indexIdx] = pointIdx + width;
triangleIndexBuffer[indexIdx + 1] = pointIdx + 1;
triangleIndexBuffer[indexIdx + 2] = pointIdx;
triangleIndexBuffer[indexIdx + 3] = pointIdx + width;
triangleIndexBuffer[indexIdx + 4] = pointIdx + width + 1;
triangleIndexBuffer[indexIdx + 5] = pointIdx + 1;
}
}
this.setAttribute(
'position',
new BufferAttribute(this.pointBuffer, DensePlaneGeometry.pointComponentSize, true)
);
// Create buffers for rendering
this.setIndex(new Uint32BufferAttribute(triangleIndexBuffer, 1));
this.computeVertexNormals();
}
}

View File

@ -0,0 +1,61 @@
import {
Color,
BufferGeometry,
LineBasicMaterial,
LineSegments,
Float32BufferAttribute
} from 'three';
class EnhancedGridHelper extends LineSegments<BufferGeometry, LineBasicMaterial> {
constructor(size = 1, divisionsX = 10, divisionsZ = 30, color1 = 0x888888, color2 = 0x888888) {
const c1 = new Color(color1);
const c2 = new Color(color2);
const halfSize = size / 2;
const centerX = divisionsX / 2;
const stepX = size / divisionsX;
const centerZ = divisionsZ / 2;
const stepZ = size / divisionsZ;
const verticesX = [],
colorsX: number[] = [];
const verticesZ = [],
colorsZ: number[] = [];
for (let i = 0, j = 0, k = -halfSize; i <= divisionsX; i++, k += stepX) {
verticesX.push(-halfSize, 0, k, halfSize, 0, k);
const color = i === centerX ? c1 : c2;
color.toArray(colorsX, j);
j += 3;
color.toArray(colorsX, j);
j += 3;
}
for (let i = 0, j = 0, k = -halfSize; i <= divisionsZ; i++, k += stepZ) {
verticesZ.push(k, 0, -halfSize, k, 0, halfSize);
const color = i === centerZ ? c1 : c2;
color.toArray(colorsZ, j);
j += 3;
color.toArray(colorsZ, j);
j += 3;
}
const vertices = [...verticesX, ...verticesZ];
const colors = [...colorsX, ...colorsZ];
const geometry = new BufferGeometry();
geometry.setAttribute('position', new Float32BufferAttribute(vertices, 3));
geometry.setAttribute('color', new Float32BufferAttribute(colors, 3));
const material = new LineBasicMaterial({ vertexColors: true, toneMapped: false });
super(geometry, material);
}
dispose() {
this.geometry.dispose();
this.material.dispose();
}
}
export default EnhancedGridHelper;

View File

@ -0,0 +1,74 @@
import * as THREE from 'three';
import { identity } from './transformers';
import type { Point3D } from './SparsePlaneGeometry';
import { INTERSECTION_CHECK_LAYER } from '../PlaneRenderer';
export class SelectablePointCloud extends THREE.Group {
private boxMesh?: THREE.InstancedMesh;
private dotMesh?: THREE.InstancedMesh;
constructor(
values: Point3D[],
color: THREE.Color,
radius: number,
hitBoxWidth: number,
public index: number,
public childIndex?: number,
public scaleX: (x: number) => number = identity,
public scaleY: (y: number) => number = identity,
public scaleZ: (z: number) => number = identity
) {
super();
const sphere = new THREE.SphereGeometry(radius);
const sphereMaterial = new THREE.MeshPhongMaterial({
color: color,
transparent: true,
opacity: 0.4
});
const hitBox = new THREE.BoxGeometry(hitBoxWidth, hitBoxWidth, hitBoxWidth);
const hitBoxMaterial = new THREE.MeshPhongMaterial({
color: color,
opacity: 0.5
// transparent: true
});
const dotMesh = new THREE.InstancedMesh(sphere, sphereMaterial, values.length);
const boxMesh = new THREE.InstancedMesh(hitBox, hitBoxMaterial, values.length);
const matrix = new THREE.Matrix4();
for (const [i, [x, z, y]] of values.entries()) {
matrix.setPosition(scaleX(x), scaleY(Number(y)), scaleZ(z));
// boxMesh.setColorAt(i, transparent)
dotMesh.setMatrixAt(i, matrix);
boxMesh.setMatrixAt(i, matrix);
}
boxMesh.visible = true;
boxMesh.layers.set(INTERSECTION_CHECK_LAYER);
this.layers.set(INTERSECTION_CHECK_LAYER);
this.add(boxMesh, dotMesh);
this.boxMesh = boxMesh;
this.dotMesh = dotMesh;
}
public globalPositionOfInstance(instanceId: number): THREE.Vector3 {
const pos = this.position.clone();
const mat = new THREE.Matrix4();
this.dotMesh!.getMatrixAt(instanceId, mat)!;
return this.localToWorld(pos.applyMatrix4(mat));
}
public setHitTest(enabled: boolean) {
if (enabled) {
this.boxMesh?.layers.set(INTERSECTION_CHECK_LAYER);
this.layers.set(INTERSECTION_CHECK_LAYER);
} else {
this.boxMesh?.layers.disable(INTERSECTION_CHECK_LAYER);
this.layers.disable(INTERSECTION_CHECK_LAYER);
}
}
}

View File

@ -0,0 +1,100 @@
import { Delaunay } from 'd3-delaunay';
import * as THREE from 'three';
import { identity } from './transformers';
export type Point2D = [number, number];
export type Point3D = [number, number, number];
// Geometry that only renders polygons between data points
export class SparsePlaneGeometry extends THREE.BufferGeometry {
static readonly pointComponentSize = 3;
private d: Delaunay<Point3D>;
private pointBuffer: Float32Array;
constructor(
values: [number, number, number][],
scaleX: (x: number) => number = identity,
scaleY: (y: number) => number = identity,
scaleZ: (z: number) => number = identity,
addsBoundPointsToDelaunay = true // if enabled bounding points are added to the delaunay triangulation. In most cases this removes strange rendering when gaps between points are large
) {
super();
let minX = Number.MAX_VALUE,
maxX = -1,
minZ = Number.MAX_VALUE,
maxZ = -1;
this.pointBuffer = new Float32Array(
(values.length + (addsBoundPointsToDelaunay ? 8 : 0)) * SparsePlaneGeometry.pointComponentSize
);
values.forEach(([x, z, y], idx) => {
if (addsBoundPointsToDelaunay) {
if (x < minX) {
minX = x;
}
if (x > maxX) {
maxX = x;
}
if (z < minZ) {
minZ = z;
}
if (z > maxZ) {
maxZ = z;
}
}
this.pointBuffer[idx * SparsePlaneGeometry.pointComponentSize] = scaleX(x);
this.pointBuffer[idx * SparsePlaneGeometry.pointComponentSize + 1] = scaleY(Number(y));
this.pointBuffer[idx * SparsePlaneGeometry.pointComponentSize + 2] = scaleZ(z);
});
const mid = (a: number, b: number) => (a + b) / 2;
const midPoint = ([x, z]: Point2D, [x2, z2]: Point2D) => [mid(x, x2), mid(z, z2)] as Point2D;
if (addsBoundPointsToDelaunay) {
this.d = Delaunay.from([
...(values as unknown as Point2D[]),
[minX, minZ],
[minX, maxZ],
[maxX, minZ],
[maxX, maxZ],
midPoint([minX, minZ], [maxX, minZ]),
midPoint([minX, maxZ], [maxX, maxZ]),
midPoint([minX, minZ], [minX, maxZ]),
midPoint([maxX, minZ], [maxX, maxZ])
]);
} else {
this.d = Delaunay.from(values as unknown as Point2D[]);
}
// compute delaunay and cull geometry linking
// to artificially added corner points
// IDEA: if performance is affected
// use a shader to cull triangles with special values for Y
const triangles = this.d.triangles;
const filteredTriangles: number[] = [];
if (addsBoundPointsToDelaunay) {
for (let i = 0; i < triangles.length; i += 3) {
if (
triangles[i] >= values.length ||
triangles[i + 1] >= values.length ||
triangles[i + 2] >= values.length
) {
continue;
}
filteredTriangles.push(triangles[i], triangles[i + 1], triangles[i + 2]);
}
}
this.setAttribute(
'position',
new THREE.BufferAttribute(this.pointBuffer, SparsePlaneGeometry.pointComponentSize, true)
);
// Create buffers for rendering
this.setIndex(
new THREE.Uint32BufferAttribute(addsBoundPointsToDelaunay ? filteredTriangles : triangles, 1)
);
this.computeVertexNormals();
}
}

View File

@ -0,0 +1,2 @@
export const identity = <T>(x: T) => x;

View File

@ -1,11 +1,18 @@
import * as THREE from 'three';
import {
Color,
DoubleSide,
ShaderMaterial,
UniformsUtils,
type ShaderMaterialParameters,
UniformsLib
} from 'three';
// Define your colors
const color1 = new THREE.Color('#F0F624');
const color2 = new THREE.Color('#C5407D');
const color3 = new THREE.Color('#15078A');
const color1 = new Color('#F0F624');
const color2 = new Color('#C5407D');
const color3 = new Color('#15078A');
export class DataPlaneShapeMaterial extends THREE.ShaderMaterial {
export class DataPlaneShapeMaterial extends ShaderMaterial {
static vertexShader = `
varying float y;
varying vec3 vNormal;
@ -77,9 +84,9 @@ export class DataPlaneShapeMaterial extends THREE.ShaderMaterial {
}
constructor(
colorA: THREE.Color = color2,
colorB: THREE.Color = color3,
options: Omit<THREE.ShaderMaterialParameters, 'lights'> = {}
colorA: Color = color2,
colorB: Color = color3,
options: Omit<ShaderMaterialParameters, 'lights'> = {}
) {
super({
...options,
@ -87,10 +94,10 @@ export class DataPlaneShapeMaterial extends THREE.ShaderMaterial {
fragmentShader: DataPlaneShapeMaterial.fragmentShader,
// lights: true,
// wireframe: true,
side: THREE.DoubleSide,
uniforms: THREE.UniformsUtils.merge([
THREE.UniformsLib['common'],
THREE.UniformsLib['lights'],
side: DoubleSide,
uniforms: UniformsUtils.merge([
UniformsLib['common'],
UniformsLib['lights'],
{
colorA: { value: colorA },
@ -101,8 +108,8 @@ export class DataPlaneShapeMaterial extends THREE.ShaderMaterial {
});
// super.onBeforeCompile = function (shader) {
// // Add custom uniforms if needed
// shader.uniforms.customColor1 = { value: new THREE.Color(0xff0000) }; // Red
// shader.uniforms.customColor2 = { value: new THREE.Color(0x0000ff) }; // Blue
// shader.uniforms.customColor1 = { value: new Color(0xff0000) }; // Red
// shader.uniforms.customColor2 = { value: new Color(0x0000ff) }; // Blue
// // Replace gl_FragColor with your own color logic
// shader.fragmentShader = shader.fragmentShader.replace(

View File

@ -0,0 +1,85 @@
export function canvasFilledRegionBounds(ctx: WebGL2RenderingContext | WebGLRenderingContext) {
const pixels = new Uint8ClampedArray(ctx.drawingBufferWidth * ctx.drawingBufferHeight * 4);
ctx.readPixels(
0,
0,
ctx.drawingBufferWidth,
ctx.drawingBufferHeight,
ctx.RGBA,
ctx.UNSIGNED_BYTE,
pixels
);
const pixelCount = pixels.length;
const bound = {
top: -1,
left: -1,
right: -1,
bottom: -1
};
let x = 0;
let y = 0;
for (let i = 0; i < pixelCount; i += 4) {
if (pixels[i + 3] !== 0) {
x = (i / 4) % ctx.drawingBufferWidth;
y = ~~(i / 4 / ctx.drawingBufferWidth);
if (bound.top === -1) {
bound.top = y;
}
if (bound.left === -1) {
bound.left = x;
} else if (x < bound.left) {
bound.left = x;
}
if (bound.right === -1) {
bound.right = x;
} else if (bound.right < x) {
bound.right = x;
}
if (bound.bottom === -1) {
bound.bottom = y;
} else if (bound.bottom < y) {
bound.bottom = y;
}
}
}
return bound;
}
export function drawCanvasToCanvas(
srcCtx: WebGL2RenderingContext | WebGLRenderingContext,
dstCtx: CanvasRenderingContext2D,
bound: ReturnType<typeof canvasFilledRegionBounds>
) {
dstCtx.drawImage(
srcCtx.canvas,
-bound.left,
-(srcCtx.drawingBufferHeight - bound.bottom), // canvas2d is inverted compared to pixels of canvas 3d
srcCtx.drawingBufferWidth,
srcCtx.drawingBufferHeight
);
}
export function imageFromGlContext(
ctx: WebGLRenderingContext | WebGL2RenderingContext,
backgroundFill?: string | CanvasGradient | CanvasPattern
): string | null {
const copyCtx = document.createElement('canvas').getContext('2d');
if (!copyCtx) {
return null;
}
const bound = canvasFilledRegionBounds(ctx);
const trimHeight = bound.bottom - bound.top,
trimWidth = bound.right - bound.left;
copyCtx.canvas.width = trimWidth;
copyCtx.canvas.height = trimHeight;
if (backgroundFill) {
copyCtx.fillStyle = backgroundFill;
copyCtx.fillRect(0, 0, copyCtx.canvas.width, copyCtx.canvas.height);
}
drawCanvasToCanvas(ctx, copyCtx, bound);
return copyCtx.canvas.toDataURL('image/png');
}

View File

@ -1,22 +1,37 @@
import * as THREE from 'three';
export interface TextTextureOptions {
color: THREE.ColorRepresentation;
color?: string;
font: string;
fontSize: number;
fontLineHeight: number;
rotation: number;
rotation?: number;
width?: number;
height?: number;
backgroundColor?: string;
useDocumentColor?: boolean;
}
const defaultTextOptions: TextTextureOptions = {
color: '#000000',
fontSize: 14,
font: 'serif',
fontLineHeight: 16,
rotation: 0
};
const renderScale = 2;
export class TextTexture extends THREE.CanvasTexture {
private canvas?: HTMLCanvasElement;
private context?: CanvasRenderingContext2D;
constructor(text: string, options: TextTextureOptions) {
constructor(text: string, _options: Partial<TextTextureOptions> = {}) {
const options = { ...defaultTextOptions, ..._options };
// Create a canvas element
const canvas = document.createElement('canvas');
canvas.width = text.length * options.fontSize;
canvas.height = options.fontSize * options.fontLineHeight;
canvas.width = (options.width ?? text.length * options.fontSize) * renderScale;
canvas.height = (options.height ?? options.fontSize * options.fontLineHeight) * renderScale;
// Get the 2D rendering context of the canvas
const context = canvas.getContext('2d');
@ -26,21 +41,25 @@ export class TextTexture extends THREE.CanvasTexture {
}
// Set the font properties
context.font = `${options.fontSize}px ${options.font}`;
// Set the text color
context.fillStyle = new THREE.Color(options.color).getStyle();
context.font = `${options.fontSize * renderScale}px ${options.font ?? ''}`;
// Set the text alignment and baseline
context.textAlign = 'center';
context.textBaseline = 'middle';
// Calculate the text position in the center of the canvas
const canvasWidth = canvas.width;
const canvasHeight = canvas.height;
const textX = canvasWidth / 2;
const textY = canvasHeight / 2;
if (options.backgroundColor) {
context.fillStyle = options.backgroundColor;
context.fillRect(0, 0, canvas.width, canvas.height);
}
// Set the text color
context.fillStyle = new THREE.Color(options.color).getStyle();
// Render the text on the canvas
context.fillText(text, textX, textY);

View File

@ -12,10 +12,46 @@ export enum Theme {
*/
interface AppSettings {
theme: Theme;
colors: ThemeColors;
}
export interface ThemeColors {
surfaceColor: string;
surfaceSecondaryColor: string;
border: string;
textColor: string;
textSecondaryColor: string;
selectionColor: string;
}
const lightColors: ThemeColors = {
surfaceColor: 'rgb(209, 210, 211)',
surfaceSecondaryColor: 'rgb(199, 200, 201)',
border: '#aaaaaa',
textColor: '#000000',
textSecondaryColor: '#333333',
selectionColor: 'rgb(253,174,97)'
};
const darkColors: ThemeColors = {
surfaceColor: 'rgb(17, 24, 39)',
surfaceSecondaryColor: 'rgb(27, 34, 49)',
border: 'rgba(55, 60, 65)',
textColor: '#ffffff',
textSecondaryColor: '#cecece',
selectionColor: 'rgb(255,174,97)'
};
const initialAppSettings: AppSettings = {
theme: Theme.Light
theme: Theme.Light,
colors: lightColors
};
const colorsForTheme = (theme: Theme): ThemeColors => {
if (theme === Theme.Dark) {
return darkColors;
} else {
return lightColors;
}
};
const settingsStore = () => {
@ -30,7 +66,11 @@ const settingsStore = () => {
initialTheme = storedTheme === Theme.Dark ? Theme.Dark : Theme.Light;
}
initialState = { ...initialAppSettings, theme: initialTheme };
initialState = {
...initialAppSettings,
theme: initialTheme,
colors: colorsForTheme(initialTheme)
};
}
const store = withLogMiddleware(writable<AppSettings>(initialState), 'SettingsStore');
@ -38,6 +78,7 @@ const settingsStore = () => {
const updateTheme = (theme: Theme) => {
store.update((settings) => {
settings.theme = theme;
settings.colors = colorsForTheme(theme);
return settings;
});

View File

@ -1,5 +1,6 @@
import { writable, get } from 'svelte/store';
import { dataStoreLoadExtension } from './loadActions';
import { dataStorePostProcessingExtension } from './postProcessing';
import type { IDataStore, TableSchema } from './types';
import { browser } from '$app/environment';
import { dataStoreFilterExtension } from './filterActions';
@ -12,12 +13,14 @@ const initialStore: IDataStore = {
sharedConnection: null,
tables: {},
combinedSchema: {},
previousQueries: []
previousQueries: [],
sqlTransformations: []
};
const _baseStore = () => {
console.log('Initializing data store');
const store = withLogMiddleware(writable<IDataStore>(initialStore), 'DataStore');
// const store = writable<IDataStore>(initialStore);
const { set, update, subscribe } = store;
@ -206,16 +209,22 @@ const _baseStore = () => {
};
export type BaseStoreType = ReturnType<typeof _baseStore>;
export type DataStoreType = ReturnType<typeof _dataStore>;
const _dataStore = () => {
const store = _baseStore();
// Execute extension
return {
const extendedStore = {
...store,
...dataStoreLoadExtension(store, store.rawStore),
...dataStoreFilterExtension(store)
// FIXME: due to tigh coupling between load and post processing this
// is loaded by the load module. Probably should be changed
//...dataStorePostProcessingExtension(store, store.rawStore)
};
return extendedStore;
};
export const dataStore = _dataStore();

View File

@ -0,0 +1,21 @@
export type Jsonable =
| string
| number
| boolean
| null
| undefined
| readonly Jsonable[]
| { readonly [key: string]: Jsonable }
| { toJSON(): Jsonable };
export class PostProeccingError extends Error {
public readonly context?: Jsonable;
constructor(message: string, options: { cause?: unknown; context?: Jsonable } = {}) {
const { cause, context } = options;
super(message, { cause });
this.name = this.constructor.name;
this.context = context;
}
}

View File

@ -1,3 +1,4 @@
import type { Point3D } from '$lib/rendering/geometry/SparsePlaneGeometry';
import type { BaseStoreType } from './DataStore';
import { DataAggregation, DataScaling, type FilterOptions, type IDataStore } from './types';
@ -9,9 +10,18 @@ export interface ITiledDataRow {
rawX: number;
rawY: number;
rawZ: number;
name: string;
id: number;
}
export type IQueryResult = {
// data: number[][];
points: Point3D[];
min: number;
max: number;
tiles: [number, number];
queryResult?: ITiledDataRow[];
};
export type MinValue = number;
export type MaxValue = number;
export type ValueRange = [MinValue, MaxValue];
@ -20,9 +30,9 @@ export type ITiledDataOptions = {
xColumnName: string;
yColumnName: string;
zColumnName: string;
xTileCount?: number;
zTileCount?: number;
tileCount: number;
xTileCount: number;
zTileCount: number;
lockTileCounts: boolean;
scaleY: DataScaling;
scaleX: DataScaling;
scaleZ: DataScaling;
@ -62,17 +72,6 @@ export const dataStoreFilterExtension = (store: BaseStoreType) => {
}
};
const defaultTiledDataOptions: ITiledDataOptions = {
xColumnName: 'iterations',
yColumnName: 'fpr',
zColumnName: 'cpu_time',
tileCount: 20,
scaleY: DataScaling.LINEAR,
scaleX: DataScaling.LINEAR,
scaleZ: DataScaling.LINEAR,
aggregation: DataAggregation.MIN
};
const getEntry = async (
tableName: string,
columnName: string,
@ -98,29 +97,37 @@ export const dataStoreFilterExtension = (store: BaseStoreType) => {
columnName: string,
scale: DataScaling
): Promise<ValueRange> => {
const query = `SELECT MIN(${getSqlScaleWrapper(
const baseQuery = `SELECT MIN(${getSqlScaleWrapper(
scale,
columnName,
'"'
)}) AS min, MAX(${getSqlScaleWrapper(scale, columnName, '"')}) AS max FROM "${tableName}"
WHERE ${
scale === DataScaling.LOG
? `"${columnName}" >= 0 and "${columnName}" != 'NaN'`
: `"${columnName}" != 'NaN'`
}
`;
${scale === DataScaling.LOG ? `WHERE "${columnName}" >= 0` : ``}`;
const combinedQuery =
baseQuery + `${scale === DataScaling.LOG ? ` and` : `where`} "${columnName}" != 'NaN'`;
const resp = await store.executeQuery(query);
let resp = await store.executeQuery(combinedQuery);
if (!resp) {
throw new Error('Failed to get min/max');
}
const rows = resp.toArray();
if (rows.length !== 1) {
throw new Error('Invalid number of rows');
let rows = resp.toArray();
// If we have no results this might be that the value is of type Int64 which does not pass the NaN test
if (rows.length !== 1 || rows[0].min === null || rows[0].max === null) {
resp = await store.executeQuery(baseQuery);
if (!resp) {
console.error('could not query min max range', { tableName, columnName, scale });
throw new Error('Failed to get min/max');
}
rows = resp.toArray();
if (rows.length !== 1) {
throw new Error('Invalid number of rows');
}
}
return [rows[0].min, rows[0].max];
// FIXME: fails with BigInt
return [Number(rows[0].min), Number(rows[0].max)];
};
const getTiledRows = async (
@ -131,8 +138,8 @@ export const dataStoreFilterExtension = (store: BaseStoreType) => {
zRange?: ValueRange,
where?: { columnName: string; value: string }
): Promise<ITiledDataRow[]> => {
const xTileCount = options.xTileCount ?? options.tileCount;
const zTileCount = options.zTileCount ?? options.tileCount;
const xTileCount = options.xTileCount;
const zTileCount = options.zTileCount;
// Compute ranges for each axis
const [xMin, xMax] =
@ -149,30 +156,37 @@ export const dataStoreFilterExtension = (store: BaseStoreType) => {
const yColValue = getSqlScaleWrapper(options.scaleY, options.yColumnName, '"');
const queryV2 = `
SELECT x, y, z, name, "${options.xColumnName}" as rawX, "${options.yColumnName}" as rawY, "${
options.xColumnName
}" as rawX
SELECT x, y, z, a.id, "${options.xColumnName}" as rawX, "${options.yColumnName}" as rawY, "${
options.zColumnName
}" as rawZ
FROM "${tableName}" a
RIGHT JOIN (
SELECT FLOOR((${xColValue} - ${xMin}) / ${xBucketSize}) AS x,
FLOOR((${zColValue} - ${zMin}) / ${zBucketSize}) AS z,
${options.aggregation}(${yColValue}) AS y
${options.aggregation}(${yColValue}) AS y,
min(id) as id
FROM "${tableName}"
${where ? `WHERE "${where.columnName}" = '${where.value}'` : ''}
GROUP BY x, z
) b ON a."${options.yColumnName}" = b.y
WHERE "${options.yColumnName}" != 'NaN' and x != 'NaN' and z != 'NaN'
${where ? `and "${where.columnName}" = '${where.value}'` : ''}
) b ON a.id = b.id
${options.scaleY === DataScaling.LOG ? `and "${options.yColumnName}" >= 0` : ''}
ORDER BY x ASC, y ASC ${where ? `, "${where.columnName}"` : ''}
ORDER BY x ASC, z ASC ${where ? `, "${where.columnName}"` : ''}
`;
try {
const resp = await store.executeQuery(queryV2);
if (!resp) {
// TODO: fix/handle this
return [];
}
return resp.toArray();
// TODO: move deduplication to DB for now simply do this in place
return resp
.toArray()
.filter((val, index, arr) =>
index === 0 ? true : val['x'] != arr[index - 1]['x'] || val['z'] != arr[index - 1]['z']
);
} catch (e) {
console.error(e);
return [];
@ -186,49 +200,45 @@ export const dataStoreFilterExtension = (store: BaseStoreType) => {
yRange?: ValueRange,
zRange?: ValueRange,
where?: { columnName: string; value: string }
): Promise<{
data: number[][];
min: number;
max: number;
queryResult?: ITiledDataRow[];
}> => {
): Promise<IQueryResult> => {
const options = {
...defaultTiledDataOptions,
..._options
};
} as ITiledDataOptions;
try {
const rows = await getTiledRows(tableName, options, xRange, yRange, zRange, where);
let rows = await getTiledRows(tableName, options, xRange, yRange, zRange, where);
const zDim = (options.zTileCount ?? options.tileCount) + 1;
const xDim = (options.xTileCount ?? options.tileCount) + 1;
// Transform rows into a 2D array for display
const data: number[][] = new Array(xDim).fill(0).map(() => new Array(zDim).fill(0));
// drop invalid rows
// needs to be done here since DB has issues with NaN and BigInt
rows = rows.filter(
(r) => !(Number.isNaN(r.x) || Number.isNaN(r.y) || Number.isNaN(r.z) || r.x < 0 || r.z < 0)
);
const points = rows.map((row) => [Number(row.x), Number(row.z), Number(row.y)] as Point3D); // wrap values in containers to handle BigInt data
const zDim = options.zTileCount + 1;
const xDim = options.xTileCount + 1;
let min = Number.MAX_VALUE;
let max = Number.MIN_VALUE;
rows.forEach((r, i) => {
if (r.x < 0 || r.z < 0 || Number.isNaN(r.y)) {
return;
}
min = Math.min(min, r.y);
max = Math.max(max, r.y);
data[r.z][r.x] = r.y;
points.forEach(([_, __, y]) => {
// use ternary to support big int
min = min > y ? y : min;
max = max < y ? y : max;
});
return {
data,
points,
min,
max,
tiles: [xDim, zDim],
queryResult: rows
};
} catch (e) {
console.error('Failed to create tiled data:', e);
return {
data: [],
points: [],
min: 0,
max: 0
max: 0,
tiles: [0, 0]
};
}
};

View File

@ -1,18 +1,14 @@
import { get, type Writable } from 'svelte/store';
import type { BaseStoreType } from './DataStore';
import type { IDataStore, ITableEntry, TableSchema } from './types';
import {
TableSource,
type ITableReference,
type ITableRefList,
type ITableExternalUrl,
type ITableExternalFile
} from '../filterStore/types';
import notificationStore from '../notificationStore';
import { flatGroup } from 'd3';
import type { BaseStoreType } from './DataStore';
import { dataStorePostProcessingExtension } from './postProcessing';
import type { IDataStore, ILoadedTable, ITableExternalFile, TableSchema } from './types';
import { TableSource, type ITableReference, type ITableRefList } from './types';
// Store extension containing actions to load data, transform & drop data
export const dataStoreLoadExtension = (store: BaseStoreType, dataStore: Writable<IDataStore>) => {
const postProcessingExtension = dataStorePostProcessingExtension(store, dataStore);
// Wrapper utility to set loading state
const withLoading = async <T>(fn: () => Promise<T>) => {
store.setIsLoading(true);
@ -23,57 +19,6 @@ export const dataStoreLoadExtension = (store: BaseStoreType, dataStore: Writable
}
};
const rewriteExperimentsEntries = async (tableName: string) => {
console.log('Rewriting entries for table:', tableName);
const rewriteQuery = `
ALTER TABLE "${tableName}" ADD COLUMN family TEXT;
ALTER TABLE "${tableName}" ADD COLUMN mode TEXT;
ALTER TABLE "${tableName}" ADD COLUMN vectorization TEXT;
ALTER TABLE "${tableName}" ADD COLUMN fixture TEXT;
ALTER TABLE "${tableName}" ADD COLUMN s FLOAT;
ALTER TABLE "${tableName}" ADD COLUMN n_threads INTEGER;
ALTER TABLE "${tableName}" ADD COLUMN n_partitions INTEGER;
ALTER TABLE "${tableName}" ADD COLUMN n_elements_build INTEGER;
ALTER TABLE "${tableName}" ADD COLUMN n_elements_lookup INTEGER;
ALTER TABLE "${tableName}" ADD COLUMN shared_elements FLOAT;
WITH SplitValues AS (
SELECT
name,
SPLIT_PART(name, '_', 1) AS family,
SPLIT_PART(name, '_', 2) AS mode,
SPLIT_PART(name, '_', 4) AS vectorization,
SPLIT_PART(name, '/', 2) AS fixture,
CAST(SPLIT_PART(name, '/', 3) AS FLOAT) / 100 AS s,
CAST(SPLIT_PART(name, '/', 4) AS INTEGER) AS n_threads,
CAST(SPLIT_PART(name, '/', 5) AS INTEGER) AS n_partitions,
CAST(SPLIT_PART(name, '/', 6) AS INTEGER) AS n_elements_build,
CAST(SPLIT_PART(name, '/', 7) AS INTEGER) AS n_elements_lookup,
CAST(SPLIT_PART(name, '/', 8) AS FLOAT) / 100 AS shared_elements
FROM "${tableName}")
UPDATE "${tableName}" AS t
SET
family = sv.family,
mode = sv.mode,
vectorization = sv.vectorization,
fixture = sv.fixture,
s = sv.s,
n_threads = sv.n_threads,
n_partitions = sv.n_partitions,
n_elements_build = sv.n_elements_build,
n_elements_lookup = sv.n_elements_lookup,
shared_elements = sv.shared_elements
FROM SplitValues AS sv
WHERE t.name = sv.name;
UPDATE "${tableName}" as t
SET fpr = 'NaN'
WHERE fpr = -1;
`;
return store.executeQuery(rewriteQuery);
};
const bindFileToDuckDB = async (dbPath: string, file: File) => {
const { db } = get(dataStore);
const conn = await store.getConnection();
@ -87,17 +32,21 @@ export const dataStoreLoadExtension = (store: BaseStoreType, dataStore: Writable
await db.registerFileHandle(dbPath, file, DuckDBDataProtocol.BROWSER_FILEREADER, true);
};
const getTableDisplayName = (rawTableName: string) => {
// NOTE: inefficient string replacement with multiple loops, deemed acceptable due to
// low call frequency. At most once per table creation.
return rawTableName.replaceAll('_', ' ').replaceAll('-', ' ').trim();
};
const loadCsvFromRef = async (
ref: ITableReference,
shouldSetLoading = true,
createTable = true,
shouldUpdateTableList = true
): Promise<ITableEntry | undefined> => {
createTable = true
): Promise<ITableReference | undefined> => {
const conn = await store.getConnection();
if (!conn) {
// TODO: add error handling
return;
throw new Error('no database connection');
}
if (shouldSetLoading) {
@ -119,30 +68,18 @@ export const dataStoreLoadExtension = (store: BaseStoreType, dataStore: Writable
break;
}
if (createTable) {
// remove old table with this name
await removeTable(ref.tableName);
}
await conn.insertCSVFromPath(url, {
name: ref.tableName,
detect: true,
create: createTable
});
const schema = await store.getTableSchema(ref.tableName);
const tableEntry: ITableEntry = {
name: ref.tableName,
displayName: ref.displayName,
schema,
filterOptions: {},
ref: ref
};
if (shouldUpdateTableList) {
// Update or replace table entry
dataStore.update((store) => {
store.tables[ref.tableName] = tableEntry;
return store;
});
}
return tableEntry;
return ref;
} catch (e) {
const msg = `Failed to load table ${ref.tableName} from path ${url}`;
console.error(msg, e);
@ -158,36 +95,6 @@ export const dataStoreLoadExtension = (store: BaseStoreType, dataStore: Writable
}
};
const postProcessTable = async (
tableName: string,
refs: ITableRefList
): Promise<ITableEntry | undefined> => {
console.debug('Post process', { tableName, refs });
if (refs.length === 0) {
return;
}
// get list type
const refType = refs[0].source;
try {
switch (refType) {
case TableSource.BUILD_IN: {
switch (refs[0].dataset.name) {
case 'experiments':
await rewriteExperimentsEntries(tableName);
}
}
}
// await addIndexColumn(tableName);
const schema = await store.getTableSchema(tableName);
const filterOptions = {}; //await getFiltersOptions(tableName, Object.keys(schema));
console.log('Rewrite response:', { schema, filterOptions, table: tableName });
return { schema, filterOptions, name: tableName, dataUrl: '' };
} catch (e) {
console.error(`Failed to rewrite entries for table ${tableName}:`, e);
return undefined;
}
};
/**
* Returns the common table schema for all tables (e.g. all columns that are present in all tables with the same type)
* @param onlyCheckFields Only check these fields
@ -208,7 +115,7 @@ export const dataStoreLoadExtension = (store: BaseStoreType, dataStore: Writable
const { tables } = get(dataStore);
return Object.entries(tables).reduce((acc, [_, value], idx) => {
if (idx === 0) {
acc = value.schema;
acc = JSON.parse(JSON.stringify(value.schema));
return acc;
}
@ -234,12 +141,28 @@ export const dataStoreLoadExtension = (store: BaseStoreType, dataStore: Writable
}, {} as TableSchema);
};
/**
* group table references by their table name
*/
const groupTableReferences = (refs: ITableReference[]): Record<string, ITableRefList> =>
refs.reduce(
(acc, ref) => {
if (!acc[ref.tableName]) {
acc[ref.tableName] = [];
}
// type
acc[ref.tableName].push(ref as any);
return acc;
},
{} as Record<string, ITableRefList>
);
/**
* Loads all CSVs for the selected filters entries
* @param selected
* @returns
*/
const loadCsvsFromRefs = async (refs: ITableRefList): Promise<ITableEntry[]> =>
const loadCsvsFromRefs = async (refs: ITableReference[]): Promise<ILoadedTable[]> =>
withLoading(async () => {
if (refs.length === 0) {
return [];
@ -248,43 +171,43 @@ export const dataStoreLoadExtension = (store: BaseStoreType, dataStore: Writable
console.debug('Loading table references:', refs);
// Group tables by name for later processing
const grouped = refs.reduce((acc, ref) => {
if (!acc[ref.tableName]) {
acc[ref.tableName] = [];
}
// type
acc[ref.tableName].push(ref as any);
return acc;
}, {} as Record<string, ITableRefList>);
const grouped = groupTableReferences(refs);
console.debug('Grouped table references:', grouped);
// Load multiple tables at once but all linked to the same tableName sequentially
// This is required since we need to rewrite the entries for each table
// and a table should only be created once
// Load multiple tables at once, with all references for one table loaded in sequentially
// Otherwise parts of one table would be loaded and processed incorrectly
const promise = Promise.all(
Object.entries(grouped).flatMap(async ([tableName, entries]) => {
if (entries.length === 0) {
return [];
return undefined;
}
const loadedTables: ITableEntry[] = [];
console.debug('loading table group:', { tableName, entries });
// Load grouped entries sequentially
for (const [i, entry] of entries.entries()) {
// Only create table for first entry
// do not update store table list, this will be done after post processing
const loadedTable = await loadCsvFromRef(entry, false, i === 0, false);
if (loadedTable) {
loadedTables.push(loadedTable);
}
await loadCsvFromRef(entry, false, i === 0);
console.debug('loaded:', { tableName, entry });
}
console.debug('applying post processing:', { tableName, entries });
const sourceSchema = await store.getTableSchema(tableName);
const loadedTableInfo: ILoadedTable = {
tableName: tableName, // when no transformations were applied use the default table
displayName: getTableDisplayName(tableName),
sourceTableName: tableName,
sourceSchema,
transformations: [],
schema: sourceSchema,
filterOptions: {},
refs: entries
};
// Post process tables
return postProcessTable(tableName, entries) ?? [];
await postProcessingExtension.applyPostProcessing(loadedTableInfo);
return loadedTableInfo;
})
);
@ -292,14 +215,12 @@ export const dataStoreLoadExtension = (store: BaseStoreType, dataStore: Writable
try {
// Filter out undefined values
const promiseResult = await promise;
const tableDefinitions = promiseResult.filter(
(t) => t !== undefined
) as unknown as ITableEntry[];
const tableDefinitions = promiseResult.filter((t) => t !== undefined) as ILoadedTable[];
console.debug('loaded tables into db:', { tableDefinitions, promiseResult });
// Update data store
dataStore.update((store) => {
tableDefinitions.forEach((table) => {
store.tables[table.name] = table;
store.tables[table.sourceTableName] = table;
});
store.combinedSchema = computeCombinedTableSchema();
return store;
@ -367,24 +288,23 @@ export const dataStoreLoadExtension = (store: BaseStoreType, dataStore: Writable
// Export public API
return {
...postProcessingExtension,
// Add modifiers
loadEntriesFromFileList: async (fileList: FileList) => {
const promises: Promise<ITableEntry | undefined>[] = [];
// TODO: handle non CSV files
let refs: ITableExternalFile[] = [];
for (const file of fileList) {
const tableName = file.name.replace('.csv', '');
promises.push(
loadCsvFromRef({
source: TableSource.FILE,
file: file,
tableName: tableName
})
);
refs.push({
source: TableSource.FILE,
file,
tableName: file.name.replace('.csv', '')
});
}
await Promise.all(promises);
await loadCsvsFromRefs(refs);
},
loadCsvFromRef,
loadCsvsFromRefs,
resetDatabase,
removeTable

View File

@ -0,0 +1,283 @@
import { get, type Writable } from 'svelte/store';
import type { BaseStoreType } from './DataStore';
import {
TransformationType,
type IDataStore,
type ILoadedTable,
TableSource,
type TableTransformation,
type JsTransformation,
type TableSchema,
type SqlTransformation
} from './types';
import notificationStore from '../notificationStore';
import { PostProeccingError } from './errors';
import { base } from '$app/paths';
export const dataStorePostProcessingExtension = (
store: BaseStoreType,
dataStore: Writable<IDataStore>
) => {
const deleteTable = (tableName: string) => store.executeQuery(`DROP TABLE "${tableName}"`);
const renameTable = (tableName: string, newTableName: string) =>
store.executeQuery(`ALTER TABLE "${tableName}" RENAME TO "${newTableName}"`);
// Adds an Id column in the table
const createIdColumn = async (targetTable: string, info: ILoadedTable) => {
const replace = info.sourceTableName == targetTable;
const intermediaryTableName = replace ? `id-inject-${targetTable}-temp` : targetTable;
const query = `
CREATE TABLE "${intermediaryTableName}" AS
SELECT
ROW_NUMBER() OVER () AS id, *
FROM "${info.sourceTableName}";
`;
await store.executeQuery(query);
if (replace) {
await deleteTable(info.sourceTableName);
await renameTable(intermediaryTableName, info.sourceTableName);
}
return true;
};
const computeCombinedTableSchema = (
onlyCheckFields: string[] | undefined = undefined
): TableSchema => {
const { tables } = get(dataStore);
return Object.entries(tables).reduce((acc, [name, value], idx) => {
console.log('checking table', { name, value });
if (idx === 0) {
acc = value.schema;
return acc;
}
Object.entries(acc).forEach(([key, val]) => {
if (onlyCheckFields !== undefined && !onlyCheckFields.includes(key)) {
return;
}
// Check if key exists in other table
if (!value.schema[key]) {
delete acc[key];
return;
}
// Check if type matches
if (val !== value.schema[key]) {
delete acc[key];
return;
}
});
return acc;
}, {} as TableSchema);
};
// some default transformations
const idTransformation: JsTransformation = {
name: 'Add Row ID',
type: TransformationType.JS,
description: 'Adds a row ID for faster query operations',
method: createIdColumn,
required: true
};
const constructTransformation: SqlTransformation = {
name: 'Gtest parser (Construct)',
type: TransformationType.SQL,
description: 'Splits Gtest name column into relevant values',
query: () => fetch(base + '/transformations/construct.sql').then((resp) => resp.text()),
required: true
};
const countTransformation: SqlTransformation = {
name: 'Gtest parser (Count)',
type: TransformationType.SQL,
description: 'Splits Gtest name column into relevant values',
query: () => fetch(base + '/transformations/count.sql').then((resp) => resp.text()),
required: true
};
const processSqlStringLiteral = (
tableName: string,
info: ILoadedTable,
queryTemplate: string
) => {
return queryTemplate
.replaceAll(/\${tableName}/g, tableName)
.replaceAll(/\${sourceTableName}/g, info.sourceTableName);
};
const getAnyValue = async (table: ILoadedTable) => {
const query = `SELECT name FROM "${table.sourceTableName}" LIMIT 1`;
return store.executeQuery(query);
};
const applyPostProcessing = async (table: ILoadedTable) => {
// clear output table
if (table.sourceTableName != table.tableName) {
await deleteTable(table.tableName);
}
const outputTableName = getOutputTableName(table);
// ensure we at least add ID injection
if (
typeof table.sourceSchema['id'] === 'undefined' &&
table.transformations.indexOf(idTransformation) === -1
) {
table.transformations.push(idTransformation);
}
console.log('Applying post processing', table);
// handle internal database rewrites
if (
table.refs[0].source === TableSource.BUILD_IN &&
table.transformations.indexOf(countTransformation) === -1 &&
table.transformations.indexOf(constructTransformation) === -1
) {
// Check for fixture in name
const someValue = await getAnyValue(table);
if (someValue) {
const value = someValue.toArray().at(0);
if (value && value['name']) {
const name = (value['name'] as string).toLowerCase();
if (name.includes('construct')) {
table.transformations.push(constructTransformation);
} else if (name.includes('count')) {
table.transformations.push(countTransformation);
}
}
}
}
table.tableName = outputTableName;
for (const transformation of table.transformations) {
try {
switch (transformation.type) {
case TransformationType.JS:
await transformation.method(outputTableName, table);
break;
case TransformationType.SQL:
let queryString =
typeof transformation.query === 'string'
? transformation.query
: await transformation.query();
const query = processSqlStringLiteral(table.tableName, table, queryString);
await store.executeQuery(query);
break;
}
// NOTE: remove if this becomes an issue
// collect intermediary table schemas
transformation.resultSchema = await store.getTableSchema(table.tableName);
transformation.lastError = undefined;
} catch (err) {
let error = new PostProeccingError('Transformation failed', {
cause: err,
context: {
transformationName: transformation.name,
type: transformation.type,
sourceTableName: table.sourceTableName
}
});
transformation.lastError = error;
throw error;
}
}
table.schema = await store.getTableSchema(table.tableName);
store.update((state) => {
// find and update table schema
// console.log('current store entry', state.tables[table.tableName], state.tables);
const combinedSchema = computeCombinedTableSchema();
console.log({ combinedSchema });
state.combinedSchema = combinedSchema;
return state;
});
};
const updatePostProcessingTransformer = async (
table: ILoadedTable,
transformer: TableTransformation
) => {
store.setIsLoading(true);
const index = table.transformations.indexOf(transformer);
if (index == -1) {
notificationStore.error({ message: 'Could not find transformer for update' });
}
try {
await applyPostProcessing(table);
} catch (err) {
if (err instanceof PostProeccingError) {
notificationStore.error({
message: err.name,
description: err.message
});
}
return err;
} finally {
store.setIsLoading(false);
}
};
const addPostProcessingTransformer = async (
table: ILoadedTable,
transformer: TableTransformation
) => {
store.setIsLoading(true);
table.transformations.push(transformer);
try {
await applyPostProcessing(table);
} catch (err) {
if (err instanceof PostProeccingError) {
notificationStore.error({
message: err.name,
description: err.message
});
}
return err;
} finally {
store.setIsLoading(false);
}
};
const removePostProcessingTransformer = async (
table: ILoadedTable,
transformer: TableTransformation
) => {
store.setIsLoading(true);
table.transformations = table.transformations.filter((tr) => tr !== transformer);
try {
await applyPostProcessing(table);
} catch (err) {
if (err instanceof PostProeccingError) {
notificationStore.error({
message: err.name,
description: err.message
});
}
return err;
} finally {
store.setIsLoading(false);
}
};
return {
createIdColumn,
applyPostProcessing,
updatePostProcessingTransformer,
addPostProcessingTransformer,
removePostProcessingTransformer
};
};
// returns the name of the unmodified table for a table reference
const getOutputTableName = (table: ILoadedTable): string => `${table.sourceTableName}-output`;

View File

@ -1,5 +1,4 @@
import type { AsyncDuckDB, AsyncDuckDBConnection } from '@duckdb/duckdb-wasm';
import type { ITableReference } from '../filterStore/types';
export type FilterOptions = Record<string, { options: unknown[]; label?: string; type: string }>;
export type TableSchema = Record<string, 'number' | 'string'>;
@ -16,21 +15,105 @@ export enum DataAggregation {
SUM = 'sum'
}
export interface ITableEntry {
name: string;
export enum TableSource {
BUILD_IN,
URL,
FILE
}
interface ITableRef {
tableName: string;
displayName?: string;
schema: TableSchema;
ref: ITableReference;
}
export interface ITableBuildIn extends ITableRef {
source: TableSource.BUILD_IN;
url: string;
// build in tables are ordered in folders
// we call these folders datasets since they indicate
// comparable table structure
datasetName: string;
}
export interface ITableExternalUrl extends ITableRef {
source: TableSource.URL;
url: string;
}
export interface ITableExternalFile extends ITableRef {
source: TableSource.FILE;
file: File;
}
// TODO: move somewhere else
export const tableSourceToString = (source: TableSource) => {
switch (source) {
case TableSource.BUILD_IN:
return 'build-in';
case TableSource.FILE:
return 'file';
case TableSource.URL:
return 'url';
}
};
export type ITableReference = ITableBuildIn | ITableExternalFile | ITableExternalUrl;
export type ITableRefList = ITableBuildIn[] | ITableExternalFile[] | ITableExternalUrl[];
export enum TransformationType {
SQL,
JS
}
export type BaseTableTransformation = {
name: string;
description: string;
type: TransformationType;
resultSchema?: TableSchema;
required?: boolean;
lastError?: Error;
};
export type SqlTransformation = BaseTableTransformation & {
type: TransformationType.SQL;
query: string | (() => Promise<string>);
};
export type JsTransformation = BaseTableTransformation & {
type: TransformationType.JS;
method: (tableName: string, info: ILoadedTable) => Promise<boolean>;
};
export type TableTransformation = SqlTransformation | JsTransformation;
export interface ILoadedTable {
tableName: string; // Table name that should be used for query operations with transformations applied
displayName: string; // Defaults to table name
schema: TableSchema; // Schema after all transformations were applied
sourceSchema: TableSchema; // Schema of raw/unmodified table
sourceTableName: Readonly<string>; // Unmodified table loaded by user
transformations: TableTransformation[];
refs: ITableReference[]; // can be multiple since a single table can be multiple files
filterOptions: FilterOptions;
}
export type DbQueryHistoryItem = {
query: string;
success: boolean;
executionTime: number;
};
export interface IDataStore {
db: AsyncDuckDB | null;
isLoading: boolean;
sharedConnection: AsyncDuckDBConnection | null;
// Property to keep track of which tables have been loaded
tables: Record<string, ITableEntry>;
// stores currently loaded tables and sources
tables: Record<string, ILoadedTable>;
// Table schema shared across all tables
combinedSchema: TableSchema;
previousQueries: { query: string; success: boolean; executionTime: number }[];
// FIXME: hide behind debug flag
previousQueries: DbQueryHistoryItem[];
sqlTransformations: string[];
}

View File

@ -1,110 +1,41 @@
import { get, writable } from 'svelte/store';
import { dataStore } from '../dataStore/DataStore';
import {
withUrlStorage,
type UrlEncoder,
defaultUrlEncoder,
type UrlDecoder,
defaultUrlDecoder
} from '../urlStorage';
import {
type IFilterStore,
TableSource,
GraphOptions,
GraphType,
type ITableRefList,
type ITableBuildIn,
type ITableExternalUrl,
type ITableExternalFile,
type ITableReference
} from './types';
import { PlaneGraphOptions } from './graphs/plane';
import { urlDecodeObject, withSingleKeyUrlStorage } from '../urlStorage';
import { type IFilterStore, GraphType, type GraphStateConfig, type DeepPartial } from './types';
import { PlaneGraphModel } from './graphs/plane';
import { defaultLogOptions, withLogMiddleware } from '../logMiddleware';
import notificationStore from '../notificationStore';
import type { Dataset, DatasetItem } from '../../../dataset/types';
type UrlTableSelection = {
source: TableSource;
tableName: string;
datasetName?: string; // Only set for source == build_in
};
import {
TableSource,
type ITableExternalUrl,
type ITableExternalFile,
type ITableBuildIn
} from '../dataStore/types';
import { toStateObject, urlEncodeFilterState } from './restore';
import type { IPlaneRenderOptions } from '$lib/rendering/PlaneRenderer';
import { goto } from '$app/navigation';
import { base } from '$app/paths';
const initialStore: IFilterStore = {
isLoading: true,
preloadedDatasets: [],
selectedTables: []
preloadedDatasets: []
};
// Hacky way to create a new store with a new base object
const baseStore = writable<IFilterStore>(JSON.parse(JSON.stringify(initialStore)));
const storeEncodeSelectedTables = (tables: IFilterStore['selectedTables']) =>
tables.map(
(el) =>
({
source: el.source,
tableName: el.tableName,
datasetName: el.source == TableSource.BUILD_IN ? el.dataset.name : undefined
} as UrlTableSelection)
);
const _filterStore = () => {
const urlEncoder: UrlEncoder = (key, type, value) => {
if (key === 'graphOptions') {
console.log('Encoding graph options', value);
if (!value) {
return undefined;
}
return (value as GraphOptions).getType();
}
if (key === 'selectedTables') {
const val = value as ITableReference[];
return defaultUrlEncoder(key, type, storeEncodeSelectedTables(val));
}
const encodedValue = defaultUrlEncoder(key, type, value);
// console.log('Encoding', key, encodedValue, value, JSON.stringify(value));
return encodedValue;
};
// Store renderer temporarily globally and
// Set it after database init was completed
let urlRestoredGraphOptions: GraphOptions | null = null;
let urlRestoredTableSelection: UrlTableSelection[] = [];
const urlDecoder: UrlDecoder = (key, type, value) => {
switch (key) {
case 'graphOptions': {
const graphType = value as GraphType;
switch (graphType) {
case GraphType.PLANE: {
urlRestoredGraphOptions = new PlaneGraphOptions();
return undefined;
}
}
break;
}
case 'selectedTables': {
urlRestoredTableSelection = defaultUrlDecoder(key, type, value) as UrlTableSelection[];
return [];
}
}
return defaultUrlDecoder(key, type, value);
};
let initialUrlConfig: Partial<GraphStateConfig> | undefined = undefined;
const store = withLogMiddleware(
withUrlStorage(
baseStore,
{
selectedTables: 'object',
graphOptions: 'object'
},
urlEncoder,
urlDecoder
),
withSingleKeyUrlStorage<IFilterStore>(baseStore, 'filter', urlEncodeFilterState, (param) => {
if (param) {
initialUrlConfig = urlDecodeObject(param);
}
// do not use URL state to set initial store
return JSON.parse(JSON.stringify(initialStore));
}),
'FilterStore',
{ ...defaultLogOptions, color: 'green' }
);
@ -118,22 +49,8 @@ const _filterStore = () => {
});
};
const selectTables = async (tables: ITableRefList) => {
setIsLoading(true);
// Load tables into data store
try {
const loadedTables = await dataStore.loadCsvsFromRefs(tables);
store.update((store) => {
store.selectedTables = [...store.selectedTables, ...tables];
return store;
});
} catch (e) {
console.error('Failed to load tables:', e);
return;
} finally {
setIsLoading(false);
}
};
// FIXME: simple rerender hack for now
const storeToUrl = () => store.update((state) => state);
const reloadCurrentGraph = async () => {
// reload state with table changes
@ -144,7 +61,11 @@ const _filterStore = () => {
}
};
const selectBuildInTables = async (dataset: Dataset, tablePaths: DatasetItem[]) => {
const loadBuildInTables = async (
dataset: Dataset,
tablePaths: DatasetItem[],
preventUrlUpdate: boolean = false
) => {
// Convert filter options to table references
const tableReferences: ITableBuildIn[] = tablePaths.flatMap((item) => {
return item.files.map((file) => ({
@ -153,46 +74,195 @@ const _filterStore = () => {
source: TableSource.BUILD_IN,
// FIXME: create correct path on server
url: '/' + file.dataURL,
dataset
datasetName: dataset.name
}));
});
try {
await selectTables(tableReferences);
await reloadCurrentGraph();
return;
await dataStore.loadCsvsFromRefs(tableReferences);
} catch {
notificationStore.error({
message: 'Failed to load external tables',
description: tablePaths.map((table) => table.name).join(',')
});
}
if (!preventUrlUpdate) {
storeToUrl();
}
};
const loadTableFromURL = async (
url: URL,
tableName?: string,
preventUrlUpdate: boolean = false
) => {
// Convert filter options to table references
const tableReferences: ITableExternalUrl[] = [
{
tableName: tableName ?? url.pathname.replaceAll('/', '-'),
source: TableSource.URL,
url: url.href
}
];
try {
await dataStore.loadCsvsFromRefs(tableReferences);
} catch (err) {
notificationStore.error({
message: 'Failed to load tables from file',
description: `${tableReferences.map((table) => table.tableName).join(',')}, err=${
err ?? 'Unknown Error'
}`
});
}
if (!preventUrlUpdate) {
storeToUrl();
}
};
const loadTablesFromFiles = async (
fileList: FileList,
tableName?: string,
preventUrlUpdate: boolean = false
) => {
// Convert filter options to table references
const tableReferences: ITableExternalFile[] = [];
for (const file of fileList) {
tableReferences.push({
tableName: tableName ?? file.name,
source: TableSource.FILE,
file
});
}
try {
await dataStore.loadCsvsFromRefs(tableReferences);
} catch (err) {
notificationStore.error({
message: 'Failed to load tables from file',
description: `${tableReferences.map((table) => table.tableName).join(',')}, err=${
err ?? 'Unknown Error'
}`
});
}
if (!preventUrlUpdate) {
storeToUrl();
}
};
const setGraphModel = (
type?: GraphType,
graphState?: {
data?: Record<string, unknown>;
render?: Record<string, unknown>;
}
) => {
update((state) => {
if (!type) {
state.graphOptions = undefined;
return state;
}
switch (type) {
case GraphType.PLANE:
state.graphOptions = new PlaneGraphModel(
graphState?.data,
(graphState?.render as Partial<IPlaneRenderOptions>) ?? {}
);
// FIXME: hack to trigger URL persistance
state.graphOptions.dataStore.subscribe(storeToUrl);
}
return state;
});
};
const reloadWithState = async (
config: Partial<GraphStateConfig>,
overrides: DeepPartial<GraphStateConfig> = {}
) => {
const datasets = get(store).preloadedDatasets;
console.debug('init from config ', { config, datasets });
if (config.selectedTables) {
for (const table of config.selectedTables) {
const loadedDatasets: Record<string, DatasetItem[]> = {};
for (const ref of table.refs) {
switch (ref.source) {
case TableSource.BUILD_IN: {
const dataset = datasets.find((dataset) => dataset.name == ref.datasetName);
if (dataset) {
if (!loadedDatasets[dataset.name]) {
loadedDatasets[dataset.name] = [];
}
const datasetItem = dataset.items.find((item) => item.name == table.tableName);
if (datasetItem) {
loadedDatasets[dataset.name] = [...loadedDatasets[dataset.name], datasetItem];
}
}
break;
}
case TableSource.URL:
console.warn('restore from URL not supported yet');
break;
case TableSource.FILE:
console.warn('restore from FILE not supported yet');
break;
}
}
for (const [datasetName, items] of Object.entries(loadedDatasets)) {
await loadBuildInTables(
datasets.find((dataset) => dataset.name === datasetName)!,
items,
true
);
}
}
}
if (config.graphOption) {
const combinedData =
config.graphOption?.data || overrides.graphOption?.data
? { ...(config.graphOption?.data ?? {}), ...(overrides.graphOption?.data ?? {}) }
: undefined;
const combinedRenderOptions =
config.graphOption?.render || overrides.graphOption?.render
? {
...(config.graphOption?.render ?? {}),
...(overrides.graphOption?.render ?? {})
}
: undefined;
setGraphModel(config.graphOption.type, { data: combinedData, render: combinedRenderOptions });
}
};
return {
set,
update,
subscribe,
selectTables,
// Util methods for loading collection of tables and handling reloads
loadBuildInTables,
loadTableFromURL,
loadTablesFromFiles,
reload: reloadCurrentGraph,
removeTable: async (tableName: string) => {
try {
await dataStore.removeTable(tableName);
await reloadCurrentGraph();
} catch {
notificationStore.error({
message: `Failed to remove table "${tableName}"`
});
}
await reloadCurrentGraph();
},
toStateObject: () => {
const state = get(store);
return {
selectedTables: storeEncodeSelectedTables(state.selectedTables),
graphOptions: state.graphOptions?.toStateObject()
};
return toStateObject(state);
},
reset: () => {
@ -202,6 +272,8 @@ const _filterStore = () => {
newInitialState.preloadedDatasets = get(store).preloadedDatasets;
newInitialState.isLoading = false;
set(newInitialState);
goto(`${base}/graph/custom`);
},
// Actions
@ -209,144 +281,44 @@ const _filterStore = () => {
// initWithPreloadedDatasets:
// called after frontend completed mount of graph component and server defined Datasets are available to the client
// This is the perfect spot for restoring state since initial server and client states are available
initWithPreloadedDatasets: async (datasets: Dataset[], selectedGraph?: any) => {
initWithPreloadedDatasets: async (datasets: Dataset[], config?: GraphStateConfig) => {
update((store) => {
store.preloadedDatasets = datasets;
store.config = config;
return store;
});
// restore selected tables not that we have the paths from the server
const restoredSelectedTables: [Dataset, DatasetItem][] = [];
// FIXME: cleanup & and handle edge cases
if (selectedGraph) {
console.log('using selected Graph', selectedGraph);
urlRestoredTableSelection = selectedGraph.selectedTables;
if (selectedGraph['graphOptions']) {
const graphType = selectedGraph.graphOptions.type as GraphType;
switch (graphType) {
case GraphType.PLANE: {
urlRestoredGraphOptions = new PlaneGraphOptions(selectedGraph.graphOptions.state);
}
}
}
}
urlRestoredTableSelection.forEach((selection) => {
switch (selection.source) {
case TableSource.BUILD_IN: {
const dataset = datasets.find((dataset) => dataset.name == selection.datasetName);
if (dataset) {
const datasetItem = dataset.items.find((item) => item.name == selection.tableName);
if (datasetItem) {
console.log(datasetItem);
restoredSelectedTables.push([dataset, datasetItem]);
}
}
break;
}
case TableSource.URL:
console.warn('restore from URL not supported yet');
break;
case TableSource.FILE:
console.warn('restore from FILE not supported yet');
break;
}
// reset current state
await dataStore.resetDatabase();
store.update((state) => {
state.graphOptions = undefined;
return state;
});
// remove temporary url values
urlRestoredTableSelection = [];
// load all tables
for (const [dataset, item] of restoredSelectedTables) {
await selectBuildInTables(dataset, [item]);
// if we have a config it takes precedence over URL decoding
if (config) {
await reloadWithState(config, initialUrlConfig);
} else if (initialUrlConfig) {
// try to apply state in the config
await reloadWithState(initialUrlConfig);
}
// Attempt reloading selected tables
if (get(store).selectedTables.length !== 0) {
try {
if (urlRestoredGraphOptions && urlRestoredGraphOptions !== null) {
update((store) => {
store.graphOptions = urlRestoredGraphOptions ?? undefined;
return store;
});
await reloadCurrentGraph();
}
} catch (e) {
console.error('Failed to load selected tables:', e);
}
}
// remove temporary url values
urlRestoredGraphOptions = null;
await reloadCurrentGraph();
setIsLoading(false);
return;
},
selectBuildInTables,
selectDataset: (dataset?: Dataset) => {
setTitle: (title: string) => {
update((state) => {
state.selectedDataset = dataset;
if (state.config) {
state.config.name = title;
}
return state;
});
},
selectTableFromURL: async (url: URL) => {
// Convert filter options to table references
const tableReferences: ITableExternalUrl[] = [
{
tableName: url.pathname.replaceAll('/', '-'),
source: TableSource.URL,
url: url.href
}
];
try {
await selectTables(tableReferences);
await reloadCurrentGraph();
return;
} catch (err) {
notificationStore.error({
message: 'Failed to load tables from file',
description: `${tableReferences.map((table) => table.tableName).join(',')}, err=${
err ?? 'Unknown Error'
}`
});
}
},
selectTablesFromFiles: async (fileList: FileList) => {
// Convert filter options to table references
const tableReferences: ITableExternalFile[] = [];
for (const file of fileList) {
tableReferences.push({
tableName: file.name,
source: TableSource.FILE,
file
});
}
try {
await selectTables(tableReferences);
await reloadCurrentGraph();
return;
} catch (err) {
notificationStore.error({
message: 'Failed to load tables from file',
description: `${tableReferences.map((table) => table.tableName).join(',')}, err=${
err ?? 'Unknown Error'
}`
});
}
},
selectGraphType: async (graphType: GraphType) => {
switch (graphType) {
case GraphType.PLANE: {
update((store) => {
store.graphOptions = new PlaneGraphOptions();
return store;
});
}
}
const state = get(store).graphOptions?.toStateObject();
setGraphModel(graphType, state);
}
};
};

View File

@ -1,16 +1,23 @@
import type { IPlaneChildData, IPlaneRendererData } from '$lib/rendering/PlaneRenderer';
import {
PlaneRenderer,
type IPlaneChildData,
type IPlaneRenderOptions,
type IPlaneRendererData,
PlaneTriangulation,
DataDisplayType,
type IPlaneData
} from '$lib/rendering/PlaneRenderer';
import { dataStore } from '$lib/store/dataStore/DataStore';
import { get, readonly, writable, type Readable, type Writable } from 'svelte/store';
import { GraphOptions, GraphType } from '../types';
import { DataAggregation, DataScaling } from '$lib/store/dataStore/types';
import { colorBrewer, graphColors } from '$lib/rendering/colors';
import { GraphOptions, GraphType, type GraphFilterOptions } from '../types';
import { DataAggregation, DataScaling, type ILoadedTable } from '$lib/store/dataStore/types';
import { colorBrewer } from '$lib/rendering/colors';
import type { ITiledDataOptions, ValueRange } from '$lib/store/dataStore/filterActions';
import { urlDecodeObject, urlEncodeObject, withSingleKeyUrlStorage } from '$lib/store/urlStorage';
import { withLogMiddleware } from '$lib/store/logMiddleware';
import notificationStore from '$lib/store/notificationStore';
type RequiredOptions = ITiledDataOptions & {
groupBy: string;
groupBy?: string;
};
export type IPlaneGraphState = (
@ -22,8 +29,11 @@ export type IPlaneGraphState = (
} & Partial<RequiredOptions>)
) & { isRendered: boolean };
export class PlaneGraphOptions extends GraphOptions<
const defaultInitialState = {};
export class PlaneGraphModel extends GraphOptions<
Partial<RequiredOptions>,
IPlaneRenderOptions,
IPlaneRendererData | undefined
> {
private _dataStore: Writable<IPlaneRendererData | undefined>;
@ -32,11 +42,76 @@ export class PlaneGraphOptions extends GraphOptions<
public dataStore: Readable<IPlaneRendererData | undefined>;
public optionsStore: Readable<Partial<RequiredOptions>>;
constructor(initialState: Partial<RequiredOptions> = {}) {
private _renderOptions: Writable<IPlaneRenderOptions>;
public renderStore: Readable<IPlaneRenderOptions>;
private renderOptionFields: GraphFilterOptions<IPlaneRenderOptions> = {
xAxisDataType: {
type: 'string',
label: 'X Axis type',
default: DataDisplayType.number,
options: Object.values(DataDisplayType)
},
yAxisDataType: {
type: 'string',
label: 'Y Axis type',
default: DataDisplayType.number,
options: Object.values(DataDisplayType)
},
zAxisDataType: {
type: 'string',
label: 'Z Axis type',
default: DataDisplayType.number,
options: Object.values(DataDisplayType)
},
showSelection: {
type: 'boolean',
label: 'Render value dots',
default: true,
required: true
},
pointCloudColor: {
type: 'color',
label: 'Value point color',
default: '#ffffff'
},
pointCloudSize: {
type: 'number?',
label: 'Point cloud size',
toggleLabel: 'Custom point size',
options: [0.001, 0.03],
default: 0.01,
step: 0.001
},
triangulation: {
type: 'string',
label: 'Triangulation',
options: Object.values(PlaneTriangulation),
required: true
}
};
public getRenderOptionFields() {
return this.renderOptionFields;
}
constructor(
initialState: Partial<RequiredOptions> = defaultInitialState,
renderSettings: Partial<IPlaneRenderOptions> = {}
) {
super({});
console.log({ initialState, renderSettings });
this._dataStore = writable(undefined);
this.dataStore = readonly(this._dataStore);
this._renderOptions = writable({
...PlaneRenderer.defaultRenderOptions(),
...renderSettings
} as IPlaneRenderOptions);
this.renderStore = readonly(this._renderOptions);
// Check if options are initially valid
const initialOptions = {
...initialState,
@ -44,48 +119,29 @@ export class PlaneGraphOptions extends GraphOptions<
isValid: this.isValid(initialState)
} as IPlaneGraphState;
this._optionsStore = withLogMiddleware(
withSingleKeyUrlStorage(
writable(initialOptions),
'filterStore',
(state) => {
return urlEncodeObject(state);
},
(value) => {
if (!value || value === 'undefined') {
return initialOptions;
}
const state = urlDecodeObject(value);
return {
isRendered: false,
isValid: this.isValid(state),
...state
} as IPlaneGraphState;
}
),
'PlaneGraphOptions',
{
color: 'orange'
}
);
this._optionsStore = withLogMiddleware(writable(initialOptions), 'PlaneGraphModel', {
color: 'orange'
});
this.optionsStore = readonly(this._optionsStore);
this.reloadFilterOptions();
// if no default init was passed in attempt to init with some data based defaults
if (initialState === defaultInitialState || Object.keys(initialState).length === 0) {
this.resetOptions();
} else {
// trigger re render of UI components
// and other events depending on state
this._dataStore.update((state) => state);
}
this.applyOptionsIfValid();
}
public toString(): string {
const state = get(this._optionsStore);
return urlEncodeObject({
type: this.getType(),
state
});
}
public toStateObject() {
const state = get(this._optionsStore);
return {
type: this.getType(),
state
data: this.getCurrentOptions(),
render: get(this._renderOptions)
};
}
@ -95,13 +151,23 @@ export class PlaneGraphOptions extends GraphOptions<
return null;
}
return `${this.getType()}-${state.xColumnName}-${state.yColumnName}-${state.zColumnName}-${
state.tileCount
}x${state.tileCount}`;
state.xTileCount
}x${state.zTileCount}`;
}
public setFilterOption = <K extends keyof RequiredOptions>(key: K, value: RequiredOptions[K]) => {
this._optionsStore.update((store) => {
(store as Partial<RequiredOptions>)[key] = value;
if (key === 'lockTileCounts') {
store.zTileCount = store.xTileCount;
}
if (store.lockTileCounts === true) {
if (key === 'xTileCount') {
store.zTileCount = value as number;
} else if (key === 'zTileCount') {
store.xTileCount = value as number;
}
}
store.isValid = this.isValid(store);
return store;
});
@ -109,12 +175,28 @@ export class PlaneGraphOptions extends GraphOptions<
this.applyOptionsIfValid();
};
public setRenderOption = <K extends keyof IPlaneRenderOptions>(
key: K,
value: IPlaneRenderOptions[K]
) => {
this._renderOptions.update((state) => {
state[key] = value;
return state;
});
this.applyOptionsIfValid();
};
// Utility method to check if current user input results in a valid graph state
private isValid(state: Partial<RequiredOptions> | undefined): boolean {
if (!state) {
return false;
}
const isValid = Object.entries(this.filterOptions).every(([key, value]) => {
if (Object.keys(state).length === 0) {
return false;
}
const isValid = Object.entries(this.filterOptionFields).every(([key, value]) => {
if (value.type === 'row') {
return value.keys.every((key, index) =>
value.items[index].required ? state[key] !== undefined : true
@ -124,11 +206,26 @@ export class PlaneGraphOptions extends GraphOptions<
return value.required ? state[key as keyof RequiredOptions] !== undefined : true;
});
console.log({ isValid });
return isValid;
}
private resetOptions() {
this._optionsStore.update((state) => {
for (const [k, v] of Object.entries(this.filterOptionFields)) {
console.log('setting', { k, v });
if (v.type === 'row') {
v.keys.forEach((key, index) => {
(state as any)[key as keyof RequiredOptions] = v.items[index].default;
});
} else {
(state as any)[k as keyof RequiredOptions] = v.default;
}
}
state.isValid = this.isValid(state);
return state;
});
}
// performs a DB query and constructs UI Options that will be used for dropdowns and other components
// to configure a given graph
public reloadFilterOptions() {
@ -139,10 +236,11 @@ export class PlaneGraphOptions extends GraphOptions<
const numberTableColumns = Object.entries(data.combinedSchema)
.filter(([, type]) => type === 'number')
.map(([column]) => column);
this.filterOptions = {
console.log({ numberTableColumns });
this.filterOptionFields = {
groupBy: {
type: 'string',
options: stringTableColumns,
options: [...stringTableColumns, ...numberTableColumns],
label: 'Group By'
},
aggregation: {
@ -161,13 +259,15 @@ export class PlaneGraphOptions extends GraphOptions<
type: 'string',
options: numberTableColumns,
label: 'X Axis',
required: true
required: true,
default: numberTableColumns.length > 1 ? numberTableColumns[1] : undefined
},
{
type: 'string',
options: Object.values(DataScaling),
label: 'X Scale',
required: true
required: true,
default: DataScaling.LINEAR
}
]
},
@ -180,13 +280,15 @@ export class PlaneGraphOptions extends GraphOptions<
type: 'string',
options: numberTableColumns,
label: 'Y Axis',
required: true
required: true,
default: numberTableColumns.length > 1 ? numberTableColumns[1] : undefined
},
{
type: 'string',
options: Object.values(DataScaling),
label: 'Y Scale',
required: true
required: true,
default: DataScaling.LINEAR
}
]
},
@ -199,21 +301,37 @@ export class PlaneGraphOptions extends GraphOptions<
type: 'string',
options: numberTableColumns,
label: 'Z Axis',
required: true
required: true,
default: numberTableColumns.length > 1 ? numberTableColumns[1] : undefined
},
{
type: 'string',
options: [DataScaling.LINEAR, DataScaling.LOG],
options: Object.values(DataScaling),
label: 'Z Scale',
required: true
required: true,
default: DataScaling.LINEAR
}
]
},
tileCount: {
xTileCount: {
type: 'number',
options: [2, 128],
label: 'Tile Count',
required: true
options: [2, 256],
label: 'X Tile Count',
required: true,
default: 24
},
zTileCount: {
type: 'number',
options: [2, 256],
label: 'Z Tile Count',
required: true,
default: 24
},
lockTileCounts: {
type: 'boolean',
label: 'Lock tile counts',
required: false,
default: true
}
};
}
@ -225,69 +343,31 @@ export class PlaneGraphOptions extends GraphOptions<
public getCurrentOptions() {
return get(this._optionsStore);
}
public async getGlobalRange(
columnName: string,
scaling: DataScaling
): Promise<ValueRange | null> {
const data = get(dataStore);
const tables = Object.keys(data.tables);
try {
const result = await Promise.all(
tables.map((table) => dataStore.getMinMax(table, columnName, scaling))
);
return result.reduce(
(acc, [min, max]) => [Math.min(acc[0], min), Math.max(acc[1], max)],
[0, -Infinity]
);
} catch {
return null;
}
}
// if options are valid dataStore will be updated with new values
// dataStore is used for actual rendering
public async applyOptionsIfValid() {
const state = get(this._optionsStore);
console.log('applying store', state);
if (state.isValid !== true) {
return;
}
const ranges = await this.getGlobalRanges();
if (ranges === null) {
return;
}
const [xAxisRange, yAxisRange, zAxisRange] = ranges;
// Get available tables
const data = get(dataStore);
const tables = Object.entries(data.tables);
const hasGroupBy = state.groupBy !== null && state.groupBy !== undefined;
// Get all layers
try {
const tables = Object.keys(data.tables);
console.debug('loading data from tables', tables);
const xAxisRange = await this.getGlobalRange(state.xColumnName, state.scaleX);
const yAxisRange = await this.getGlobalRange(state.yColumnName, state.scaleY);
const zAxisRange = await this.getGlobalRange(state.zColumnName, state.scaleZ);
if (!xAxisRange || !yAxisRange || !zAxisRange) {
notificationStore.error({
message: 'Data range invalid',
description: JSON.stringify({
column: state.xColumnName,
xAxisRange,
yAxisRange,
zAxisRange
})
});
return;
}
console.debug('axis ranges', {
xAxisRange,
yAxisRange,
zAxisRange
});
const promise = await Promise.all(
tables.map((table) =>
tables.map(([_, table]) =>
dataStore.getTiledData(
table,
table.tableName,
state as RequiredOptions,
xAxisRange,
yAxisRange,
@ -297,56 +377,28 @@ export class PlaneGraphOptions extends GraphOptions<
);
// If group by set also query groupBy data
const childLayers = await Promise.all<IPlaneChildData[]>(
tables.map(async (table) => {
const values = await dataStore.getDistinctValues(table, state.groupBy);
if (values.length > 30) {
const error = `Too many options returned by group by number=${values.length} (limit 10)`;
notificationStore.error({
message: error
});
throw new Error(error);
}
const childLayers = hasGroupBy
? await Promise.all(
tables.map(([_, table]) => this.queryGroupedLayers(table, state.groupBy!, ranges))
)
: null;
const data = await Promise.all(
values.map((value) =>
dataStore.getTiledData(
table,
state as RequiredOptions,
xAxisRange,
yAxisRange,
zAxisRange,
{ columnName: state.groupBy, value: value as string }
)
)
);
return data.map((value, index) => ({
points: value.data,
min: value.min,
max: value.max,
isChild: true,
name: values[index] as string,
color: colorBrewer.Set2[8][index % colorBrewer.Set2[8].length],
const layers = promise.map(
(data, index) =>
({
points: data.points,
layers: childLayers?.[index],
min: data.min,
max: data.max,
name: tables[index][1].displayName,
table: tables[index][1],
color: colorBrewer.Paired[12][index % colorBrewer.Paired[12].length],
meta: {
rows: value.queryResult
rows: data.queryResult
}
}));
})
}) as IPlaneData
);
const layers = promise.map((data, index) => ({
points: data.data,
layers: childLayers[index],
min: data.min,
max: data.max,
name: tables[index] as string,
color: colorBrewer.Paired[12][index % colorBrewer.Paired[12].length],
meta: {
rows: data.queryResult
}
}));
this._dataStore.set({
layers,
labels: {
@ -360,8 +412,13 @@ export class PlaneGraphOptions extends GraphOptions<
z: zAxisRange
},
tileRange: {
x: state.xTileCount ?? state.tileCount,
z: state.zTileCount ?? state.tileCount
x: state.xTileCount,
z: state.zTileCount
},
scales: {
x: state.scaleX,
y: state.scaleY,
z: state.scaleZ
}
});
} catch (e) {
@ -369,4 +426,138 @@ export class PlaneGraphOptions extends GraphOptions<
return;
}
}
public setColorForLayer(color: string, layerIndex: number, subLayerIndex?: number) {
// FIXME: color is not persisted correctly
let parentName = '';
let subLayerName = '';
this._dataStore.update((state) => {
let l = state?.layers[layerIndex];
parentName = l?.table.tableName ?? '';
if (subLayerIndex !== undefined) {
l = l?.layers?.at(subLayerIndex);
subLayerName = `-${l?.table.tableName}`;
}
if (!l) {
return state;
}
l.color = color;
return state;
});
this._renderOptions.update((state) => {
state[`color-${parentName}-${subLayerName}`] = color;
return state;
});
}
private async getGlobalRange(
columnName: string,
scaling: DataScaling
): Promise<ValueRange | null> {
const data = get(dataStore);
const tables = Object.values(data.tables);
try {
const result = await Promise.all(
tables.map((table) => dataStore.getMinMax(table.tableName, columnName, scaling))
);
return result.reduce(
(acc, [min, max]) => [Math.min(acc[0], min), Math.max(acc[1], max)],
[0, -Infinity]
);
} catch {
return null;
}
}
// Get ranges along all selected columns and all tables
private async getGlobalRanges(): Promise<[ValueRange, ValueRange, ValueRange] | null> {
const state = get(this._optionsStore);
if (state.isValid !== true) {
return null;
}
// Get all layers
try {
const xAxisRange = await this.getGlobalRange(state.xColumnName, state.scaleX);
const yAxisRange = await this.getGlobalRange(state.yColumnName, state.scaleY);
const zAxisRange = await this.getGlobalRange(state.zColumnName, state.scaleZ);
if (!xAxisRange || !yAxisRange || !zAxisRange) {
throw Error(`Data ranges empty, x:${xAxisRange}, y:${yAxisRange}, z:${zAxisRange}`);
}
return [xAxisRange, yAxisRange, zAxisRange];
} catch (err) {
notificationStore.error({
message: `Could not compute data range: ${err}`,
description: 'Verify databases are loaded correctly and contain the selected columns'
});
console.error({ msg: 'getGlobalRanges:', state, err });
return null;
}
}
private subLayerDisplayName = (table: ILoadedTable, groupByValue: string) => groupByValue;
private async queryGroupedLayers(
table: ILoadedTable,
groupColumn: string,
ranges: [ValueRange, ValueRange, ValueRange],
groupValueLimit: number = 40
): Promise<IPlaneChildData[]> {
const state = get(this._optionsStore);
if (state.isValid !== true) {
return [];
}
const values = (await dataStore.getDistinctValues(table.tableName, groupColumn)) as string[];
if (values.length > groupValueLimit) {
throw Error(
`Too many options for group by: col:${groupColumn} has ${values.length} distinct values`
);
}
try {
const data = await Promise.all(
values.map((value) =>
dataStore.getTiledData(
table.tableName,
state as RequiredOptions,
ranges[0],
ranges[1],
ranges[2],
{
columnName: groupColumn,
value: value as string
}
)
)
);
return data.map(
(value, index) =>
({
points: value.points,
min: value.min,
max: value.max,
isChild: true,
table,
name: this.subLayerDisplayName(table, values[index]),
groupByValue: values[index],
color: colorBrewer.Set2[8][index % colorBrewer.Set2[8].length],
meta: {
rows: value.queryResult ?? []
}
}) as IPlaneChildData
);
} catch (err) {
notificationStore.error({
message: `${err}`
});
return [];
}
}
}

View File

@ -0,0 +1,57 @@
import { get } from 'svelte/store';
import { dataStore } from '../dataStore/DataStore';
import { urlEncodeObject } from '../urlStorage';
import type { GraphStateConfig, IFilterStore, IMinimalTableRef } from './types';
import { TableSource } from '../dataStore/types';
import { updatedDiff } from 'deep-object-diff';
export const toStateObject = (state: IFilterStore): GraphStateConfig => {
const tables: IMinimalTableRef[] = Object.entries(get(dataStore).tables).map(
([tableName, table]) => ({
tableName,
// build in only need one of the sources
// since a table in a dataset can be composed from multiple parts
refs:
(table.refs ?? []).length > 0
? [
{
source: table.refs[0].source,
datasetName:
table.refs[0].source == TableSource.BUILD_IN
? table.refs[0].datasetName
: undefined
}
]
: []
})
);
// ({
// source: el.source,
// tableName: el.tableName,
// datasetName: el.source == TableSource.BUILD_IN ? el.dataset.name : undefined
// } as UrlTableSelection)
return {
name: state.config?.name ?? 'Untitled Graph',
selectedTables: tables,
// Probably defer this?
graphOption: state.graphOptions && {
type: state.graphOptions!.getType(),
...(state.graphOptions!.toStateObject() as any)
}
};
};
// Stores currently loaded DB state and filter selections
export const urlEncodeFilterState = (state: IFilterStore): string | null => {
const newState = toStateObject(state);
if (state.config) {
const d = updatedDiff(state.config, newState) as Partial<GraphStateConfig>;
if (Object.keys(d).length === 0) {
return null;
}
return urlEncodeObject(d);
}
return urlEncodeObject(newState);
};

View File

@ -1,6 +1,8 @@
import type { FilterEntry } from '$routes/graph/[slug]/+page.server';
import type { Readable } from 'svelte/store';
import type { Dataset } from '../../../dataset/types';
import type { TableSource } from '../dataStore/types';
import type { IPlaneRenderOptions } from '$lib/rendering/PlaneRenderer';
import type { IPlaneGraphState } from './graphs/plane';
export enum GraphType {
PLANE = 'plane'
@ -10,28 +12,79 @@ export type DeepPartial<T> = {
[P in keyof T]?: DeepPartial<T[P]>;
};
export type SimpleGraphFilterOption =
| (
| {
type: 'string';
options: string[];
label: string;
default?: string;
}
| {
type: 'number';
options: number[];
label: string;
default?: number;
}
| {
type: 'boolean';
label: string;
default?: boolean;
}
) & {
required?: boolean;
};
// Minimal definition of a loaded table
// omits full file paths
// should be matched agains preloaded dataset at
// init time
export type IMinimalTableRef = {
refs: {
source: TableSource;
// name: string;
url?: string;
datasetName?: string; // Only set for source == build_in
}[];
tableName: string;
};
export type GraphStateConfig = {
name?: string;
description?: string;
selectedTables: IMinimalTableRef[];
graphOption?: {
type: GraphType.PLANE;
data: IPlaneGraphState;
render: IPlaneRenderOptions;
};
ui?: {
rotation?: {
x: number;
y: number;
z: number;
};
position?: {
x: number;
y: number;
z: number;
};
};
};
// Filter options used to render UI components for dynamic configuration
export type SimpleGraphFilterOption = (
| {
type: 'string';
options: string[];
label: string;
default?: string;
}
| {
type: 'number';
options: [number, number];
step?: number;
label: string;
default?: number;
}
| {
type: 'number?';
options: [number, number];
label: string;
toggleLabel: string;
step?: number;
default?: number;
}
| {
type: 'boolean';
label: string;
default?: boolean;
}
| {
type: 'color';
label: string;
default?: string;
}
) & {
required?: boolean;
};
export type GraphFilterOption<T> =
| SimpleGraphFilterOption
@ -46,32 +99,35 @@ export type GraphFilterOptions<T> = Partial<Record<keyof T, GraphFilterOption<T>
export abstract class GraphOptions<
Options extends Record<string, unknown> = Record<string, unknown>,
RenderOptions extends Record<string, unknown> = Record<string, unknown>,
Data = unknown,
K extends keyof Options = keyof Options
K extends keyof Options = keyof Options,
RenderKey extends keyof RenderOptions = keyof RenderOptions
> {
public active = false;
public filterOptions: GraphFilterOptions<Options>;
public filterOptionFields: GraphFilterOptions<Options>;
constructor(filterOptions: GraphFilterOptions<Options>) {
this.filterOptions = filterOptions;
constructor(filterOptionFields: GraphFilterOptions<Options>) {
this.filterOptionFields = filterOptionFields;
}
public abstract getType(): GraphType;
public abstract applyOptionsIfValid(): Promise<void>;
public abstract reloadFilterOptions(): void;
public abstract setFilterOption(key: K, value: Options[K]): void;
public abstract getRenderOptionFields(): GraphFilterOptions<RenderOptions>;
public abstract setFilterOption(key: K, value: Options[K]): void;
public abstract setRenderOption(key: RenderKey, value: RenderOptions[RenderKey]): void;
// store for currently loaded data
public abstract dataStore: Readable<Data | undefined>;
// store for currently set rendering options
public abstract renderStore: Readable<RenderOptions | undefined>;
// store for currently set filter options
public abstract optionsStore: Readable<Options | undefined>;
public abstract toString(): string;
public abstract toStateObject(): {
type: GraphType;
state: any;
};
public static fromString(str: string): GraphOptions | null {
return null;
}
public abstract toStateObject(): { data: Options; render: RenderOptions };
public abstract description(): string | null;
}
@ -79,38 +135,8 @@ export abstract class GraphOptions<
export interface IFilterStore {
isLoading: boolean;
preloadedDatasets: Dataset[];
selectedDataset?: Dataset;
selectedTables: ITableReference[];
graphOptions?: GraphOptions;
// reference to base config
// used for intelligent diff URL encoding
config?: GraphStateConfig;
}
export enum TableSource {
BUILD_IN,
URL,
FILE
}
interface ITableRef {
tableName: string;
displayName?: string;
}
export interface ITableBuildIn extends ITableRef {
source: TableSource.BUILD_IN;
url: string;
dataset: Dataset;
}
export interface ITableExternalUrl extends ITableRef {
source: TableSource.URL;
url: string;
}
export interface ITableExternalFile extends ITableRef {
source: TableSource.FILE;
file: File;
}
export type ITableReference = ITableBuildIn | ITableExternalFile | ITableExternalUrl;
export type ITableRefList = ITableBuildIn[] | ITableExternalFile[] | ITableExternalUrl[];

View File

@ -1,5 +1,4 @@
import { get, type Subscriber, type Writable } from 'svelte/store';
import { detailedDiff } from 'deep-object-diff';
import type{ Subscriber, Writable } from 'svelte/store';
export const defaultLogOptions = {
color: 'blue'
};

View File

@ -60,6 +60,10 @@ export const withSingleKeyUrlStorage = <S>(
encoder: (state: S) => string | null,
decoder: (value?: string | null) => S
) => {
if (!browser) {
return store;
}
// Restore state from storage
const params = new URLSearchParams(location.search);

29
src/lib/util.ts Normal file
View File

@ -0,0 +1,29 @@
import { DataScaling } from './store/dataStore/types';
export const formatPowerOfTen = (num: number) => {
if (num === 0) return '0';
const exponent = Math.floor(Math.log10(Math.abs(num)));
return `10^${exponent}`;
};
export const labelSkipFactor = (numSegments: number) => {
if (numSegments > 100) {
return numSegments / 10;
} else if (numSegments > 25) {
return 8;
} else if (numSegments > 14) {
return 4;
} else if (numSegments > 8) {
return 2;
}
return 1;
};
export const scaleDecoder = (scale: DataScaling) => {
switch (scale) {
case DataScaling.LINEAR:
return (n: number) => n;
case DataScaling.LOG:
return (n: number) => Math.pow(2, n);
}
};

View File

@ -5,22 +5,28 @@
renderer: THREE.WebGLRenderer,
scene: THREE.Scene,
camera: THREE.Camera,
geometry: THREE.BufferGeometry<THREE.NormalBufferAttributes>,
material: THREE.Material,
group: THREE.Group
geometry?: THREE.BufferGeometry<THREE.NormalBufferAttributes>,
material?: THREE.Material,
group?: THREE.Group
) => void;
// let displatFilter: ;
export type CameraState = {
position: THREE.Vector3;
rotation: THREE.Euler;
};
export type GraphService = {
getValues: () => {
scene: THREE.Scene;
camera: THREE.Camera;
renderer: THREE.WebGLRenderer;
domElement: HTMLElement;
domElement: HTMLDivElement;
};
registerOnBeforeRender: (callback: GraphRenderLoopCallback) => GraphUnsubscribe;
registerOnAfterRender: (callback: GraphRenderLoopCallback) => GraphUnsubscribe;
getScreenshot: () => string;
getCameraState: () => CameraState;
setCameraState: (state: CameraState) => void;
};
export const CTX_NAME_GRAPH = 'graph';
@ -31,32 +37,41 @@
</script>
<script lang="ts">
import * as TWEEN from '@tweenjs/tween.js';
import * as THREE from 'three';
import { update } from '@tweenjs/tween.js';
import {
AmbientLight,
Camera,
DirectionalLight,
OrthographicCamera,
PerspectiveCamera,
Scene,
Vector2,
Vector3,
WebGLRenderer
} from 'three';
import { onMount, onDestroy, setContext, getContext } from 'svelte';
import { browser } from '$app/environment';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls';
import { EffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer';
import { RenderPass } from 'three/examples/jsm/postprocessing/RenderPass';
import { OutlinePass } from 'three/examples/jsm/postprocessing/OutlinePass';
import Stats from './graph/Stats.svelte';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
import { OutlinePass } from 'three/addons/postprocessing/OutlinePass.js';
// import Stats from './graph/Stats.svelte';
let containerElement: HTMLDivElement;
let scene: THREE.Scene;
let camera: THREE.Camera;
let renderer: THREE.WebGLRenderer;
let scene: Scene;
let camera: Camera;
let renderer: WebGLRenderer;
let controls: OrbitControls;
let outlinePass: OutlinePass;
let composer: EffectComposer;
let isSetupComplete = false;
let stats: Stats;
function setupControls() {
controls = new OrbitControls(camera, renderer.domElement);
controls.rotateSpeed = 1;
controls.zoomSpeed = 1;
controls.zoomSpeed = 0.75;
controls.panSpeed = 1;
controls.enablePan = false;
@ -77,13 +92,13 @@
function setupScene() {
// Initialize Three.js scene, camera, and renderer
scene = new THREE.Scene();
scene = new Scene();
scene.add(new THREE.AmbientLight(0xffffff, 0.5));
scene.add(new AmbientLight(0xffffff, 1));
const cameraField = Math.max(containerElement.clientWidth, containerElement.clientHeight) * 2;
camera = new THREE.OrthographicCamera(
camera = new OrthographicCamera(
containerElement.clientWidth / -2,
containerElement.clientWidth / 2,
containerElement.clientHeight / 2,
@ -97,9 +112,9 @@
camera.position.y = 2000;
// Add directional light pointing from camera
const light = new THREE.DirectionalLight(0xffffff, 1);
const light = new DirectionalLight(0xffffff, 3);
// const light = new PointLight(0xffffff, 1, 1000);
light.position.set(0, 500, 500);
light.position.set(0, 1000, 1000);
light.lookAt(0, 0, 0);
camera.add(light);
@ -107,13 +122,13 @@
}
function windowResizeHandler(evt: UIEvent) {
if (camera instanceof THREE.OrthographicCamera) {
if (camera instanceof OrthographicCamera) {
camera.left = containerElement.clientWidth / -2;
camera.right = containerElement.clientWidth / 2;
camera.top = containerElement.clientHeight / 2;
camera.bottom = containerElement.clientHeight / -2;
camera.updateProjectionMatrix();
} else if (camera instanceof THREE.PerspectiveCamera) {
} else if (camera instanceof PerspectiveCamera) {
camera.aspect = containerElement.clientWidth / containerElement.clientHeight;
camera.updateProjectionMatrix();
}
@ -130,14 +145,12 @@
setupScene();
renderer = new THREE.WebGLRenderer({
renderer = new WebGLRenderer({
alpha: false,
antialias: true,
powerPreference: 'high-performance',
// logarithmicDepthBuffer: true,
preserveDrawingBuffer: true,
// TODO: disable for production builds
failIfMajorPerformanceCaveat: true
// precision: 'lowp'
});
renderer.domElement.setAttribute('id', 'basic-graph');
renderer.setPixelRatio(window.devicePixelRatio || 1);
@ -153,7 +166,7 @@
// Outline/Hover handling
outlinePass = new OutlinePass(
new THREE.Vector2(containerElement.clientWidth, containerElement.clientHeight),
new Vector2(containerElement.clientWidth, containerElement.clientHeight),
scene,
camera
);
@ -174,7 +187,7 @@
subscriber(renderer, scene, camera);
}
// Update tween for all animations
TWEEN.update(time);
update(time);
controls.update();
composer.render();
@ -216,21 +229,33 @@
const getScreenshot = () => renderer.domElement.toDataURL('image/png');
setGraphContext({
getValues: getContextValues,
getScreenshot: getScreenshot,
registerOnBeforeRender,
registerOnAfterRender
});
const getCameraState = () => ({ rotation: camera.rotation, position: camera.position });
function getContextValues() {
export let setCameraState = (state: CameraState) => {
camera.rotation.copy(state.rotation);
camera.position.copy(state.position);
controls.update();
};
export const getValues = () => {
return {
scene,
camera,
renderer,
domElement: containerElement
};
}
};
export const getContextValues = () => ({
getValues: getValues,
getScreenshot: getScreenshot,
getCameraState: getCameraState,
setCameraState: setCameraState,
registerOnBeforeRender,
registerOnAfterRender
});
setGraphContext(getContextValues());
onDestroy(() => {
if (!browser) {
@ -243,8 +268,8 @@
renderer.dispose();
});
let mousePosition: THREE.Vector2 = new THREE.Vector2(0, 0);
let mouseClientPosition: THREE.Vector2 = new THREE.Vector2(0, 0);
let mousePosition: THREE.Vector2 = new Vector2(0, 0);
let mouseClientPosition: THREE.Vector2 = new Vector2(0, 0);
function handleHover(event: MouseEvent) {
const bounds = containerElement.getBoundingClientRect();
@ -263,16 +288,15 @@
}
</script>
<div>
<div class="relative w-screen h-screen">
<div bind:this={containerElement} class="w-full h-full overflow-hidden isolate" />
<!-- Render children only after setup complete -->
{#if isSetupComplete}
<slot />
<Stats />
{/if}
</div>
<div class="relative w-screen h-screen">
<div bind:this={containerElement} class="w-full h-full overflow-hidden isolate" />
<!-- Render children only after setup complete -->
{#if isSetupComplete}
<slot name="inner" />
<!-- <Stats /> -->
{/if}
</div>
<slot />
<style lang="scss">
.bar-chart-container {

View File

@ -0,0 +1,271 @@
<script lang="ts">
import { dataStore } from '$lib/store/dataStore/DataStore';
import filterStore from '$lib/store/filterStore/FilterStore';
import settingsStore, { Theme } from '$lib/store/SettingsStore';
import Button from '$lib/components/button/Button.svelte';
import Card from '$lib/components/Card.svelte';
import { GraphOptions, GraphType } from '$lib/store/filterStore/types';
import OptionRenderer from '$lib/components/OptionRenderer.svelte';
import Divider from '$lib/components/base/Divider.svelte';
import {
CameraIcon,
DatabaseIcon,
Edit2Icon,
LayersIcon,
MoonIcon,
PlusIcon,
RefreshCcwIcon,
SaveIcon,
SettingsIcon,
SunIcon,
XIcon
} from 'svelte-feather-icons';
import { ButtonColor, ButtonSize, ButtonVariant } from '$lib/components/button/type';
import Dialog, { DialogSize, getDialogContext } from '$lib/components/dialog/Dialog.svelte';
import TableSelection, {
type TableSelectionEvent
} from '$lib/views/tableSelection/TableSelection.svelte';
import QueryEditor from '$lib/views/QueryEditor.svelte';
import { fadeSlide } from '$lib/transitions/fadeSlide';
import { get } from 'svelte/store';
import notificationStore from '$lib/store/notificationStore';
import { getGraphContext, type GraphService } from '$lib/views/CoreGraph.svelte';
import { imageFromGlContext } from '$lib/rendering/screenshot';
import SchemaMapper from './SchemaMapper.svelte';
import H3 from '$lib/components/base/H3.svelte';
import EditableText from '$lib/components/EditableText.svelte';
const graphService: GraphService = getGraphContext();
let optionsStore: GraphOptions['optionsStore'] | undefined;
let renderStore: GraphOptions['renderStore'] | undefined;
let isFilterBarOpen: boolean = true;
$: if ($filterStore.graphOptions) {
optionsStore = $filterStore.graphOptions.optionsStore;
renderStore = $filterStore.graphOptions.renderStore;
}
function onTableSelect(evt: TableSelectionEvent) {
const { buildInTables, externalTables } = evt.detail;
if (buildInTables) {
filterStore.loadBuildInTables(
buildInTables.dataset,
buildInTables.paths.map((option) => option.value)
);
}
if (externalTables && externalTables.fileList) {
filterStore.loadTablesFromFiles(externalTables.fileList);
}
if (externalTables && externalTables.url) {
filterStore.loadTableFromURL(externalTables.url);
}
}
function toggleFilterBar() {
isFilterBarOpen = !isFilterBarOpen;
}
function captureScreenshot(backgroundFill?: string | CanvasGradient | CanvasPattern) {
const { renderer } = graphService.getValues();
const srcCtx = renderer.getContext();
const imgData = imageFromGlContext(srcCtx, backgroundFill);
if (!imgData) {
notificationStore.error({
message: 'Failed to capture screenshot',
description: 'Image data empty'
});
return;
}
let link = document.createElement('a');
link.href = imgData;
const state = get(filterStore);
let imageName = 'screenshot';
if (state.graphOptions) {
imageName = state.graphOptions.description() ?? imageName;
}
link.download = `${imageName}.png`;
link.click();
}
const copyConfigValue = () => {
const cameraState = graphService.getCameraState();
console.log(JSON.stringify(cameraState));
const state = {
...filterStore.toStateObject(),
ui: {
rotation: {
x: cameraState.rotation.x,
y: cameraState.rotation.y,
z: cameraState.rotation.z
},
position: {
x: cameraState.position.x,
y: cameraState.position.y,
z: cameraState.position.z
}
}
};
navigator.clipboard.writeText(JSON.stringify(state));
notificationStore.info({
message: 'Graph State copied to clipboard',
dismissDuration: 1000
});
};
</script>
<div class="absolute right-4 pt-4 t-0 top-0 max-h-full overflow-y-auto">
<div class="mb-4 gap-3 flex justify-end mr-1">
<Button size={ButtonSize.LG} color={ButtonColor.SECONDARY} on:click={() => captureScreenshot()}>
<div class="py">
<CameraIcon size="20" />
</div>
</Button>
<Button on:click={copyConfigValue} color={ButtonColor.SECONDARY} size={ButtonSize.LG}>
<SaveIcon slot="leading" size="20" />
</Button>
<Dialog size={DialogSize.large}>
<Button slot="trigger" color={ButtonColor.SECONDARY} size={ButtonSize.LG}>
<DatabaseIcon slot="leading" size="20" />
</Button>
<svelte:fragment slot="title">SQL Query Editor</svelte:fragment>
<QueryEditor />
</Dialog>
<Button
size={ButtonSize.LG}
color={ButtonColor.SECONDARY}
on:click={settingsStore.toggleThemeMode}
>
<div class="py">
{#if $settingsStore.theme === Theme.Dark}
<MoonIcon size="20" />
{:else}
<SunIcon size="20" />
{/if}
</div>
</Button>
<Button
size={ButtonSize.LG}
color={isFilterBarOpen ? ButtonColor.PRIMARY : ButtonColor.SECONDARY}
on:click={toggleFilterBar}
>
<div class="py">
<SettingsIcon size="20" />
</div>
</Button>
</div>
{#if isFilterBarOpen}
<div class="w-full md:w-96" transition:fadeSlide={{ duration: 100 }}>
<Card class="max-h-[80vh] md:max-h-[70vh] overflow-auto">
<div class="flex justify-between items-center">
<H3 class="select-none cursor-pointer">Loaded datasets</H3>
<Button size={ButtonSize.SM} on:click={filterStore.reset}>
<svelte:fragment slot="trailing">
<RefreshCcwIcon size="12" />
</svelte:fragment>
Reset</Button
>
</div>
<ul>
{#each Object.entries($dataStore.tables) as [tableName, table]}
<li class="flex py-1 justify-between items-center">
<div class="flex gap-2">
<span><EditableText value={table.displayName} /></span>
<Dialog>
<Button slot="trigger" variant={ButtonVariant.DEFAULT} size={ButtonSize.SM}>
<Edit2Icon size="15" />
</Button>
<SchemaMapper initiallySelectedTable={table} />
</Dialog>
</div>
<Button
on:click={() => filterStore.removeTable(tableName)}
variant={ButtonVariant.LINK}
size={ButtonSize.SM}><XIcon size="15" /></Button
>
</li>
{/each}
</ul>
<Dialog size={DialogSize.small}>
<Button slot="trigger" size={ButtonSize.SM}>
<svelte:fragment slot="trailing">
<PlusIcon size="12" />
</svelte:fragment>
Load More</Button
>
{@const dialogCtx = getDialogContext()}
<TableSelection
on:selectTable={(selection) => {
onTableSelect(selection);
dialogCtx.close();
}}
/>
</Dialog>
{#if Object.keys($dataStore.tables).length > 0}
<Divider />
<details open={!$filterStore.graphOptions?.getType()}>
<summary><H3 class="inline-block select-none cursor-pointer">Graph Type</H3></summary>
{#each Object.values(GraphType) as graphType}
<Button
color={graphType === $filterStore.graphOptions?.getType()
? ButtonColor.PRIMARY
: ButtonColor.SECONDARY}
on:click={() => filterStore.selectGraphType(graphType)}
>
<div class="flex gap-2 flex-col items-center">
<LayersIcon />
<p class="text-sm">{graphType}</p>
</div>
</Button>
{/each}
</details>
{#if optionsStore && $filterStore.graphOptions}
<Divider />
<details open>
<summary><H3 class="inline-block select-none cursor-pointer">Data Query</H3></summary>
<div class="flex flex-col gap-2">
{#each Object.entries($filterStore.graphOptions.filterOptionFields ?? {}) as [key, value]}
{#if typeof value !== 'undefined'}
<OptionRenderer
onValueChange={$filterStore.graphOptions.setFilterOption}
option={value}
state={$optionsStore}
{key}
/>
{/if}
{/each}
</div>
</details>
<Divider />
<details>
<summary
><H3 class="inline-block select-none cursor-pointer">Display options</H3></summary
>
<div class="flex flex-col gap">
{#each Object.entries($filterStore.graphOptions.getRenderOptionFields()) as [key, value]}
{#if typeof value !== 'undefined'}
<OptionRenderer
onValueChange={$filterStore.graphOptions.setRenderOption}
option={value}
state={$renderStore}
{key}
/>
{/if}
{/each}
</div>
</details>
{/if}
{/if}
</Card>
</div>
{/if}
</div>

View File

@ -1,11 +1,17 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import CodeEditor from './CodeEditor.svelte';
import CodeEditor from '$lib/components/CodeEditor.svelte';
import type { editor } from 'monaco-editor';
import Button from './button/Button.svelte';
import Button from '$lib/components/button/Button.svelte';
import { dataStore } from '$lib/store/dataStore/DataStore';
import { ButtonColor } from './button/type';
import { ButtonColor } from '$lib/components/button/type';
import DropdownSelect, {
type DropdownSelectionEvent,
type OptionConstructor
} from '$lib/components/DropdownSelect.svelte';
import type { DbQueryHistoryItem } from '$lib/store/dataStore/types';
import Tag from '$lib/components/base/Tag.svelte';
let currentQuery: ReturnType<typeof dataStore.executeQuery> | undefined = undefined;
@ -14,7 +20,7 @@
export let storageKey = 'query-editor';
export let initialQuery = '';
onMount(async () => {
onMount(() => {
// Reload query if it was saved in the store
initialQuery = sessionStorage.getItem(storageKey) || initialQuery;
@ -43,18 +49,54 @@
editor.layout();
editor.setValue(initialQuery);
}
const historyOptionConstructor: OptionConstructor<DbQueryHistoryItem, DbQueryHistoryItem> = (
value,
index,
meta
) => {
return {
label: value.query,
value: value,
id: index
};
};
const onHistoryItemSelected = (item: DropdownSelectionEvent<DbQueryHistoryItem>) => {
if (item.detail.selected.length === 0) {
return;
}
editor.setValue(item.detail.selected[0].value.query);
};
</script>
<div>
<div class="grid grid-cols-8 max-h-full">
<div class="grid grid-cols-6 max-h-full">
<div class="col-span-3">
<h3 class="font-semibold text-lg mb-3">Query</h3>
<div>
<h3 class="font-semibold text-lg mb-3">Query</h3>
<p class="flex gap-2 mb-2">
Tables:
{#each Object.values($dataStore.tables) as table}
<Tag>{table.tableName}</Tag>
{/each}
</p>
</div>
<div class="border border-background-100 dark:border-background-800">
<CodeEditor bind:editor class="h-[60vh] w-full" />
<DropdownSelect
label="Query History"
expand
singular
values={$dataStore.previousQueries}
optionConstructor={historyOptionConstructor}
on:select={onHistoryItemSelected}
/>
</div>
</div>
<div class="col-span-3">
<h3 class="font-semibold text-lg mb-3">Output</h3>
<div
class="h-[60vh] overflow-auto border-t border-b border-r border-background-100 dark:border-background-800"
style="font-family: monospace;"
@ -67,7 +109,10 @@
<tr>
<th class="px-4 py-2">ID</th>
{#each data.schema.fields as field}
<th class="px-4 py-2">{field.name}</th>
{@const row = data.get(0)}
<th class="px-4 py-2"
>{field.name} [{typeof row?.[field.name]}] [{field.type}]</th
>
{/each}
</tr>
</thead>
@ -99,32 +144,6 @@
{/if}
</div>
</div>
<div class="col-span-2">
<h3 class="font-semibold text-lg mb-3">History</h3>
<div class="h-[60vh] overflow-scroll">
{#each $dataStore.previousQueries as entry}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
class="relative border-background-100 dark:border-background-800 border-b select-none cursor-pointer first:border-t py-2 px-2 hover:text-background-900 text-background-500 hover:bg-background-100 dark:hover:bg-background-600"
style="font-family: monospace;"
on:click={() => {
editor.setValue(entry.query);
}}
>
<p class="line-clamp-2 overflow-ellipsis">
{entry.query}
</p>
<div
class="absolute right-2 bottom-2 text-sm bg-cyan-500 text-cyan-200 px-2 rounded-lg"
class:bg-red-600={!entry.success}
class:text-red-100={!entry.success}
>
{entry.executionTime.toLocaleString()}ms
</div>
</div>
{/each}
</div>
</div>
</div>
<div class="flex justify-end pt-5">
<Button color={ButtonColor.PRIMARY} on:click={onExecute}>Execute</Button>

View File

@ -0,0 +1,200 @@
<script lang="ts">
import Card from '$lib/components/Card.svelte';
import DropdownSelect, {
type DropdownSelectionEvent,
type OptionConstructor
} from '$lib/components/DropdownSelect.svelte';
import H2 from '$lib/components/base/H2.svelte';
import H3 from '$lib/components/base/H3.svelte';
import Tag, { TagColor } from '$lib/components/base/Tag.svelte';
import Button from '$lib/components/button/Button.svelte';
import { ButtonSize, ButtonVariant } from '$lib/components/button/type';
import { dataStore } from '$lib/store/dataStore/DataStore';
import {
tableSourceToString,
type ILoadedTable,
type TableTransformation,
TableSource,
type ITableReference,
TransformationType
} from '$lib/store/dataStore/types';
import { AlertTriangleIcon, EditIcon, PenToolIcon, PlusIcon, XIcon } from 'svelte-feather-icons';
import TransformerEditor, { type TransformerCreated } from './TransformerEditor.svelte';
import FilterStore from '$lib/store/filterStore/FilterStore';
export let initiallySelectedTable: ILoadedTable;
let table = initiallySelectedTable;
const typeToColor = (typename: string): TagColor => {
switch (typename) {
case 'string':
return TagColor.orange;
case 'number':
return TagColor.blue;
default:
return TagColor.default;
}
};
const isColumnNew = (columnName: string) =>
table.schema[columnName] != table.sourceSchema[columnName];
const updateTransformation = (evt: TransformerCreated) => {
// NOTE: hack to force rerender
dataStore.updatePostProcessingTransformer(table, evt.detail.transformation).finally(() => {
table = table;
FilterStore.reload();
});
};
const addTransformer = (evt: TransformerCreated) => {
// NOTE: hack to force rerender
dataStore.addPostProcessingTransformer(table, evt.detail.transformation).finally(() => {
table = table;
FilterStore.reload();
});
};
const removeTransformer = (transformer: TableTransformation) => {
// NOTE: hack to force rerender
dataStore.removePostProcessingTransformer(table, transformer).finally(() => (table = table));
};
const tableSelectionOptionConstructor: OptionConstructor<ILoadedTable, ILoadedTable> = (
value,
index
) => {
return {
label: value.displayName,
value: value,
id: index,
initiallySelected: value === table
};
};
const onTableSelected = (evt: DropdownSelectionEvent<ILoadedTable>) => {
if (evt.detail.selected.length === 0) {
return;
}
console.log({ table, new: evt.detail.selected });
table = evt.detail.selected[0].value;
};
const pathForSource = (ref: ITableReference) => {
switch (ref.source) {
case TableSource.BUILD_IN:
return ref.url;
case TableSource.FILE:
return ref.file;
case TableSource.URL:
return ref.url;
}
};
</script>
<div class="mb-2 flex gap-2 items-center">
<H2>Schema Mapper</H2><DropdownSelect
values={Object.values($dataStore.tables)}
singular
required
optionConstructor={tableSelectionOptionConstructor}
on:select={onTableSelected}
/>
</div>
<div class="w-full overflow-x-scroll mb-2 border-t border-b">
<div class="flex gap-2 py-2">
{#each table.refs as ref}<div class="flex gap-2">
<Card>
<div>
<b>Source type:</b>
<span>{tableSourceToString(ref.source)}</span>
</div>
<div class="whitespace-nowrap">
<b>URL:</b>
<span>{pathForSource(ref)}</span>
</div>
</Card>
</div>
{/each}
</div>
</div>
<div class="grid md:grid-cols-3 gap-4">
<div>
<div class="flex mb-2 gap-4 items-center">
<H3>Transformations</H3>
<TransformerEditor on:created={addTransformer}>
<Button slot="trigger" size={ButtonSize.SM}>
<svelte:fragment slot="leading">
<PlusIcon size="14" />
</svelte:fragment>
Add Transformer</Button
>
</TransformerEditor>
</div>
<div class="max-h-[60vh] overflow-auto">
{#each table.transformations as transformation}
<Card
><h3 class="flex items-center font-bold mr-8">
<span class="pr-2">{transformation.name}</span>
{#if transformation.required}
<Tag color={TagColor.red}>required</Tag>
{/if}
</h3>
<p>{transformation.description}</p>
<div class="absolute flex right-0 top-2">
{#if !transformation.required && TransformationType.SQL == transformation.type}<TransformerEditor
existingTransformation={transformation}
on:updated={updateTransformation}
>
<Button slot="trigger" size={ButtonSize.SM} variant={ButtonVariant.LINK}>
<EditIcon size="20" /></Button
>
</TransformerEditor>{/if}
<Button
disabled={transformation.required}
on:click={() => removeTransformer(transformation)}
variant={ButtonVariant.LINK}><XIcon /></Button
>
</div>
{#if transformation.lastError}<div class="mt-2">
<Tag color={TagColor.red}>
<div>
<details class="w-full block">
<summary
><div class="text-red-600 mb-2 w-full items-baseline inline-flex gap-2">
<AlertTriangleIcon /> <b>Transformation Failed</b>
</div>
</summary>
<p class="whitespace-break-spaces">{transformation.lastError.cause}</p>
</details>
</div>
</Tag>
</div>
{/if}
</Card>
{/each}
</div>
</div>
<div>
<H3 class="mb-2">Input Schema</H3>
<div class="md:max-h-[60vh] overflow-auto">
{#each Object.entries(table.sourceSchema) as [key, value]}
<Tag color={typeToColor(value)}>{key}:<b>{value}</b></Tag>
{/each}
</div>
</div>
<div>
<H3 class="mb-2">Output Schema</H3>
<div class="md:max-h-[60vh] overflow-auto">
{#each Object.entries(table.schema) as [key, value]}
<Tag color={isColumnNew(key) ? TagColor.green : typeToColor(value)}
>{key}:<b>{value}</b></Tag
>
{/each}
</div>
</div>
</div>

View File

@ -0,0 +1,169 @@
<script lang="ts" context="module">
import type {
ILoadedTable,
SqlTransformation,
TableTransformation
} from '$lib/store/dataStore/types';
import { TransformationType } from '$lib/store/dataStore/types';
export type TransformerCreated = CustomEvent<{
transformation: TableTransformation;
}>;
export type TransformerUpdated = CustomEvent<{
transformation: TableTransformation;
}>;
</script>
<script lang="ts">
import Input from '$lib/components/Input.svelte';
import MiniEditor from '$lib/components/MiniEditor.svelte';
import H2 from '$lib/components/base/H2.svelte';
import Button from '$lib/components/button/Button.svelte';
import { dataStore } from '$lib/store/dataStore/DataStore';
import { ButtonColor } from '$lib/components/button/type';
import Dialog from '$lib/components/dialog/Dialog.svelte';
import { createEventDispatcher, onMount } from 'svelte';
import DropdownSelect, {
type DropdownSelectionEvent,
type OptionConstructor
} from '$lib/components/DropdownSelect.svelte';
import notificationStore from '$lib/store/notificationStore';
import type { editor } from 'monaco-editor';
import CodeEditor from '$lib/components/CodeEditor.svelte';
import { base } from '$app/paths';
interface $$Events {
created: TransformerCreated;
updated: TransformerUpdated;
}
export let existingTransformation: SqlTransformation | undefined = undefined;
const initialExample =
'ALTER TABLE "${tableName}" ADD COLUMN example TEXT; \nUPDATE "${tableName}" SET example = \'foobar\'';
let defaultName = 'Untitled Transformation';
const eventDispatcher = createEventDispatcher();
let currentTransformation = `${initialExample}`;
let transformerName = `${defaultName}`;
let description = 'Custom SQL Transformation';
let isOpen = false;
let editor: editor.IStandaloneCodeEditor;
const createTransformation = () => {
currentTransformation = editor.getValue();
if (existingTransformation) {
existingTransformation.description = description;
existingTransformation.name = transformerName;
existingTransformation.query = editor.getValue();
eventDispatcher('updated', {
transformation: existingTransformation
});
} else {
eventDispatcher('created', {
transformation: {
type: TransformationType.SQL,
name: transformerName,
description: 'Custom SQL Transformer',
query: currentTransformation
}
});
}
isOpen = false;
};
onMount(async () => {
if (existingTransformation) {
if (existingTransformation?.type == TransformationType.SQL) {
try {
currentTransformation =
typeof existingTransformation.query === 'string'
? existingTransformation.query
: await existingTransformation.query();
description = existingTransformation.description;
transformerName = existingTransformation.name;
} catch {
notificationStore.error({
message: 'Could not load existing transformation for editing'
});
isOpen = false;
}
} else {
notificationStore.error({
message: 'Can only edit SQL transformation in current app version'
});
isOpen = false;
}
}
});
const onClose = () => {
isOpen = false;
transformerName = defaultName;
};
const transformationSelectionOptionConstructor: OptionConstructor<string, string> = (
value,
index
) => {
return {
label: value,
value: value,
id: index
};
};
const onTransformationSelected = (evt: DropdownSelectionEvent<string>) => {
if (evt.detail.selected.length === 0) {
return;
}
loadTransformation(evt.detail.selected[0].value);
};
const loadTransformation = async (fileName: string) => {
try {
currentTransformation = await fetch(base + '/transformations/' + fileName).then((resp) =>
resp.text()
);
editor.setValue(currentTransformation);
} catch {
notificationStore.error({ message: 'Failed to load transformation' });
}
};
$: if (isOpen && editor) {
editor.setValue(currentTransformation);
}
</script>
<button on:click={() => (isOpen = true)}>
<slot name="trigger" />
</button>
<Dialog dialogOpen={isOpen}>
<H2 class="mb-2">Create custom transformer</H2>
<label>Name<Input bind:value={transformerName} placeholder="Name" /></label>
<p class="mb-2 mt-4">
Use DuckDB/SQL syntax, instead of table name use <span style="font-family: monospace;"
>$&#123;tableName&#125;</span
>
<DropdownSelect
values={Object.values($dataStore.sqlTransformations)}
singular
required
optionConstructor={transformationSelectionOptionConstructor}
on:select={onTransformationSelected}
/>
</p>
<CodeEditor bind:editor class="min-h-[50vh]" />
<hr />
<div class="flex justify-end mt-2 gap-2">
<Button on:click={onClose}>Cancel</Button>
<Button color={ButtonColor.PRIMARY} on:click={createTransformation}
>{#if existingTransformation}Save
{:else}Create{/if}</Button
>
</div>
</Dialog>

View File

@ -16,16 +16,17 @@
<script lang="ts">
import filterStore from '$lib/store/filterStore/FilterStore';
import { createEventDispatcher } from 'svelte';
import DropZone from '../DropZone.svelte';
import DropZone from '$lib/components/DropZone.svelte';
import Input from '$lib/components/Input.svelte';
import DropdownSelect, {
type DropdownSelectionEvent,
type Option,
type OptionConstructor
} from '../DropdownSelect.svelte';
import Divider from '../base/Divider.svelte';
import Button from '../button/Button.svelte';
} from '$lib/components/DropdownSelect.svelte';
import Divider from '$lib/components/base/Divider.svelte';
import Button from '$lib/components/button/Button.svelte';
// FIXME: add new alias for types
import type { Dataset, DatasetItem } from '../../../dataset/types';
import { get } from 'svelte/store';
interface $$Events {
selectDataset: DatasetSelectionEvent;
@ -35,22 +36,27 @@
var urlInput: string | undefined = undefined;
const dispatch = createEventDispatcher();
function onSelectDataset(evt: DropdownSelectionEvent<Dataset>) {
const selectedDataset = $filterStore.preloadedDatasets.filter(
(option) => option === evt.detail.selected[0].value
);
var selectedDataset: Dataset | undefined = undefined;
if (selectedDataset.length === 0) {
function onSelectDataset(evt: DropdownSelectionEvent<Dataset>) {
const selectedDatasets =
evt.detail.selected.length > 0 &&
$filterStore.preloadedDatasets.filter((option) => option === evt.detail.selected[0].value);
if (!selectedDatasets || selectedDatasets.length === 0) {
dispatch('selectDataset');
selectedDataset = undefined;
} else {
dispatch('selectDataset', selectedDataset[0]);
dispatch('selectDataset', selectedDatasets[0]);
selectedDataset = selectedDatasets[0];
}
}
function onSelectTable(evt: DropdownSelectionEvent<DatasetItem>) {
console.log('Table selected', evt, selectedDataset);
dispatch('selectTable', {
buildInTables: {
dataset: get(filterStore).selectedDataset!,
dataset: selectedDataset!,
paths: evt.detail.selected
}
});
@ -82,7 +88,7 @@
label: value.name,
value: value,
id: index,
initiallySelected: value === get(filterStore).selectedDataset
initiallySelected: value === selectedDataset
};
};
@ -111,12 +117,12 @@
values={$filterStore.preloadedDatasets}
/>
<DropdownSelect
disabled={!$filterStore.selectedDataset}
disabled={!selectedDataset}
label="Table"
expand
on:select={onSelectTable}
optionConstructor={tableOptionConstructor}
values={$filterStore.selectedDataset?.items ?? []}
values={selectedDataset?.items ?? []}
/>
</div>
<!-- <DropdownSelect on:select={onSelectTable} {options} /> -->
@ -134,11 +140,6 @@
</div>
<p class="mb-2">a CSV dataset from url</p>
<form class="flex gap-2" on:submit={onUrlLoad}>
<input
bind:value={urlInput}
type="url"
placeholder="URL"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
/>
<Input bind:value={urlInput} type="url" placeholder="URL" class="" />
<Button>Load</Button>
</form>

View File

@ -4,9 +4,6 @@
import '../app.css';
import settingsStore, { Theme } from '$lib/store/SettingsStore';
import notificationStore, { NotificationType } from '$lib/store/notificationStore';
import Button from '$lib/components/button/Button.svelte';
import { XIcon } from 'svelte-feather-icons';
import { ButtonColor, ButtonSize, ButtonVariant } from '$lib/components/button/type';
import { browser } from '$app/environment';
import Notification from '$lib/components/Notification.svelte';
@ -37,14 +34,13 @@
</script>
<div
class="min-h-screen relative isolate max-h-screen max-w-full bg-slate-100 dark:bg-background-950 dark:text-slate-200"
class="min-h-screen relative isolate max-h-screen max-w-full bg-white dark:bg-background-950 dark:text-slate-200"
>
<div class="absolute top-5 flex flex-col gap-2 max-h-96 left-5 z-50">
<div class="absolute top-5 flex flex-col gap-2 max-h-96 left-5 max-sm:right-5 md z-50">
{#each $notificationStore as notification}
<Notification {notification} />
{/each}
</div>
<main>
<slot />
</main>

View File

@ -1,7 +1,7 @@
// Fetch all available data entries
import fs from 'fs';
import path from 'path';
import base from '$app/paths';
import { base } from '$app/paths';
import type { Load } from '@sveltejs/kit';
export type NamedGraph = {
@ -12,7 +12,6 @@ export type NamedGraph = {
};
const graphPath = 'static/graphs';
const graphPathAbs = `/${graphPath}`;
export const load: Load = async ({ params }) => {
const items: NamedGraph[] = fs

View File

@ -5,27 +5,49 @@
import Button from '$lib/components/button/Button.svelte';
import { ChevronRightIcon } from 'svelte-feather-icons';
import type { PageServerData } from './$types';
import { ButtonColor, ButtonSize, ButtonVariant } from '$lib/components/button/type';
import { base } from '$app/paths';
export let data: PageServerData;
let filters = [
{
name: 'Ribbon',
paper: 'https://arxiv.org/abs/2103.02515',
compare: '',
code: ''
},
{
name: 'Xor',
paper: 'https://arxiv.org/abs/1912.08258',
compare: '',
code: ''
}
];
</script>
<div class="relative min-h-screen p-10 gap-14 flex flex-col items-center">
<GridBackground />
<div class="min-h-[50vh] max-w-6xl flex flex-col gap-10 justify-center">
<div class="min-h-[25vh] md:min-h-[50vh] md:max-w-6xl flex flex-col gap-10 justify-center">
<h1
class="text-9xl font-bold max-w-[60vw] drop-shadow-[7px_6px_0px_rgba(100,10,100,0.5)] bg-gradient-to-r from-orange-600 to-purple-700 inline-block text-transparent bg-clip-text"
class="text-6xl md:text-8xl font-bold md:max-w-[60vw] drop-shadow-[7px_6px_0px_rgba(100,10,100,0.5)] bg-gradient-to-r from-orange-600 to-purple-700 inline-block text-transparent bg-clip-text"
>
Approximate Filters
</h1>
<p class="text-4xl max-w-[60vw]">visualization tool for comapring multidemsional data</p>
<p class="text-4xl max-w-[60vw]">
Amazing tradeoff between, computation, size and precision for your data
<a href="{base}/graph/custom"
><Button variant={ButtonVariant.DEFAULT} size={ButtonSize.LG} color={ButtonColor.PRIMARY}
>New Comparisson</Button
></a
>
</p>
</div>
<div class="max-w-6xl w-full">
<h2 class="text-3xl font-bold mb-4">Featured comparisons</h2>
<div class="-mx-6 grid gap-10 grid-cols-3">
<div class="-mx-6 grid gap-10 grid-cols-2 md:grid-cols-3">
{#each data.items as item}
<a href="/graph/{item.href}"
<a href="{base}/graph/{item.href}"
><MessageCard class="hover:shadow-2xl transition-shadow">
<div class="flex place-content-between items-center">
<div>
@ -41,75 +63,18 @@
</div>
<div class="max-w-6xl w-full">
<h2 class="text-3xl font-bold mb-4">Filters</h2>
<div class="-mx-6 grid gap-10 grid-cols-3">
<MessageCard>
<h3 class="text-2xl font-bold mb-4">Ribbon</h3>
<p class="opacity-60">
Lorem ipsum dolor sit amet consectetur, adipisicing elit. Quidem natus optio incidunt
placeat ab cumque ut laborum corporis omnis at fugit officiis
</p>
<div class="mt-4">
<Button>Compare</Button>
<Button>Paper</Button>
<Button>Code</Button>
</div>
</MessageCard>
<MessageCard>
<h3 class="text-2xl font-bold mb-4">Ribbon</h3>
<p class="opacity-60">
Lorem ipsum dolor sit amet consectetur, adipisicing elit. Quidem natus optio incidunt
placeat ab cumque ut laborum corporis omnis at fugit officiis
</p>
<div class="mt-4">
<Button>Compare</Button>
<Button>Paper</Button>
<Button>Code</Button>
</div>
</MessageCard>
<MessageCard>
<h3 class="text-2xl font-bold mb-4">Ribbon</h3>
<p class="opacity-60">
Lorem ipsum dolor sit amet consectetur, adipisicing elit. Quidem natus optio incidunt
placeat ab cumque ut laborum corporis omnis at fugit officiis
</p>
<div class="mt-4">
<Button>Compare</Button>
<Button>Paper</Button>
<Button>Code</Button>
</div>
</MessageCard>
<MessageCard>
<h3 class="text-2xl font-bold mb-4">Ribbon</h3>
<p class="opacity-60">
Lorem ipsum dolor sit amet consectetur, adipisicing elit. Quidem natus optio incidunt
placeat ab cumque ut laborum corporis omnis at fugit officiis
</p>
<div class="mt-4">
<Button>Compare</Button>
<Button>Paper</Button>
<Button>Code</Button>
</div>
</MessageCard>
<MessageCard>
<h3 class="text-2xl font-bold mb-4">Ribbon</h3>
<p class="opacity-60">
Lorem ipsum dolor sit amet consectetur, adipisicing elit. Quidem natus optio incidunt
placeat ab cumque ut laborum corporis omnis at fugit officiis
</p>
<div class="mt-4">
<Button>Compare</Button>
<Button>Paper</Button>
<Button>Code</Button>
</div>
</MessageCard>
<MessageCard>
<h3 class="text-2xl font-bold mb-4">Ribbon</h3>
<!-- <TableSelection /> -->
</MessageCard>
<MessageCard>
<h3 class="text-2xl font-bold mb-4">Ribbon</h3>
<!-- <TableSelection /> -->
</MessageCard>
<div class="-mx-6 grid gap-10 grid-cols-2 md:grid-cols-3">
{#each filters as filter}
<MessageCard>
<h3 class="text-2xl font-bold mb-4">{filter.name}</h3>
<div class="mt-4">
{#if filter.compare}<a href={filter.compare}><Button>Compare</Button></a>{/if}
{#if filter.paper}<a href={filter.paper}><Button>Paper</Button></a>{/if}
{#if filter.code}
<a href={filter.code}><Button>Code</Button></a>{/if}
</div>
</MessageCard>
{/each}
</div>
</div>
</div>

View File

@ -2,7 +2,7 @@
import { base } from '$app/paths';
import type { Load } from '@sveltejs/kit';
import parseDataset from '../../../dataset/tumPartitionParser.server';
import parseDataset, { parseDirectory } from '../../../dataset/tumPartitionParser.server';
export type DataEntry = {
name: string;
@ -50,8 +50,12 @@ const datasetPath = '/static/dataset';
export const load: Load = async ({ params }) => {
const dataset = parseDataset(`.${datasetPath}`);
// load all available transformers
const items = parseDirectory(`./static/transformations`);
return {
dataset
dataset,
sqlTransformations: items.map((item) => item.name)
// data: dataEntries,
// filters: filters
};

View File

@ -1,62 +1,101 @@
<script lang="ts">
import type { PageServerData } from '../$types';
import BasicGraph from '$lib/components/BasicGraph.svelte';
import LoadingOverlay from '$lib/components/LoadingOverlay.svelte';
import type { Vector2 } from 'three';
import { dataStore } from '$lib/store/dataStore/DataStore';
import { browser } from '$app/environment';
import { onMount } from 'svelte';
import { page } from '$app/stores';
import FilterSidebar from '$lib/components/FilterSidebar.svelte';
import CoreGraph, { type CameraState } from '$lib/views/CoreGraph.svelte';
import GridBackground from '$lib/components/GridBackground.svelte';
import filterStore from '$lib/store/filterStore/FilterStore';
import Minimal from '$lib/components/graph/Minimal.svelte';
import LoadingOverlay from '$lib/components/LoadingOverlay.svelte';
import Button from '$lib/components/button/Button.svelte';
import { ButtonVariant } from '$lib/components/button/type';
import Minimap from '$lib/components/graph/Minimap.svelte';
import PlaneGraph from '$lib/components/graph/PlaneGraph.svelte';
import { PlaneGraphOptions } from '$lib/store/filterStore/graphs/plane';
import type {
DatasetSelectionEvent,
TableSelectionEvent
} from '$lib/components/tableSelection/TableSelection.svelte';
import { dataStore } from '$lib/store/dataStore/DataStore';
import filterStore from '$lib/store/filterStore/FilterStore';
import { PlaneGraphModel } from '$lib/store/filterStore/graphs/plane';
import type { GraphStateConfig } from '$lib/store/filterStore/types';
import FilterSidebar from '$lib/views/FilterSidebar.svelte';
import { onMount } from 'svelte';
import { ArrowLeftCircleIcon } from 'svelte-feather-icons';
import { Euler, Vector3 } from 'three';
import type { PageServerData } from './$types';
import notificationStore from '$lib/store/notificationStore';
import EditableText from '$lib/components/EditableText.svelte';
import { base } from '$app/paths';
export let data: PageServerData;
let loadedGraph: GraphStateConfig | undefined = undefined;
let setCameraState: (state: CameraState) => void;
// Accessing the slug parameter
$: slug = $page.params.slug;
onMount(async () => {
if (!browser) return;
let selectedGraph: any;
if (slug !== 'custom') {
selectedGraph = JSON.parse(await (await fetch(`/graphs/${slug}.json`)).text());
try {
loadedGraph = JSON.parse(await (await fetch(`/graphs/${slug}.json`)).text());
} catch (err) {
console.error('Config file invalid', err);
notificationStore.error({
message: 'Config load failed',
description: 'Error while decoding JSON'
});
}
}
// Pass possible db options to the filter sidebar
await filterStore.initWithPreloadedDatasets(data.dataset, selectedGraph);
await filterStore.initWithPreloadedDatasets(data.dataset, loadedGraph);
dataStore.update((state) => {
console.log('SQL sqlTransformations', data);
state.sqlTransformations = data.sqlTransformations ?? [];
return state;
});
if (loadedGraph?.ui) {
if (loadedGraph.ui.position && loadedGraph.ui.rotation) {
setCameraState({
position: new Vector3(
loadedGraph.ui.position.x,
loadedGraph.ui.position.y,
loadedGraph.ui.position.z
),
rotation: new Euler(
loadedGraph.ui.rotation.x,
loadedGraph.ui.rotation.y,
loadedGraph.ui.rotation.z
)
});
}
}
});
let graphName = '';
$: graphName = $filterStore.config?.name ?? 'Untitled Graph';
</script>
<div>
<div class="relative">
<div class="h-screen w-full">
<div class="h-screen w-full relative">
<CoreGraph>
<svelte:fragment slot="inner">
{#if $filterStore.graphOptions}
<div class="flex-grow flex-shrink">
<div class="flex flex-col">
<BasicGraph>
{#if $filterStore.graphOptions instanceof PlaneGraphOptions}
<PlaneGraph options={$filterStore.graphOptions} />
{/if}
<Minimal />
</BasicGraph>
</div>
</div>
{#if $filterStore.graphOptions instanceof PlaneGraphModel}
<PlaneGraph options={$filterStore.graphOptions} graphScale={0.6} />
{/if}
<Minimap bind:setCameraState />
{:else}
<GridBackground />
{/if}
</div>
</svelte:fragment>
<FilterSidebar />
{#if $filterStore.isLoading || $dataStore.isLoading}
<LoadingOverlay isLoading={true} />
</CoreGraph>
<div class="absolute left-3 top-4 text-lg font-bold flex gap-2 items-center">
<a href="{base}/"><Button variant={ButtonVariant.LINK}><ArrowLeftCircleIcon /></Button></a>
{#if $filterStore.config}
<EditableText
value={graphName}
on:change={(evt) => filterStore.setTitle(evt.detail.change)}
/>
{/if}
</div>
{#if $filterStore.isLoading || $dataStore.isLoading}
<LoadingOverlay isLoading={true} />
{/if}
</div>

View File

@ -1,20 +0,0 @@
{
"name": "Bloom Filter Test",
"description": "Some description can go here",
"selectedTables": [{ "source": 0, "tableName": "bloom-count-10k", "datasetName": "experiments" }],
"graphOptions": {
"type": "plane",
"state": {
"isRendered": false,
"isValid": true,
"xColumnName": "bits",
"yColumnName": "cpu_time",
"zColumnName": "fpr",
"scaleX": "linear",
"scaleY": "linear",
"scaleZ": "linear",
"tileCount": 10,
"aggregation": "min"
}
}
}

View File

@ -0,0 +1,44 @@
{
"name": "Bloom Throughput 100M",
"description": "Compares different Bloom varations in respect to lookup thoughput",
"selectedTables": [
{ "tableName": "bloom-count-100m", "refs": [{ "source": 0, "datasetName": "experiments" }] }
],
"graphOption": {
"type": "plane",
"data": {
"isRendered": false,
"isValid": true,
"aggregation": "max",
"xColumnName": "fpr",
"scaleX": "log",
"yColumnName": "lookup_throughput",
"scaleY": "linear",
"zColumnName": "size",
"scaleZ": "linear",
"xTileCount": 28,
"zTileCount": 28,
"lockTileCounts": true,
"groupBy": "mode"
},
"render": {
"pointCloudSize": 0.005,
"triangulation": "delaunay",
"showSelection": true,
"pointCloudColor": 15658734,
"color-bloom-count-1m-output-": "rgb(244,109,67)",
"color-cuckoo-count-1m-output-": "rgb(94,79,162)",
"color-morton-count-1m-output-": "rgb(158,1,66)",
"color-xor-count-1m-output-": "rgb(171,221,164)"
}
},
"ui": {
"rotation": { "x": -1.5707962929299748, "y": -9.994708936590934e-7, "z": -1.5369264350084104 },
"position": {
"x": -0.00029984126815695223,
"y": 299.9999999998495,
"z": 0.000010159476483488965
}
}
}

View File

@ -0,0 +1,44 @@
{
"name": "Bloom Throughput 1M",
"description": "Compares different Bloom varations in respect to lookup thoughput",
"selectedTables": [
{ "tableName": "bloom-count-1m", "refs": [{ "source": 0, "datasetName": "experiments" }] }
],
"graphOption": {
"type": "plane",
"data": {
"isRendered": false,
"isValid": true,
"aggregation": "max",
"xColumnName": "fpr",
"scaleX": "log",
"yColumnName": "lookup_throughput",
"scaleY": "linear",
"zColumnName": "size",
"scaleZ": "linear",
"xTileCount": 28,
"zTileCount": 28,
"lockTileCounts": true,
"groupBy": "mode"
},
"render": {
"pointCloudSize": 0.005,
"triangulation": "delaunay",
"showSelection": true,
"pointCloudColor": 15658734,
"color-bloom-count-1m-output-": "rgb(244,109,67)",
"color-cuckoo-count-1m-output-": "rgb(94,79,162)",
"color-morton-count-1m-output-": "rgb(158,1,66)",
"color-xor-count-1m-output-": "rgb(171,221,164)"
}
},
"ui": {
"rotation": { "x": -1.5707962929299748, "y": -9.994708936590934e-7, "z": -1.5369264350084104 },
"position": {
"x": -0.00029984126815695223,
"y": 299.9999999998495,
"z": 0.000010159476483488965
}
}
}

View File

@ -0,0 +1,55 @@
{
"name": "Constuct Throughput 100M",
"description": "Comparing 100M item constructions for Cuckoo, Morton, Xor and Bloom.",
"selectedTables": [
{
"tableName": "bloom-count-100m",
"refs": [{ "source": 0, "datasetName": "experiments" }]
},
{
"tableName": "cuckoo-count-100m",
"displayName": "Cuckoo",
"refs": [{ "source": 0, "datasetName": "experiments" }]
},
{
"tableName": "morton-count-100m",
"refs": [{ "source": 0, "datasetName": "experiments" }]
},
{ "tableName": "xor-count-100m", "refs": [{ "source": 0, "datasetName": "experiments" }] }
],
"graphOption": {
"type": "plane",
"data": {
"isRendered": false,
"isValid": true,
"aggregation": "max",
"xColumnName": "fpr",
"scaleX": "log",
"yColumnName": "construction_throughput",
"scaleY": "linear",
"zColumnName": "size",
"scaleZ": "linear",
"xTileCount": 28,
"zTileCount": 28,
"lockTileCounts": true
},
"render": {
"pointCloudSize": 0.005,
"triangulation": "delaunay",
"showSelection": true,
"pointCloudColor": 15658734,
"color-bloom-count-1m-output-": "rgb(244,109,67)",
"color-cuckoo-count-1m-output-": "rgb(94,79,162)",
"color-morton-count-1m-output-": "rgb(158,1,66)",
"color-xor-count-1m-output-": "rgb(171,221,164)"
}
},
"ui": {
"rotation": { "x": -1.570796342932043, "y": -9.99914242627742e-7, "z": -1.5869334562163329 },
"position": {
"x": -0.0002999742727708546,
"y": 299.999999999849,
"z": -0.000004841143892596219
}
}
}

View File

@ -0,0 +1,45 @@
{
"name": "Construct Throughput 1M",
"description": "Comparing 1M lookups for Cuckoo, Morton, Xor and Bloom.",
"selectedTables": [
{ "tableName": "bloom-count-1m", "refs": [{ "source": 0, "datasetName": "experiments" }] },
{ "tableName": "cuckoo-count-1m", "refs": [{ "source": 0, "datasetName": "experiments" }] },
{ "tableName": "morton-count-1m", "refs": [{ "source": 0, "datasetName": "experiments" }] },
{ "tableName": "xor-count-1m", "refs": [{ "source": 0, "datasetName": "experiments" }] }
],
"graphOption": {
"type": "plane",
"data": {
"isRendered": false,
"isValid": true,
"aggregation": "max",
"xColumnName": "fpr",
"scaleX": "log",
"yColumnName": "construction_throughput",
"scaleY": "linear",
"zColumnName": "size",
"scaleZ": "linear",
"xTileCount": 28,
"zTileCount": 28,
"lockTileCounts": true
},
"render": {
"pointCloudSize": 0.005,
"triangulation": "delaunay",
"showSelection": true,
"pointCloudColor": 15658734,
"color-bloom-count-1m-output-": "rgb(244,109,67)",
"color-cuckoo-count-1m-output-": "rgb(94,79,162)",
"color-morton-count-1m-output-": "rgb(158,1,66)",
"color-xor-count-1m-output-": "rgb(171,221,164)"
}
},
"ui": {
"rotation": { "x": -1.570796342932043, "y": -9.99914242627742e-7, "z": -1.5869334562163329 },
"position": {
"x": -0.0002999742727708546,
"y": 299.999999999849,
"z": -0.000004841143892596219
}
}
}

View File

@ -0,0 +1,49 @@
{
"name": "Lookup Throughput 100M",
"description": "Comparing 100M lookups for Cuckoo, Morton, Xor and Bloom.",
"selectedTables": [
{ "tableName": "bloom-count-100m", "refs": [{ "source": 0, "datasetName": "experiments" }] },
{
"tableName": "cuckoo-count-100m",
"displayName": "Cuckoo",
"refs": [{ "source": 0, "datasetName": "experiments" }]
},
{ "tableName": "morton-count-100m", "refs": [{ "source": 0, "datasetName": "experiments" }] },
{ "tableName": "xor-count-100m", "refs": [{ "source": 0, "datasetName": "experiments" }] }
],
"graphOption": {
"type": "plane",
"data": {
"isRendered": false,
"isValid": true,
"aggregation": "max",
"xColumnName": "fpr",
"scaleX": "log",
"yColumnName": "lookup_throughput",
"scaleY": "linear",
"zColumnName": "size",
"scaleZ": "linear",
"xTileCount": 28,
"zTileCount": 28,
"lockTileCounts": true
},
"render": {
"pointCloudSize": 0.005,
"triangulation": "delaunay",
"showSelection": true,
"pointCloudColor": 15658734,
"color-bloom-count-1m-output-": "rgb(244,109,67)",
"color-cuckoo-count-1m-output-": "rgb(94,79,162)",
"color-morton-count-1m-output-": "rgb(158,1,66)",
"color-xor-count-1m-output-": "rgb(171,221,164)"
}
},
"ui": {
"rotation": { "x": -1.570796342932043, "y": -9.99914242627742e-7, "z": -1.5869334562163329 },
"position": {
"x": -0.0002999742727708546,
"y": 299.999999999849,
"z": -0.000004841143892596219
}
}
}

View File

@ -0,0 +1,45 @@
{
"name": "Lookup Throughput 1M",
"description": "Comparing 1M lookups for Cuckoo, Morton, Xor and Bloom.",
"selectedTables": [
{ "tableName": "bloom-count-1m", "refs": [{ "source": 0, "datasetName": "experiments" }] },
{ "tableName": "cuckoo-count-1m", "refs": [{ "source": 0, "datasetName": "experiments" }] },
{ "tableName": "morton-count-1m", "refs": [{ "source": 0, "datasetName": "experiments" }] },
{ "tableName": "xor-count-1m", "refs": [{ "source": 0, "datasetName": "experiments" }] }
],
"graphOption": {
"type": "plane",
"data": {
"isRendered": false,
"isValid": true,
"aggregation": "max",
"xColumnName": "fpr",
"scaleX": "log",
"yColumnName": "lookup_throughput",
"scaleY": "linear",
"zColumnName": "size",
"scaleZ": "linear",
"xTileCount": 28,
"zTileCount": 28,
"lockTileCounts": true
},
"render": {
"pointCloudSize": 0.005,
"triangulation": "delaunay",
"showSelection": true,
"pointCloudColor": 15658734,
"color-bloom-count-1m-output-": "rgb(244,109,67)",
"color-cuckoo-count-1m-output-": "rgb(94,79,162)",
"color-morton-count-1m-output-": "rgb(158,1,66)",
"color-xor-count-1m-output-": "rgb(171,221,164)"
}
},
"ui": {
"rotation": { "x": -1.570796342932043, "y": -9.99914242627742e-7, "z": -1.5869334562163329 },
"position": {
"x": -0.0002999742727708546,
"y": 299.999999999849,
"z": -0.000004841143892596219
}
}
}

View File

@ -0,0 +1,37 @@
{
"selectedTables": [
{
"source": 0,
"tableName": "bloom-count-10k",
"datasetName": "experiments"
}
],
"graphOptions": {
"type": "plane",
"state": {
"isRendered": false,
"isValid": true,
"xColumnName": "bits",
"yColumnName": "cpu_time",
"zColumnName": "fpr",
"scaleX": "linear",
"scaleY": "linear",
"scaleZ": "log",
"tileCount": 23,
"aggregation": "min",
"groupBy": "vectorization"
}
},
"ui": {
"rotation": {
"x": 1.1749577892000096,
"y": -0.5658275677115607,
"z": 0.9086866098122086
},
"position": {
"x": -101.86177149998203,
"y": -147.98539063256504,
"z": 61.84257163324188
}
}
}

View File

@ -0,0 +1,51 @@
ALTER TABLE "${tableName}" ADD COLUMN family TEXT;
ALTER TABLE "${tableName}" ADD COLUMN mode TEXT;
ALTER TABLE "${tableName}" ADD COLUMN memory TEXT;
ALTER TABLE "${tableName}" ADD COLUMN vectorization TEXT;
ALTER TABLE "${tableName}" ADD COLUMN fixture TEXT;
ALTER TABLE "${tableName}" ADD COLUMN k INTEGER;
ALTER TABLE "${tableName}" ADD COLUMN s FLOAT;
ALTER TABLE "${tableName}" ADD COLUMN n_threads INTEGER;
ALTER TABLE "${tableName}" ADD COLUMN n_partitions INTEGER;
ALTER TABLE "${tableName}" ADD COLUMN n_elements_build INTEGER;
ALTER TABLE "${tableName}" ADD COLUMN n_elements_lookup INTEGER;
ALTER TABLE "${tableName}" ADD COLUMN shared_elements FLOAT;
ALTER TABLE "${tableName}" ADD COLUMN construction_throughput FLOAT;
ALTER TABLE "${tableName}" ADD COLUMN lookup_throughput FLOAT;
WITH SplitValues AS (
SELECT
name,
SPLIT_PART(name, '_', 1) AS family,
SPLIT_PART(name, '_', 2) AS memory,
SPLIT_PART(name, '_', 3) AS mode,
SPLIT_PART(name, '_', 4) AS vectorization,
SPLIT_PART(name, '_', 5) AS k,
SPLIT_PART(name, '/', 2) AS fixture,
CAST(SPLIT_PART(name, '/', 3) AS FLOAT) / 100 AS s,
CAST(SPLIT_PART(name, '/', 4) AS INTEGER) AS n_threads,
CAST(SPLIT_PART(name, '/', 5) AS INTEGER) AS n_partitions,
CAST(SPLIT_PART(name, '/', 6) AS FLOAT) AS n_elements_build,
CAST(SPLIT_PART(name, '/', 7) AS FLOAT) AS n_elements_lookup,
CAST(SPLIT_PART(name, '/', 8) AS FLOAT) / 100 AS shared_elements
FROM "${tableName}")
UPDATE "${tableName}" AS t
SET
family = sv.family,
mode = sv.mode,
vectorization = sv.vectorization,
fixture = sv.fixture,
s = sv.s,
n_threads = sv.n_threads,
n_partitions = sv.n_partitions,
n_elements_build = sv.n_elements_build,
n_elements_lookup = sv.n_elements_lookup,
shared_elements = sv.shared_elements
FROM SplitValues AS sv
WHERE t.name = sv.name;
DELETE from "${tableName}" where "task-clock" == 'NaN';
UPDATE "${tableName}"
SET "construction_throughput" = (n_elements_build * 1000.0) / real_time
WHERE real_time IS NOT NULL AND real_time != 0;

View File

@ -0,0 +1,55 @@
ALTER TABLE "${tableName}" ADD COLUMN family TEXT;
ALTER TABLE "${tableName}" ADD COLUMN mode TEXT;
ALTER TABLE "${tableName}" ADD COLUMN vectorization TEXT;
ALTER TABLE "${tableName}" ADD COLUMN fixture TEXT;
ALTER TABLE "${tableName}" ADD COLUMN s FLOAT;
ALTER TABLE "${tableName}" ADD COLUMN n_threads INTEGER;
ALTER TABLE "${tableName}" ADD COLUMN n_partitions INTEGER;
ALTER TABLE "${tableName}" ADD COLUMN n_elements_build INTEGER;
ALTER TABLE "${tableName}" ADD COLUMN n_elements_lookup INTEGER;
ALTER TABLE "${tableName}" ADD COLUMN shared_elements FLOAT;
ALTER TABLE "${tableName}" ADD COLUMN construction_throughput FLOAT;
ALTER TABLE "${tableName}" ADD COLUMN lookup_throughput FLOAT;
WITH SplitValues AS (
SELECT
name,
SPLIT_PART(name, '_', 1) AS family,
SPLIT_PART(name, '_', 2) AS mode,
SPLIT_PART(name, '_', 4) AS vectorization,
SPLIT_PART(name, '/', 2) AS fixture,
CAST(SPLIT_PART(name, '/', 3) AS FLOAT) / 100 AS s,
CAST(SPLIT_PART(name, '/', 4) AS INTEGER) AS n_threads,
CAST(SPLIT_PART(name, '/', 5) AS INTEGER) AS n_partitions,
CAST(SPLIT_PART(name, '/', 6) AS FLOAT) AS n_elements_build,
CAST(SPLIT_PART(name, '/', 7) AS FLOAT) AS n_elements_lookup,
CAST(SPLIT_PART(name, '/', 8) AS FLOAT) / 100 AS shared_elements
FROM "${tableName}")
UPDATE "${tableName}" AS t
SET
family = sv.family,
mode = sv.mode,
vectorization = sv.vectorization,
fixture = sv.fixture,
s = sv.s,
n_threads = sv.n_threads,
n_partitions = sv.n_partitions,
n_elements_build = sv.n_elements_build,
n_elements_lookup = sv.n_elements_lookup,
shared_elements = sv.shared_elements
FROM SplitValues AS sv
WHERE t.name = sv.name;
UPDATE "${tableName}"
SET "lookup_throughput" = (n_elements_lookup * 1000.0) / real_time
WHERE real_time IS NOT NULL AND real_time != 0;
UPDATE "${tableName}"
SET "construction_throughput" = (n_elements_build * 1000.0) / real_time
WHERE real_time IS NOT NULL AND real_time != 0;
DELETE from "${tableName}" where "task-clock" == 'NaN';
UPDATE "${tableName}" as t
SET fpr = 'NaN'
WHERE fpr = -1;

View File

@ -0,0 +1 @@
DELETE from "${tableName}" where "COLUMN_NAME" != 'value'

View File

@ -0,0 +1,2 @@
ALTER TABLE "${tableName}" ADD COLUMN example TEXT;
UPDATE "${tableName}" SET example = 'foobar';

View File

@ -9,6 +9,7 @@ const config = {
// Consult https://kit.svelte.dev/docs/integrations#preprocessors
// for more information about preprocessors
preprocess: vitePreprocess({
fallback: '404.html',
postcss: {
plugins: [tailwind, autoprefixer]
}
@ -21,7 +22,8 @@ const config = {
adapter: adapter(),
// Adapt paths for GitHub Pages
paths: {
base: process.env.NODE_ENV === 'production' ? '/partition-filter-visualization' : ''
base: process.argv.includes('dev') ? '' : '/partition-filter-visualization'
//base: process.env.NODE_ENV === 'production' ? '/partition-filter-visualization' : ''
},
alias: {
$lib: './src/lib',