diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f7d11a0..968b3e1 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -8,6 +8,10 @@ on: branches: [main] workflow_dispatch: +permissions: + actions: read + contents: read + concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true @@ -18,10 +22,17 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Checkout Sigma + uses: actions/checkout@v4 + with: + repository: adsharma/sigma.js + ref: icebug-arrow-graph + path: .deps/sigma.js + - name: Install Node.js uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 24 cache: 'npm' - name: Install dependencies @@ -55,16 +66,41 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 + - name: Checkout ladybug + uses: actions/checkout@v4 + with: + repository: LadybugDB/ladybug + fetch-depth: 1 + path: ladybug + + - name: Checkout Sigma + uses: actions/checkout@v4 + with: + repository: adsharma/sigma.js + ref: icebug-arrow-graph + path: .deps/sigma.js + - name: Install Node.js uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 24 cache: 'npm' - name: Install Rust stable uses: dtolnay/rust-toolchain@stable + - name: Cache Tauri CLI + id: cache-tauri-cli + uses: actions/cache@v4 + with: + path: | + ~/.cargo/bin/cargo-tauri + ~/.cargo/registry + ~/.cargo/git + key: ${{ runner.os }}-${{ matrix.target }}-cargo-tauri-v2-${{ hashFiles('src-tauri/Cargo.lock') }} + - name: Install Tauri CLI (cargo plugin) + if: steps.cache-tauri-cli.outputs.cache-hit != 'true' run: cargo install tauri-cli --locked - name: Install dependencies (macOS) @@ -81,10 +117,58 @@ jobs: - name: Install frontend dependencies run: npm ci + - name: Build Sigma package + run: | + npm ci --prefix .deps/sigma.js + npm run build --prefix .deps/sigma.js + - name: Enable pnpm run: corepack enable + - name: Resolve compatible lbug artifact run + working-directory: ladybug + shell: bash + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + SHA="$(git rev-parse HEAD)" + API_URL="https://api.github.com/repos/LadybugDB/ladybug/actions/workflows/build-and-deploy.yml/runs" + AUTH_HEADER="Authorization: Bearer $GITHUB_TOKEN" + ACCEPT_HEADER="Accept: application/vnd.github+json" + VERSION_HEADER="X-GitHub-Api-Version: 2022-11-28" + + RUN_ID="$( + curl -fsSL \ + -H "$AUTH_HEADER" \ + -H "$ACCEPT_HEADER" \ + -H "$VERSION_HEADER" \ + "$API_URL?head_sha=$SHA&status=success&per_page=1" \ + | python -c 'import json,sys; data=json.load(sys.stdin); runs=data.get("workflow_runs") or []; print(runs[0]["id"] if runs else "")' + )" + + if [ -z "$RUN_ID" ]; then + RUN_ID="$( + curl -fsSL \ + -H "$AUTH_HEADER" \ + -H "$ACCEPT_HEADER" \ + -H "$VERSION_HEADER" \ + "$API_URL?branch=main&status=success&per_page=1" \ + | python -c 'import json,sys; data=json.load(sys.stdin); runs=data.get("workflow_runs") or []; print(runs[0]["id"] if runs else "")' + )" + fi + + if [ -z "$RUN_ID" ]; then + echo "Could not find a successful LadybugDB/ladybug build-and-deploy run." >&2 + exit 1 + fi + + echo "Using Ladybug build-and-deploy RUN_ID=$RUN_ID for SHA=$SHA" + echo "LBUG_PRECOMPILED_RUN_ID=$RUN_ID" >> "$GITHUB_ENV" + - name: Download liblbug + env: + GH_TOKEN: ${{ github.token }} + LBUG_TARGET_DIR: ${{ github.workspace }}/src-tauri/liblbug run: bash scripts/download-liblbug.sh - name: Build Tauri app (Linux) @@ -117,6 +201,7 @@ jobs: if: startsWith(github.ref, 'refs/tags/v') runs-on: ubuntu-latest permissions: + actions: read contents: write steps: diff --git a/.gitignore b/.gitignore index ad0d5de..3100e22 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ pnpm-debug.log* lerna-debug.log* node_modules +.deps dist dist-ssr *.local diff --git a/eslint.config.js b/eslint.config.js index 1097190..10b5b93 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -6,7 +6,7 @@ import tseslint from 'typescript-eslint' import { defineConfig, globalIgnores } from 'eslint/config' export default defineConfig([ - globalIgnores(['dist', 'src-tauri/target', 'src-tauri/src-tauri', 'src-tauri/gen']), + globalIgnores(['.deps', 'dist', 'src-tauri/target', 'src-tauri/src-tauri', 'src-tauri/gen']), { files: ['**/*.{ts,tsx}'], extends: [ diff --git a/package-lock.json b/package-lock.json index 7771eb0..8308d5e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,31 +1,604 @@ { "name": "bugscope", - "version": "0.1.0", + "version": "0.15.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "bugscope", - "version": "0.1.0", + "version": "0.15.1", + "dependencies": { + "@ladybugmem/icebug": "^12.8.0", + "@tauri-apps/api": "^2", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "react-force-graph-2d": "^1.29.1", + "sigma": "file:.deps/sigma.js/packages/sigma" + }, + "devDependencies": { + "@eslint/js": "^9.39.1", + "@types/node": "^24.10.1", + "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.1", + "eslint": "^9.39.1", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.24", + "globals": "^16.5.0", + "typescript": "~5.9.3", + "typescript-eslint": "^8.48.0", + "vite": "^7.3.1" + } + }, + ".deps/sigma.js/packages/sigma": { + "version": "3.0.3", + "license": "MIT", + "dependencies": { + "@ladybugmem/icebug": "^12.7.0", + "apache-arrow": "^21.1.0", + "events": "^3.3.0" + }, + "devDependencies": { + "vite": "^6.0.7" + } + }, + ".deps/sigma.js/packages/sigma/node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + ".deps/sigma.js/packages/sigma/node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + ".deps/sigma.js/packages/sigma/node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + ".deps/sigma.js/packages/sigma/node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + ".deps/sigma.js/packages/sigma/node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + ".deps/sigma.js/packages/sigma/node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + ".deps/sigma.js/packages/sigma/node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + ".deps/sigma.js/packages/sigma/node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + ".deps/sigma.js/packages/sigma/node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + ".deps/sigma.js/packages/sigma/node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + ".deps/sigma.js/packages/sigma/node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + ".deps/sigma.js/packages/sigma/node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + ".deps/sigma.js/packages/sigma/node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + ".deps/sigma.js/packages/sigma/node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + ".deps/sigma.js/packages/sigma/node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + ".deps/sigma.js/packages/sigma/node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + ".deps/sigma.js/packages/sigma/node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + ".deps/sigma.js/packages/sigma/node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + ".deps/sigma.js/packages/sigma/node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + ".deps/sigma.js/packages/sigma/node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + ".deps/sigma.js/packages/sigma/node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + ".deps/sigma.js/packages/sigma/node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + ".deps/sigma.js/packages/sigma/node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + ".deps/sigma.js/packages/sigma/node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + ".deps/sigma.js/packages/sigma/node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + ".deps/sigma.js/packages/sigma/node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + ".deps/sigma.js/packages/sigma/node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + ".deps/sigma.js/packages/sigma/node_modules/vite": { + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", + "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", + "dev": true, + "license": "MIT", "dependencies": { - "@tauri-apps/api": "^2", - "react": "^19.2.0", - "react-dom": "^19.2.0", - "react-force-graph-2d": "^1.29.1" + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" }, - "devDependencies": { - "@eslint/js": "^9.39.1", - "@types/node": "^24.10.1", - "@types/react": "^19.2.7", - "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^5.1.1", - "eslint": "^9.39.1", - "eslint-plugin-react-hooks": "^7.0.1", - "eslint-plugin-react-refresh": "^0.4.24", - "globals": "^16.5.0", - "typescript": "~5.9.3", - "typescript-eslint": "^8.48.0", - "vite": "^7.3.1" + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } } }, "node_modules/@babel/code-frame": { @@ -1011,6 +1584,19 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@ladybugmem/icebug": { + "version": "12.8.0", + "resolved": "https://registry.npmjs.org/@ladybugmem/icebug/-/icebug-12.8.0.tgz", + "integrity": "sha512-9gAi0d/T5x2EW9e3leJO5A/fDeqHsvw4Z0/Np4q2PVyH662fz2ZqIwtnEjqItoGXAf5o19PaXYB0WzGJ2rCWGQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^8.0.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-rc.3", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", @@ -1368,6 +1954,15 @@ "win32" ] }, + "node_modules/@swc/helpers": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.21.tgz", + "integrity": "sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, "node_modules/@tauri-apps/api": { "version": "2.10.1", "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.10.1.tgz", @@ -1429,6 +2024,18 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/command-line-args": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/command-line-args/-/command-line-args-5.2.3.tgz", + "integrity": "sha512-uv0aG6R0Y8WHZLTamZwtfsDLVRnOa+n+n5rEvFWL5Na5gZ8V2Teab/duDPFzIIIhs9qizDpcavCusCLJZu62Kw==", + "license": "MIT" + }, + "node_modules/@types/command-line-usage": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/command-line-usage/-/command-line-usage-5.0.4.tgz", + "integrity": "sha512-BwR5KP3Es/CSht0xqBcUXS3qCAUVXwpRKsV2+arxeb65atasuXG9LykC9Ab10Cw3s2raH92ZqOeILaQbsB2ACg==", + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1447,7 +2054,6 @@ "version": "24.10.13", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.13.tgz", "integrity": "sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~7.16.0" @@ -1842,7 +2448,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -1854,6 +2459,26 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/apache-arrow": { + "version": "21.1.0", + "resolved": "https://registry.npmjs.org/apache-arrow/-/apache-arrow-21.1.0.tgz", + "integrity": "sha512-kQrYLxhC+NTVVZ4CCzGF6L/uPVOzJmD1T3XgbiUnP7oTeVFOFgEUu6IKNwCDkpFoBVqDKQivlX4RUFqqnWFlEA==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.11", + "@types/command-line-args": "^5.2.3", + "@types/command-line-usage": "^5.0.4", + "@types/node": "^24.0.3", + "command-line-args": "^6.0.1", + "command-line-usage": "^7.0.1", + "flatbuffers": "^25.1.24", + "json-bignum": "^0.0.3", + "tslib": "^2.6.2" + }, + "bin": { + "arrow2csv": "bin/arrow2csv.js" + } + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -1861,6 +2486,15 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/array-back": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/array-back/-/array-back-6.2.3.tgz", + "integrity": "sha512-SGDvmg6QTYiTxCBkYVmThcoa67uLl35pyzRHdpCGBOcqFy6BtwnphoFPk7LhJshD+Yk1Kt35WGWeZPTgwR4Fhw==", + "license": "MIT", + "engines": { + "node": ">=12.17" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -1983,7 +2617,6 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", @@ -1996,11 +2629,25 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/chalk-template": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/chalk-template/-/chalk-template-0.4.0.tgz", + "integrity": "sha512-/ghrgmhfY8RaSdeo43hNXxpoHAtxdbskUHjPpfqUWGttFgycUhYPGx3YZBCnUCvOa7Doivn1IZec3DEGFoMgLg==", + "license": "MIT", + "dependencies": { + "chalk": "^4.1.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/chalk-template?sponsor=1" + } + }, "node_modules/chalk/node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, "license": "MIT", "dependencies": { "has-flag": "^4.0.0" @@ -2013,7 +2660,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -2026,9 +2672,46 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, "license": "MIT" }, + "node_modules/command-line-args": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/command-line-args/-/command-line-args-6.0.2.tgz", + "integrity": "sha512-AIjYVxrV9X752LmPDLbVYv8aMCuHPSLZJXEo2qo/xJfv+NYhaZ4sMSF01rM+gHPaMgvPM0l5D/F+Qx+i2WfSmQ==", + "license": "MIT", + "dependencies": { + "array-back": "^6.2.3", + "find-replace": "^5.0.2", + "lodash.camelcase": "^4.3.0", + "typical": "^7.3.0" + }, + "engines": { + "node": ">=12.20" + }, + "peerDependencies": { + "@75lb/nature": "latest" + }, + "peerDependenciesMeta": { + "@75lb/nature": { + "optional": true + } + } + }, + "node_modules/command-line-usage": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/command-line-usage/-/command-line-usage-7.0.4.tgz", + "integrity": "sha512-85UdvzTNx/+s5CkSgBm/0hzP80RFHAa7PsfeADE5ezZF3uHz3/Tqj9gIKGT9PTtpycc3Ua64T0oVulGfKxzfqg==", + "license": "MIT", + "dependencies": { + "array-back": "^6.2.2", + "chalk-template": "^0.4.0", + "table-layout": "^4.1.1", + "typical": "^7.3.0" + }, + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -2562,6 +3245,15 @@ "node": ">=0.10.0" } }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -2614,6 +3306,23 @@ "node": ">=16.0.0" } }, + "node_modules/find-replace": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/find-replace/-/find-replace-5.0.2.tgz", + "integrity": "sha512-Y45BAiE3mz2QsrN2fb5QEtO4qb44NcS7en/0y9PEVsg351HsLeVclP8QPMH79Le9sH3rs5RSwJu99W0WPZO43Q==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@75lb/nature": "latest" + }, + "peerDependenciesMeta": { + "@75lb/nature": { + "optional": true + } + } + }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -2645,6 +3354,12 @@ "node": ">=16" } }, + "node_modules/flatbuffers": { + "version": "25.9.23", + "resolved": "https://registry.npmjs.org/flatbuffers/-/flatbuffers-25.9.23.tgz", + "integrity": "sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ==", + "license": "Apache-2.0" + }, "node_modules/flatted": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", @@ -2747,7 +3462,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -2896,6 +3610,14 @@ "node": ">=6" } }, + "node_modules/json-bignum": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/json-bignum/-/json-bignum-0.0.3.tgz", + "integrity": "sha512-2WHyXj3OfHSgNyuzDbSxI1w2jgw5gkWSWhS7Qg4bWXx1nLk3jnbwfUeS0PSba3IzpTUWdHxBieELUzXRjQB2zg==", + "engines": { + "node": ">=0.8" + } + }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -2988,6 +3710,12 @@ "integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==", "license": "MIT" }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -3063,6 +3791,15 @@ "dev": true, "license": "MIT" }, + "node_modules/node-addon-api": { + "version": "8.8.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.8.0.tgz", + "integrity": "sha512-c5Ko1fZJIJmzhFIkhRN76WTq+fC6tWnGy9CXA0fA+XygsWZmEwG8vmbkNqxMyoaa0Tin4djul49NzdVcJJcjeA==", + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, "node_modules/node-releases": { "version": "2.0.27", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", @@ -3415,6 +4152,10 @@ "node": ">=8" } }, + "node_modules/sigma": { + "resolved": ".deps/sigma.js/packages/sigma", + "link": true + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -3438,6 +4179,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/table-layout": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/table-layout/-/table-layout-4.1.1.tgz", + "integrity": "sha512-iK5/YhZxq5GO5z8wb0bY1317uDF3Zjpha0QFFLA8/trAoiLbQD0HUbMesEaxyzUgDxi2QlcbM8IvqOlEjgoXBA==", + "license": "MIT", + "dependencies": { + "array-back": "^6.2.2", + "wordwrapjs": "^5.1.0" + }, + "engines": { + "node": ">=12.17" + } + }, "node_modules/tinycolor2": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz", @@ -3474,6 +4228,12 @@ "typescript": ">=4.8.4" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -3525,11 +4285,19 @@ "typescript": ">=4.8.4 <6.0.0" } }, + "node_modules/typical": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/typical/-/typical-7.3.0.tgz", + "integrity": "sha512-ya4mg/30vm+DOWfBg4YK3j2WD6TWtRkCbasOJr40CseYENzCUby/7rIvXA99JGsQHeNxLbnXdyLLxKSv3tauFw==", + "license": "MIT", + "engines": { + "node": ">=12.17" + } + }, "node_modules/undici-types": { "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", - "dev": true, "license": "MIT" }, "node_modules/update-browserslist-db": { @@ -3674,6 +4442,15 @@ "node": ">=0.10.0" } }, + "node_modules/wordwrapjs": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/wordwrapjs/-/wordwrapjs-5.1.1.tgz", + "integrity": "sha512-0yweIbkINJodk27gX9LBGMzyQdBDan3s/dEAiwBOj+Mf0PPyWL6/rikalkv8EeD0E8jm4o5RXEOrFTP3NXbhJg==", + "license": "MIT", + "engines": { + "node": ">=12.17" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/package.json b/package.json index de34faf..a4229fa 100644 --- a/package.json +++ b/package.json @@ -14,10 +14,12 @@ "preview": "vite preview" }, "dependencies": { + "@ladybugmem/icebug": "^12.8.0", "@tauri-apps/api": "^2", "react": "^19.2.0", "react-dom": "^19.2.0", - "react-force-graph-2d": "^1.29.1" + "react-force-graph-2d": "^1.29.1", + "sigma": "file:.deps/sigma.js/packages/sigma" }, "devDependencies": { "@eslint/js": "^9.39.1", diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index eac80f4..c26f5e5 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,2 +1,6 @@ -onlyBuiltDependencies: - - lbug +allowBuilds: + '@ladybugmem/icebug': true + esbuild: true + lbug: true +minimumReleaseAgeExclude: + - "@ladybugmem/*" diff --git a/scripts/download-liblbug.sh b/scripts/download-liblbug.sh index d6424ff..132904c 100755 --- a/scripts/download-liblbug.sh +++ b/scripts/download-liblbug.sh @@ -1,41 +1,85 @@ #!/usr/bin/env bash -# Download prebuilt liblbug shared library from GitHub releases. -# This is required before building the Tauri app. +# Download prebuilt liblbug archives from GitHub releases or workflow artifacts. set -euo pipefail -LBUG_VERSION="latest" -RELEASE_URL="https://github.com/LadybugDB/ladybug/releases/${LBUG_VERSION}/download" +LIB_KIND="${LBUG_LIB_KIND:-shared}" +LINUX_VARIANT="${LBUG_LINUX_VARIANT:-compat}" +REPOSITORY="${LBUG_GITHUB_REPOSITORY:-LadybugDB/ladybug}" +RUN_ID="${LBUG_PRECOMPILED_RUN_ID:-}" +VERSION_OVERRIDE="${LBUG_VERSION:-}" + +if [ "$LIB_KIND" != "shared" ] && [ "$LIB_KIND" != "static" ]; then + echo "Unsupported LBUG_LIB_KIND: $LIB_KIND (expected 'shared' or 'static')" >&2 + exit 1 +fi + +if [ "$LINUX_VARIANT" != "compat" ] && [ "$LINUX_VARIANT" != "perf" ]; then + echo "Unsupported LBUG_LINUX_VARIANT: $LINUX_VARIANT (expected 'compat' or 'perf')" >&2 + exit 1 +fi + SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" -TARGET_DIR="$PROJECT_DIR/src-tauri/liblbug" +TARGET_DIR="${LBUG_TARGET_DIR:-$PROJECT_DIR/lib}" -# Determine platform OS="$(uname -s)" ARCH="$(uname -m)" case "$OS" in Darwin) - ARCHIVE="liblbug-osx-universal.tar.gz" + if [ "$ARCH" = "x86_64" ]; then + MACOS_ARCHIVE_ARCH="x86_64" + elif [ "$ARCH" = "arm64" ]; then + MACOS_ARCHIVE_ARCH="arm64" + else + echo "Unsupported macOS architecture: $ARCH" >&2 + exit 1 + fi + if [ "$LIB_KIND" = "static" ]; then + ARCHIVE="liblbug-static-osx-${MACOS_ARCHIVE_ARCH}.tar.gz" + ARTIFACT_NAME="liblbug-static-osx-${MACOS_ARCHIVE_ARCH}" + LIB_NAME="liblbug.a" + else + ARCHIVE="liblbug-osx-${MACOS_ARCHIVE_ARCH}.tar.gz" + ARTIFACT_NAME="liblbug-osx-${MACOS_ARCHIVE_ARCH}" + LIB_NAME="liblbug.dylib" + fi ;; Linux) if [ "$ARCH" = "x86_64" ]; then - ARCHIVE="liblbug-linux-x86_64.tar.gz" - elif [ "$ARCH" = "aarch64" ]; then - ARCHIVE="liblbug-linux-aarch64.tar.gz" + LINUX_ARCHIVE_ARCH="x86_64" + elif [ "$ARCH" = "aarch64" ] || [ "$ARCH" = "arm64" ]; then + LINUX_ARCHIVE_ARCH="aarch64" else echo "Unsupported Linux architecture: $ARCH" >&2 exit 1 fi + if [ "$LIB_KIND" = "static" ]; then + ARCHIVE="liblbug-static-linux-${LINUX_ARCHIVE_ARCH}-${LINUX_VARIANT}.tar.gz" + ARTIFACT_NAME="liblbug-static-linux-${LINUX_ARCHIVE_ARCH}-${LINUX_VARIANT}" + LIB_NAME="liblbug.a" + else + ARCHIVE="liblbug-linux-${LINUX_ARCHIVE_ARCH}.tar.gz" + ARTIFACT_NAME="liblbug-linux-${LINUX_ARCHIVE_ARCH}" + LIB_NAME="liblbug.so" + fi ;; MINGW*|MSYS*|CYGWIN*) - if [ "$ARCH" = "x86_64" ]; then - ARCHIVE="liblbug-windows-x86_64.zip" - elif [ "$ARCH" = "aarch64" ]; then - ARCHIVE="liblbug-windows-aarch64.zip" + if [ "$ARCH" = "x86_64" ] || [ "$ARCH" = "AMD64" ]; then + WINDOWS_ARCHIVE_ARCH="x86_64" else echo "Unsupported Windows architecture: $ARCH" >&2 exit 1 fi + if [ "$LIB_KIND" = "static" ]; then + ARCHIVE="liblbug-static-windows-${WINDOWS_ARCHIVE_ARCH}.zip" + ARTIFACT_NAME="liblbug-static-windows-${WINDOWS_ARCHIVE_ARCH}" + LIB_NAME="lbug.lib" + else + ARCHIVE="liblbug-windows-${WINDOWS_ARCHIVE_ARCH}.zip" + ARTIFACT_NAME="liblbug-windows-${WINDOWS_ARCHIVE_ARCH}" + LIB_NAME="lbug_shared.dll" + fi ;; *) echo "Unsupported OS: $OS" >&2 @@ -43,33 +87,53 @@ case "$OS" in ;; esac -DOWNLOAD_URL="${RELEASE_URL}/${ARCHIVE}" - -# Check if already downloaded -if [ -f "$TARGET_DIR/liblbug.dylib" ] || [ -f "$TARGET_DIR/liblbug.so" ] || [ -f "$TARGET_DIR/liblbug.dll" ]; then +if [ -f "$TARGET_DIR/$LIB_NAME" ]; then echo "liblbug already exists in $TARGET_DIR" - echo "To re-download, remove the directory first: rm -rf $TARGET_DIR" exit 0 fi -echo "Downloading liblbug v${LBUG_VERSION} for ${OS}/${ARCH}..." -echo " URL: $DOWNLOAD_URL" -echo " Target: $TARGET_DIR" - mkdir -p "$TARGET_DIR" +TMPDIR="$(mktemp -d)" +trap 'rm -rf "$TMPDIR"' EXIT -# Download and extract -TMPFILE="$(mktemp)" -trap "rm -f '$TMPFILE'" EXIT +fetch_release_archive() { + local version + if [ -n "$VERSION_OVERRIDE" ]; then + version="$VERSION_OVERRIDE" + else + version="$(curl -sS "https://api.github.com/repos/${REPOSITORY}/releases/latest" | grep -o '"tag_name": "v\([^"]*\)"' | cut -d'"' -f4 | cut -c2-)" + fi + local download_url="https://github.com/${REPOSITORY}/releases/download/v${version}/${ARCHIVE}" + curl -fSL "$download_url" -o "$TMPDIR/$ARCHIVE" + echo "release:v${version}" +} -curl -fSL "$DOWNLOAD_URL" -o "$TMPFILE" +fetch_run_artifact() { + if ! command -v gh >/dev/null 2>&1; then + echo "gh CLI is required when LBUG_PRECOMPILED_RUN_ID is set" >&2 + exit 1 + fi + gh run download "$RUN_ID" --repo "$REPOSITORY" --name "$ARTIFACT_NAME" --dir "$TMPDIR/artifact" >/dev/null + local extracted_archive + extracted_archive="$(find "$TMPDIR/artifact" -type f -name "$ARCHIVE" | head -n1)" + if [ -z "$extracted_archive" ]; then + echo "Artifact ${ARTIFACT_NAME} does not contain ${ARCHIVE}" >&2 + exit 1 + fi + mv "$extracted_archive" "$TMPDIR/$ARCHIVE" + echo "run:${RUN_ID}/${ARTIFACT_NAME}" +} + +if [ -n "$RUN_ID" ]; then + SOURCE_DESC="$(fetch_run_artifact)" +else + SOURCE_DESC="$(fetch_release_archive)" +fi if [[ "$ARCHIVE" == *.zip ]]; then - unzip -o "$TMPFILE" -d "$TARGET_DIR" + unzip -o "$TMPDIR/$ARCHIVE" -d "$TARGET_DIR" else - tar xzf "$TMPFILE" -C "$TARGET_DIR" + tar xzf "$TMPDIR/$ARCHIVE" -C "$TARGET_DIR" fi -echo "" -echo "liblbug v${LBUG_VERSION} installed to $TARGET_DIR" -ls -lh "$TARGET_DIR" +echo "Installed ${ARCHIVE} from ${SOURCE_DESC} to $TARGET_DIR" diff --git a/scripts/download_lbug.sh b/scripts/download_lbug.sh new file mode 100755 index 0000000..85b3ee2 --- /dev/null +++ b/scripts/download_lbug.sh @@ -0,0 +1,69 @@ +#!/bin/sh +# Wrapper around upstream download-liblbug.sh (same pattern as go-ladybug). +# Downloads prebuilt liblbug into a local cache and writes CMake env flags. +set -eu + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" + +ENV_FILE="${1:-$PROJECT_DIR/.cache/lbug-prebuilt.env}" +CACHE_LIB_DIR="${LBUG_TARGET_DIR:-$PROJECT_DIR/.cache/lbug-prebuilt/lib}" +LIB_KIND="${LBUG_LIB_KIND:-static}" +UPSTREAM_SCRIPT="$SCRIPT_DIR/download-liblbug.sh" +UPSTREAM_URL="https://raw.githubusercontent.com/LadybugDB/ladybug/refs/heads/main/scripts/download-liblbug.sh" + +# Fetch the upstream helper if needed. +if [ ! -f "$UPSTREAM_SCRIPT" ]; then + echo "Fetching $UPSTREAM_URL ..." + curl -fsSL "$UPSTREAM_URL" -o "$UPSTREAM_SCRIPT" + chmod +x "$UPSTREAM_SCRIPT" +fi + +LBUG_TARGET_DIR="$CACHE_LIB_DIR" LBUG_LIB_KIND="$LIB_KIND" bash "$UPSTREAM_SCRIPT" + +OS="$(uname -s)" +if [ "$LIB_KIND" = "shared" ]; then + case "$OS" in + Darwin) + LIB_PATH="$CACHE_LIB_DIR/liblbug.dylib" + ;; + Linux) + LIB_PATH="$CACHE_LIB_DIR/liblbug.so" + ;; + MINGW*|MSYS*|CYGWIN*) + LIB_PATH="$CACHE_LIB_DIR/lbug_shared.dll" + ;; + *) + echo "Unsupported OS: $OS" >&2 + exit 1 + ;; + esac +else + case "$OS" in + MINGW*|MSYS*|CYGWIN*) + LIB_PATH="$CACHE_LIB_DIR/lbug.lib" + ;; + *) + LIB_PATH="$CACHE_LIB_DIR/liblbug.a" + ;; + esac +fi + +if [ ! -f "$LIB_PATH" ]; then + echo "Expected precompiled library not found at $LIB_PATH" >&2 + exit 1 +fi + +mkdir -p "$(dirname "$ENV_FILE")" +if [ "$LIB_KIND" = "shared" ]; then + cat > "$ENV_FILE" < "$ENV_FILE" <, + #[serde(rename = "expandNodeId", skip_serializing_if = "Option::is_none")] + expand_node_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + offset: Option, + #[serde(rename = "hiddenCount", skip_serializing_if = "Option::is_none")] + hidden_count: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -36,6 +44,11 @@ struct GraphData { links: Vec, } +const SEED_NODE_COUNT: usize = 8; +const EXPAND_BATCH_SIZE: usize = 8; +const EDGE_SCAN_LIMIT: usize = 10_000; +const EXPANDER_PREFIX: &str = "__expand__:"; + #[derive(Debug, Clone, Serialize, Deserialize)] struct DirEntry { name: String, @@ -54,6 +67,7 @@ struct DirectoryListing { struct AppState { custom_databases: Mutex>, + initial_database_path: Option, data_dir: PathBuf, } @@ -97,6 +111,40 @@ fn get_all_databases(state: &AppState) -> Vec { all } +fn database_info_from_path(file_path: &str) -> Result { + if file_path.is_empty() { + return Err("filePath is required".to_string()); + } + + let abs_path = if Path::new(file_path).is_absolute() { + PathBuf::from(file_path) + } else { + std::env::current_dir().unwrap_or_default().join(file_path) + }; + + if !abs_path.exists() { + return Err("File not found".to_string()); + } + + if abs_path.extension().and_then(|e| e.to_str()) != Some("lbdb") { + return Err("Only .lbdb files are supported".to_string()); + } + + let abs_path_str = abs_path.to_string_lossy().to_string(); + let name = abs_path + .file_stem() + .unwrap_or_default() + .to_string_lossy() + .to_string(); + + Ok(DatabaseInfo { + id: 0, + name, + path: abs_path_str.clone(), + relative_path: abs_path_str, + }) +} + fn value_to_string(val: &Value) -> String { match val { Value::String(s) => s.clone(), @@ -115,56 +163,267 @@ fn id_to_string(id: &lbug::InternalID) -> String { format!("{}:{}", id.table_id, id.offset) } -#[tauri::command] -fn get_databases(state: State) -> Vec { - get_all_databases(&state) +fn make_expander_node(parent_id: &str, hidden_count: usize, offset: usize) -> GraphNode { + GraphNode { + id: format!("{EXPANDER_PREFIX}node:{parent_id}:{offset}"), + name: format!("+{hidden_count}"), + label: "More".to_string(), + expansion_kind: Some("node".to_string()), + expand_node_id: Some(parent_id.to_string()), + offset: Some(offset), + hidden_count: Some(hidden_count), + } } -#[tauri::command] -fn add_database(state: State, file_path: String) -> Result { - if file_path.is_empty() { - return Err("filePath is required".to_string()); +fn merge_node(nodes: &mut HashMap, node: GraphNode) { + nodes.entry(node.id.clone()).or_insert(node); +} + +fn merge_link(links: &mut Vec, seen: &mut HashSet<(String, String, String)>, link: GraphLink) { + let key = (link.source.clone(), link.target.clone(), link.label.clone()); + if seen.insert(key) { + links.push(link); } +} - let abs_path = if Path::new(&file_path).is_absolute() { - PathBuf::from(&file_path) - } else { - std::env::current_dir().unwrap_or_default().join(&file_path) - }; +fn collect_edge_graph(conn: &Connection, limit: usize) -> Result { + let mut result = conn + .query(&format!("MATCH (a)-[r]->(b) RETURN a, r, b LIMIT {limit}")) + .map_err(|e| format!("Relationship query failed: {}", e))?; - if !abs_path.exists() { - return Err("File not found".to_string()); + let mut nodes = HashMap::new(); + let mut links = Vec::new(); + let mut seen_links = HashSet::new(); + + for row in &mut result { + if row.len() < 3 { + continue; + } + let (source_node, rel, target_node) = match (&row[0], &row[1], &row[2]) { + (Value::Node(source_node), Value::Rel(rel), Value::Node(target_node)) => { + (source_node, rel, target_node) + } + _ => continue, + }; + + let source = id_to_string(rel.get_src_node()); + let target = id_to_string(rel.get_dst_node()); + for node_val in [source_node, target_node] { + let props = node_val.get_properties(); + let name = props + .iter() + .find(|(k, _)| k == "name") + .or_else(|| props.iter().find(|(k, _)| k == "id")) + .or_else(|| props.iter().find(|(k, _)| k == "title")) + .map(|(_, val)| value_to_string(val)) + .unwrap_or_else(|| "Node".to_string()); + merge_node( + &mut nodes, + GraphNode { + id: id_to_string(node_val.get_node_id()), + name, + label: node_val.get_label_name().clone(), + expansion_kind: None, + expand_node_id: None, + offset: None, + hidden_count: None, + }, + ); + } + merge_link( + &mut links, + &mut seen_links, + GraphLink { + source, + target, + label: rel.get_label_name().clone(), + }, + ); } - if abs_path.extension().and_then(|e| e.to_str()) != Some("lbdb") { - return Err("Only .lbdb files are supported".to_string()); + Ok(GraphData { + nodes: nodes.into_values().collect(), + links, + }) +} + +fn add_expanders(graph: &GraphData, visible_ids: &HashSet, nodes: &mut Vec, links: &mut Vec) { + let known_ids: HashSet = graph.nodes.iter().map(|node| node.id.clone()).collect(); + let mut neighbors: HashMap> = HashMap::new(); + for link in &graph.links { + neighbors + .entry(link.source.clone()) + .or_default() + .insert(link.target.clone()); + neighbors + .entry(link.target.clone()) + .or_default() + .insert(link.source.clone()); } - let abs_path_str = abs_path.to_string_lossy().to_string(); + for node_id in visible_ids { + let hidden_count = neighbors + .get(node_id) + .map(|items| { + items + .iter() + .filter(|neighbor_id| !visible_ids.contains(*neighbor_id) && known_ids.contains(*neighbor_id)) + .count() + }) + .unwrap_or(0); + if hidden_count == 0 { + continue; + } - let mut custom = state.custom_databases.lock().unwrap(); - if custom.iter().any(|d| d.path == abs_path_str) { - return Err("Database already added".to_string()); + let expander = make_expander_node(node_id, hidden_count, 0); + links.push(GraphLink { + source: node_id.clone(), + target: expander.id.clone(), + label: "more".to_string(), + }); + nodes.push(expander); } +} - let name = abs_path - .file_stem() - .unwrap_or_default() - .to_string_lossy() - .to_string(); +fn seed_graph_from_full(full_graph: GraphData) -> GraphData { + let mut degrees: HashMap = HashMap::new(); + for node in &full_graph.nodes { + degrees.entry(node.id.clone()).or_insert(0); + } + for link in &full_graph.links { + *degrees.entry(link.source.clone()).or_insert(0) += 1; + *degrees.entry(link.target.clone()).or_insert(0) += 1; + } - let db_info = DatabaseInfo { - id: 0, // will be recalculated - name, - path: abs_path_str.clone(), - relative_path: abs_path_str, - }; + let mut ranked_nodes = full_graph.nodes.clone(); + ranked_nodes.sort_by_key(|node| std::cmp::Reverse(*degrees.get(&node.id).unwrap_or(&0))); + let visible_ids: HashSet = ranked_nodes + .iter() + .take(SEED_NODE_COUNT) + .map(|node| node.id.clone()) + .collect(); + + let mut nodes: Vec = ranked_nodes + .into_iter() + .filter(|node| visible_ids.contains(&node.id)) + .collect(); + let mut links: Vec = full_graph + .links + .iter() + .filter(|link| visible_ids.contains(&link.source) && visible_ids.contains(&link.target)) + .cloned() + .collect(); + + add_expanders(&full_graph, &visible_ids, &mut nodes, &mut links); + GraphData { nodes, links } +} + +fn expand_node_from_full(full_graph: GraphData, node_id: &str, visible_node_ids: &[String], offset: usize) -> GraphData { + let visible_ids: HashSet = visible_node_ids + .iter() + .filter(|id| !id.starts_with(EXPANDER_PREFIX)) + .cloned() + .collect(); + + let mut degrees: HashMap = HashMap::new(); + for link in &full_graph.links { + *degrees.entry(link.source.clone()).or_insert(0) += 1; + *degrees.entry(link.target.clone()).or_insert(0) += 1; + } + + let mut neighbor_ids: Vec = full_graph + .links + .iter() + .filter_map(|link| { + if link.source == node_id { + Some(link.target.clone()) + } else if link.target == node_id { + Some(link.source.clone()) + } else { + None + } + }) + .filter(|neighbor_id| !visible_ids.contains(neighbor_id)) + .collect(); + neighbor_ids.sort(); + neighbor_ids.dedup(); + neighbor_ids.sort_by_key(|id| std::cmp::Reverse(*degrees.get(id).unwrap_or(&0))); + + let selected_ids: HashSet = neighbor_ids + .iter() + .skip(offset) + .take(EXPAND_BATCH_SIZE) + .cloned() + .collect(); + + let full_node_by_id: HashMap = full_graph + .nodes + .iter() + .map(|node| (node.id.clone(), node.clone())) + .collect(); + let mut return_ids = visible_ids.clone(); + return_ids.extend(selected_ids.iter().cloned()); + + let mut nodes: Vec = selected_ids + .iter() + .filter_map(|id| full_node_by_id.get(id).cloned()) + .collect(); + let mut links: Vec = full_graph + .links + .iter() + .filter(|link| return_ids.contains(&link.source) && return_ids.contains(&link.target)) + .cloned() + .collect(); + + let next_offset = offset + selected_ids.len(); + let remaining = neighbor_ids.len().saturating_sub(next_offset); + if remaining > 0 { + let expander = make_expander_node(node_id, remaining, next_offset); + links.push(GraphLink { + source: node_id.to_string(), + target: expander.id.clone(), + label: "more".to_string(), + }); + nodes.push(expander); + } + + GraphData { nodes, links } +} + +#[tauri::command] +fn get_databases(state: State) -> Vec { + get_all_databases(&state) +} + +#[tauri::command] +fn get_initial_database_id(state: State) -> Option { + let initial_path = state.initial_database_path.as_ref()?; + get_all_databases(&state) + .iter() + .find(|db| db.path == *initial_path) + .map(|db| db.id) +} + +fn add_database_info(state: &AppState, db_info: DatabaseInfo) -> Result { + let mut custom = state.custom_databases.lock().unwrap(); + if custom.iter().any(|d| d.path == db_info.path) { + return Err("Database already added".to_string()); + } custom.push(db_info.clone()); // Return with correct id drop(custom); let all = get_all_databases(&state); - Ok(all.last().cloned().unwrap_or(db_info)) + Ok(all + .into_iter() + .find(|db| db.path == db_info.path) + .unwrap_or(db_info)) +} + +#[tauri::command] +fn add_database(state: State, file_path: String) -> Result { + let db_info = database_info_from_path(&file_path)?; + add_database_info(&state, db_info) } #[tauri::command] @@ -178,10 +437,12 @@ fn get_directories( if resolved.is_absolute() { resolved } else { - state.data_dir.join(p) + std::env::current_dir() + .unwrap_or_else(|_| state.data_dir.clone()) + .join(p) } } - _ => state.data_dir.clone(), + _ => std::env::current_dir().unwrap_or_else(|_| state.data_dir.clone()), }; let entries = fs::read_dir(&dir).map_err(|e| e.to_string())?; @@ -237,94 +498,31 @@ fn get_graph(state: State, id: usize) -> Result { .map_err(|e| format!("Failed to open database: {}", e))?; let conn = Connection::new(&db).map_err(|e| format!("Failed to create connection: {}", e))?; - // Query nodes - let mut nodes_result = conn - .query("MATCH (n) RETURN n, LABEL(n) as label, ID(n) as nodeId LIMIT 500") - .map_err(|e| format!("Node query failed: {}", e))?; - - let mut nodes = Vec::new(); - for row in &mut nodes_result { - // row is Vec with 3 columns: [node, label, nodeId] - if row.len() < 3 { - continue; - } - - let node_id = match &row[2] { - Value::InternalID(id) => id_to_string(id), - _ => continue, - }; - - let label = match &row[1] { - Value::String(s) => s.clone(), - _ => "Node".to_string(), - }; - - // Extract display name from node properties - let name = match &row[0] { - Value::Node(node_val) => { - let props = node_val.get_properties(); - let name_val = props - .iter() - .find(|(k, _)| k == "name") - .or_else(|| props.iter().find(|(k, _)| k == "id")) - .or_else(|| props.iter().find(|(k, _)| k == "title")); - match name_val { - Some((_, val)) => value_to_string(val), - None => "Node".to_string(), - } - } - _ => "Node".to_string(), - }; - - nodes.push(GraphNode { - id: node_id, - name, - label, - }); - } - - // Query links - let mut links_result = conn - .query( - "MATCH (a)-[r]->(b) RETURN ID(a) as src, ID(b) as dst, LABEL(r) as relType LIMIT 500", - ) - .map_err(|e| format!("Link query failed: {}", e))?; - - let node_id_set: HashSet = nodes.iter().map(|n| n.id.clone()).collect(); - - let mut links = Vec::new(); - for row in &mut links_result { - // row is Vec with 3 columns: [src, dst, relType] - if row.len() < 3 { - continue; - } - - let source = match &row[0] { - Value::InternalID(id) => id_to_string(id), - _ => continue, - }; - - let target = match &row[1] { - Value::InternalID(id) => id_to_string(id), - _ => continue, - }; - - let label = match &row[2] { - Value::String(s) => s.clone(), - _ => String::new(), - }; + collect_edge_graph(&conn, EDGE_SCAN_LIMIT).map(seed_graph_from_full) +} - // Only include links where both endpoints exist in our node set - if node_id_set.contains(&source) && node_id_set.contains(&target) { - links.push(GraphLink { - source, - target, - label, - }); - } - } +#[tauri::command] +fn expand_node( + state: State, + id: usize, + node_id: String, + visible_node_ids: Vec, + offset: Option, +) -> Result { + let databases = get_all_databases(&state); + let db_info = databases.get(id).ok_or("Database not found")?; - Ok(GraphData { nodes, links }) + let db = Database::new(&db_info.path, SystemConfig::default()) + .map_err(|e| format!("Failed to open database: {}", e))?; + let conn = Connection::new(&db).map_err(|e| format!("Failed to create connection: {}", e))?; + let full_graph = collect_edge_graph(&conn, EDGE_SCAN_LIMIT)?; + + Ok(expand_node_from_full( + full_graph, + &node_id, + &visible_node_ids, + offset.unwrap_or(0), + )) } #[tauri::command] @@ -368,6 +566,10 @@ fn execute_query(state: State, id: usize, query: String) -> Result Some(db_info), + Err(err) => { + eprintln!("Ignoring database path {arg:?}: {err}"); + None + } + }); + let initial_database_path = initial_database.as_ref().map(|db| db.path.clone()); app.manage(AppState { - custom_databases: Mutex::new(Vec::new()), + custom_databases: Mutex::new(initial_database.into_iter().collect()), + initial_database_path, data_dir, }); Ok(()) }) .invoke_handler(tauri::generate_handler![ get_databases, + get_initial_database_id, add_database, get_directories, get_graph, + expand_node, execute_query, ]) .run(tauri::generate_context!()) diff --git a/src/App.css b/src/App.css index 3404e90..310ca3d 100644 --- a/src/App.css +++ b/src/App.css @@ -110,6 +110,9 @@ body { align-items: center; justify-content: space-between; gap: 16px; + flex-shrink: 0; + position: relative; + z-index: 2; } .header-left { @@ -124,6 +127,38 @@ body { gap: 12px; } +.renderer-toggle { + display: inline-grid; + grid-template-columns: 1fr 1fr; + background-color: var(--bg-tertiary); + border: 1px solid var(--border-color); + border-radius: 6px; + overflow: hidden; +} + +.renderer-toggle button { + min-width: 68px; + padding: 8px 12px; + background: transparent; + color: var(--text-secondary); + border: 0; + cursor: pointer; + font-size: 14px; +} + +.renderer-toggle button + button { + border-left: 1px solid var(--border-color); +} + +.renderer-toggle button.active { + background-color: var(--accent-color); + color: white; +} + +.renderer-toggle button:hover:not(.active) { + color: var(--text-primary); +} + .query-box { display: flex; align-items: flex-start; @@ -221,6 +256,13 @@ body { min-height: 0; position: relative; overflow: hidden; + z-index: 1; +} + +.sigma-canvas { + position: absolute; + inset: 0; + background-color: var(--bg-primary); } .add-db-btn { diff --git a/src/App.tsx b/src/App.tsx index 32e5f91..8762947 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,7 +1,8 @@ import { useState, useEffect, useMemo, useCallback, useRef } from 'react' import { invoke } from '@tauri-apps/api/core' import ForceGraph2D from 'react-force-graph-2d' -import type { NodeObject } from 'react-force-graph-2d' +import type { GraphData as ForceGraphData, NodeObject } from 'react-force-graph-2d' +import { Sigma } from './vendor/sigma-runtime.js' import './App.css' interface Database { @@ -15,11 +16,15 @@ interface GraphNode { id: string name: string label: string + expansionKind?: 'node' | 'cluster' + expandNodeId?: string + offset?: number + hiddenCount?: number } interface GraphLink { - source: string - target: string + source: string | NodeObject + target: string | NodeObject label: string } @@ -28,6 +33,488 @@ interface GraphData { links: GraphLink[] } +interface NormalizedGraphLink { + source: string + target: string + label: string +} + +interface NormalizedGraphData { + nodes: GraphNode[] + links: NormalizedGraphLink[] +} + +interface ForceGraphLink { + label: string +} + +interface SigmaNodeAttributes extends Record { + x: number + y: number + size: number + color: string + label: string + hoverLabel: string + isNewlyExpanded: boolean + nodeType: string +} + +interface SigmaEdgeAttributes extends Record { + size: number + color: string + label: string + forceLabel: boolean +} + +interface SigmaGraphViewProps { + graphData: NormalizedGraphData + labelNodeIds: Set + newlyExpandedNodeIds: Set + darkMode: boolean + getNodeColor: (label: string) => string + getEdgeColor: (label: string) => string + onNodeClick: (nodeId: string) => void +} + +interface SigmaLabelData { + x: number + y: number + size: number + label?: string + hoverLabel?: string + color: string + isNewlyExpanded?: boolean +} + +interface SigmaEdgeLabelData { + key?: string + label?: string + size: number + forceLabel?: boolean +} + +interface SigmaEdgeLabelNodeData { + x: number + y: number + size: number +} + +class SigmaGraph< + N extends Record = Record, + E extends Record = Record, +> { + private nodeAttributes = new Map() + private edgeRecords = new Map() + + get order() { + return this.nodeAttributes.size + } + + addNode(key: string, attributes = {} as N): void { + if (this.nodeAttributes.has(key)) throw new Error(`SigmaGraph: node "${key}" already exists.`) + this.nodeAttributes.set(key, attributes) + } + + addEdge(key: string, source: string, target: string, attributes = {} as E): void { + if (this.edgeRecords.has(key)) throw new Error(`SigmaGraph: edge "${key}" already exists.`) + if (!this.nodeAttributes.has(source)) this.addNode(source) + if (!this.nodeAttributes.has(target)) this.addNode(target) + this.edgeRecords.set(key, { source, target, attributes }) + } + + hasNode(key: string): boolean { + return this.nodeAttributes.has(key) + } + + hasEdge(key: string): boolean { + return this.edgeRecords.has(key) + } + + nodes(): string[] { + return [...this.nodeAttributes.keys()] + } + + edges(): string[] { + return [...this.edgeRecords.keys()] + } + + forEachNode(callback: (key: string, attributes: N) => void): void { + this.nodeAttributes.forEach((attributes, key) => callback(key, attributes)) + } + + forEachEdge(callback: (key: string, attributes: E) => void): void { + this.edgeRecords.forEach(({ attributes }, key) => callback(key, attributes)) + } + + getNodeAttributes(key: string): N { + const attributes = this.nodeAttributes.get(key) + if (!attributes) throw new Error(`SigmaGraph: node "${key}" not found.`) + return attributes + } + + getEdgeAttributes(key: string): E { + const record = this.edgeRecords.get(key) + if (!record) throw new Error(`SigmaGraph: edge "${key}" not found.`) + return record.attributes + } + + extremities(key: string): [string, string] { + const record = this.edgeRecords.get(key) + if (!record) throw new Error(`SigmaGraph: edge "${key}" not found.`) + return [record.source, record.target] + } + + on(): void {} + + removeListener(): void {} +} + +function getEndpointId(endpoint: string | NodeObject): string { + return typeof endpoint === 'object' ? String(endpoint.id) : endpoint +} + +function normalizeGraphData(graphData: GraphData): NormalizedGraphData { + return { + nodes: graphData.nodes.map(node => ({ ...node })), + links: graphData.links.map(link => ({ + source: getEndpointId(link.source), + target: getEndpointId(link.target), + label: link.label, + })), + } +} + +const EXPANDER_PREFIX = '__expand__:' + +function isExpanderNode(node: GraphNode) { + return Boolean(node.expansionKind) || node.id.startsWith(EXPANDER_PREFIX) +} + +function mergeGraphData(current: GraphData, incoming: GraphData, expandedNodeId?: string): GraphData { + const nodesById = new Map() + current.nodes + .filter(node => node.id !== expandedNodeId) + .forEach(node => nodesById.set(node.id, { ...node })) + incoming.nodes.forEach(node => nodesById.set(node.id, { ...node })) + + const linksByKey = new Map() + current.links + .filter(link => getEndpointId(link.source) !== expandedNodeId && getEndpointId(link.target) !== expandedNodeId) + .forEach(link => linksByKey.set(`${getEndpointId(link.source)}\t${getEndpointId(link.target)}\t${link.label}`, { ...link })) + incoming.links.forEach(link => { + linksByKey.set(`${getEndpointId(link.source)}\t${getEndpointId(link.target)}\t${link.label}`, { ...link }) + }) + + return { + nodes: [...nodesById.values()], + links: [...linksByKey.values()], + } +} + +function realNodeIds(nodes: GraphNode[]): Set { + return new Set(nodes.filter(node => !isExpanderNode(node)).map(node => node.id)) +} + +function drawRoundedRect( + context: CanvasRenderingContext2D, + x: number, + y: number, + width: number, + height: number, + radius: number, +) { + context.beginPath() + context.moveTo(x + radius, y) + context.lineTo(x + width - radius, y) + context.quadraticCurveTo(x + width, y, x + width, y + radius) + context.lineTo(x + width, y + height - radius) + context.quadraticCurveTo(x + width, y + height, x + width - radius, y + height) + context.lineTo(x + radius, y + height) + context.quadraticCurveTo(x, y + height, x, y + height - radius) + context.lineTo(x, y + radius) + context.quadraticCurveTo(x, y, x + radius, y) + context.closePath() +} + +function drawSigmaLabel( + context: CanvasRenderingContext2D, + data: SigmaLabelData, + textColor: string, + backgroundColor: string, + strongBackground: boolean, +) { + if (!data.label) return + + const fontSize = 13 + const paddingX = strongBackground ? 7 : 4 + const paddingY = strongBackground ? 4 : 2 + + context.font = `600 ${fontSize}px system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif` + context.textBaseline = 'middle' + + const textWidth = context.measureText(data.label).width + const boxWidth = textWidth + paddingX * 2 + const boxHeight = fontSize + paddingY * 2 + const canvasWidth = context.canvas.width + const canvasHeight = context.canvas.height + const margin = 6 + const preferredX = data.x + data.size + 5 - paddingX + const preferredY = data.y - fontSize / 2 - paddingY + const boxX = Math.min(Math.max(preferredX, margin), Math.max(margin, canvasWidth - boxWidth - margin)) + const boxY = Math.min(Math.max(preferredY, margin), Math.max(margin, canvasHeight - boxHeight - margin)) + const textX = boxX + paddingX + const textY = boxY + boxHeight / 2 + + context.save() + context.fillStyle = backgroundColor + drawRoundedRect(context, boxX, boxY, boxWidth, boxHeight, strongBackground ? 6 : 4) + context.fill() + + context.fillStyle = textColor + context.shadowColor = strongBackground ? 'transparent' : backgroundColor + context.shadowBlur = strongBackground ? 0 : 3 + context.fillText(data.label, textX, textY) + context.restore() +} + +function drawSigmaNode( + context: CanvasRenderingContext2D, + data: SigmaLabelData, + highlighted: boolean, +) { + context.save() + if (highlighted) { + context.fillStyle = 'rgba(245, 158, 11, 0.22)' + context.beginPath() + context.arc(data.x, data.y, data.size + 7, 0, Math.PI * 2) + context.fill() + } + + context.fillStyle = data.color + context.beginPath() + context.arc(data.x, data.y, data.size, 0, Math.PI * 2) + context.fill() + + if (highlighted) { + context.strokeStyle = '#f59e0b' + context.lineWidth = 3 + context.beginPath() + context.arc(data.x, data.y, data.size + 2, 0, Math.PI * 2) + context.stroke() + } + context.restore() +} + +function drawSigmaEdgeLabel( + context: CanvasRenderingContext2D, + edgeData: SigmaEdgeLabelData, + sourceData: SigmaEdgeLabelNodeData, + targetData: SigmaEdgeLabelNodeData, + hoveredEdgeId: string | null, + textColor: string, + backgroundColor: string, +) { + if (edgeData.key !== hoveredEdgeId) return + if (!edgeData.label) return + + const dx = targetData.x - sourceData.x + const dy = targetData.y - sourceData.y + const distance = Math.hypot(dx, dy) + if (distance < sourceData.size + targetData.size + 18) return + + const fontSize = 11 + const paddingX = 5 + const paddingY = 3 + const unitX = dx / distance + const unitY = dy / distance + const startX = sourceData.x + unitX * sourceData.size + const startY = sourceData.y + unitY * sourceData.size + const endX = targetData.x - unitX * targetData.size + const endY = targetData.y - unitY * targetData.size + const midX = (startX + endX) / 2 + const midY = (startY + endY) / 2 + const availableWidth = Math.max(12, Math.hypot(endX - startX, endY - startY) - 8) + + context.save() + context.font = `600 ${fontSize}px system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif` + context.textBaseline = 'middle' + + let label = edgeData.label + let textWidth = context.measureText(label).width + if (textWidth > availableWidth) { + while (label.length > 1 && context.measureText(`${label}...`).width > availableWidth) { + label = label.slice(0, -1) + } + label = `${label}...` + textWidth = context.measureText(label).width + } + + const boxWidth = textWidth + paddingX * 2 + const boxHeight = fontSize + paddingY * 2 + const boxX = midX - boxWidth / 2 + const boxY = midY - boxHeight / 2 + + context.fillStyle = backgroundColor + drawRoundedRect(context, boxX, boxY, boxWidth, boxHeight, 4) + context.fill() + context.fillStyle = textColor + context.fillText(label, boxX + paddingX, midY) + context.restore() +} + +function createInitialLayout(graphData: NormalizedGraphData) { + const nodeCount = Math.max(1, graphData.nodes.length) + const degrees: Record = {} + const positions: Record = {} + + graphData.nodes.forEach(node => { + degrees[node.id] = 0 + }) + + graphData.links.forEach(link => { + degrees[link.source] = (degrees[link.source] || 0) + 1 + degrees[link.target] = (degrees[link.target] || 0) + 1 + }) + + const rankedNodes = [...graphData.nodes].sort((a, b) => (degrees[b.id] || 0) - (degrees[a.id] || 0)) + const radius = Math.max(4, Math.sqrt(nodeCount) * 2.4) + + rankedNodes.forEach((node, index) => { + const angle = index * Math.PI * (3 - Math.sqrt(5)) + const ring = radius * Math.sqrt((index + 0.5) / nodeCount) + positions[node.id] = { + x: Math.cos(angle) * ring, + y: Math.sin(angle) * ring, + } + }) + + return { degrees, positions } +} + +function SigmaGraphView({ graphData, labelNodeIds, newlyExpandedNodeIds, darkMode, getNodeColor, getEdgeColor, onNodeClick }: SigmaGraphViewProps) { + const containerRef = useRef(null) + const rendererRef = useRef(null) + const hoveredEdgeRef = useRef(null) + + const graph = useMemo(() => { + const { degrees, positions } = createInitialLayout(graphData) + const maxDegree = Math.max(1, ...Object.values(degrees)) + const sigmaGraph = new SigmaGraph() + + graphData.nodes.forEach(node => { + const position = positions[node.id] || { x: 0, y: 0 } + const degree = degrees[node.id] || 0 + sigmaGraph.addNode(node.id, { + x: position.x, + y: position.y, + size: 4 + (degree / maxDegree) * 14, + color: isExpanderNode(node) ? '#f59e0b' : getNodeColor(node.label), + label: isExpanderNode(node) || labelNodeIds.has(node.id) ? node.name || node.id : '', + hoverLabel: node.name || node.id, + isNewlyExpanded: newlyExpandedNodeIds.has(node.id), + nodeType: node.label, + }) + }) + + const edgeCounts = new Map() + graphData.links.forEach((link, index) => { + if (!sigmaGraph.hasNode(link.source) || !sigmaGraph.hasNode(link.target)) return + const pairKey = `${link.source}->${link.target}` + const pairIndex = edgeCounts.get(pairKey) || 0 + edgeCounts.set(pairKey, pairIndex + 1) + const edgeKey = `${pairKey}#${pairIndex}-${index}` + const edgeLabel = link.label === 'more' ? '' : link.label || '' + sigmaGraph.addEdge(edgeKey, link.source, link.target, { + size: 1.8, + color: getEdgeColor(link.label || 'edge'), + label: edgeLabel, + forceLabel: Boolean(edgeLabel), + }) + }) + + return sigmaGraph + }, [graphData, labelNodeIds, newlyExpandedNodeIds, getNodeColor, getEdgeColor]) + + useEffect(() => { + const container = containerRef.current + if (!container) return + + const labelTextColor = '#111827' + const labelBackgroundColor = darkMode ? 'rgba(248, 250, 252, 0.94)' : 'rgba(255, 255, 255, 0.9)' + const expandedTextColor = '#111827' + const expandedBackgroundColor = 'rgba(254, 243, 199, 0.96)' + const hoverTextColor = '#111827' + const hoverBackgroundColor = darkMode ? 'rgba(255, 255, 255, 0.98)' : 'rgba(255, 255, 255, 0.98)' + const edgeLabelTextColor = '#111827' + const edgeLabelBackgroundColor = darkMode ? 'rgba(248, 250, 252, 0.92)' : 'rgba(255, 255, 255, 0.92)' + + rendererRef.current?.kill() + rendererRef.current = new Sigma(graph, container, { + allowInvalidContainer: true, + defaultEdgeType: 'arrow', + enableEdgeEvents: true, + labelColor: { color: labelTextColor }, + renderEdgeLabels: true, + edgeLabelColor: { color: edgeLabelTextColor }, + edgeLabelSize: 11, + edgeLabelWeight: '600', + labelRenderedSizeThreshold: 0, + minCameraRatio: 0.03, + maxCameraRatio: 12, + defaultDrawEdgeLabel: (context, edgeData, sourceData, targetData) => { + drawSigmaEdgeLabel( + context, + edgeData, + sourceData, + targetData, + hoveredEdgeRef.current, + edgeLabelTextColor, + edgeLabelBackgroundColor, + ) + }, + defaultDrawNodeLabel: (context, data) => { + drawSigmaLabel( + context, + data, + data.isNewlyExpanded ? expandedTextColor : labelTextColor, + data.isNewlyExpanded ? expandedBackgroundColor : labelBackgroundColor, + Boolean(data.isNewlyExpanded), + ) + }, + defaultDrawNodeHover: (context, data) => { + const labelData = { + ...data, + label: typeof data.hoverLabel === 'string' ? data.hoverLabel : data.label, + } + + drawSigmaNode(context, data, Boolean(data.isNewlyExpanded)) + + drawSigmaLabel(context, labelData, hoverTextColor, hoverBackgroundColor, true) + }, + }) + rendererRef.current.on('clickNode', ({ node }: { node: string }) => { + onNodeClick(node) + }) + rendererRef.current.on('enterEdge', ({ edge }: { edge: string }) => { + hoveredEdgeRef.current = edge + rendererRef.current?.refresh() + }) + rendererRef.current.on('leaveEdge', () => { + hoveredEdgeRef.current = null + rendererRef.current?.refresh() + }) + + rendererRef.current.refresh() + + return () => { + rendererRef.current?.kill() + rendererRef.current = null + } + }, [graph, darkMode, onNodeClick]) + + return
+} + function App() { const [databases, setDatabases] = useState([]) const [selectedId, setSelectedId] = useState(0) @@ -46,14 +533,26 @@ function App() { const [customQuery, setCustomQuery] = useState('') const [isCustomQuery, setIsCustomQuery] = useState(false) const [queryActivated, setQueryActivated] = useState(false) + const [renderer, setRenderer] = useState<'sigma' | 'force'>('sigma') + const [lastExpandedNodeIds, setLastExpandedNodeIds] = useState>(() => new Set()) // eslint-disable-next-line @typescript-eslint/no-explicit-any const graphRef = useRef(null) + const graphContainerRef = useRef(null) + const [graphSize, setGraphSize] = useState({ width: 1, height: 1 }) const customQueryRef = useRef('') const debounceTimerRef = useRef | null>(null) const fetchDatabases = () => { - invoke('get_databases') - .then(setDatabases) + Promise.all([ + invoke('get_databases'), + invoke('get_initial_database_id'), + ]) + .then(([items, initialId]) => { + setDatabases(items) + if (typeof initialId === 'number') { + setSelectedId(initialId) + } + }) .catch(err => setError(String(err))) } @@ -82,6 +581,32 @@ function App() { document.documentElement.setAttribute('data-theme', darkMode ? 'dark' : 'light') }, [darkMode]) + useEffect(() => { + const container = graphContainerRef.current + if (!container) return + + const updateGraphSize = () => { + const rect = container.getBoundingClientRect() + const width = Math.max(1, Math.floor(rect.width)) + const height = Math.max(1, Math.floor(rect.height)) + setGraphSize(current => ( + current.width === width && current.height === height + ? current + : { width, height } + )) + } + + updateGraphSize() + const observer = new ResizeObserver(updateGraphSize) + observer.observe(container) + window.addEventListener('resize', updateGraphSize) + + return () => { + observer.disconnect() + window.removeEventListener('resize', updateGraphSize) + } + }, []) + const fetchGraphData = useCallback(() => { if (databases.length === 0) { setGraphData({ nodes: [], links: [] }) @@ -95,6 +620,7 @@ function App() { invoke('execute_query', { id: selectedId, query }) .then(data => { setGraphData(data) + setLastExpandedNodeIds(new Set()) setLoading(false) setTimeout(() => { if (graphRef.current) { @@ -110,6 +636,7 @@ function App() { invoke('get_graph', { id: selectedId }) .then(data => { setGraphData(data) + setLastExpandedNodeIds(new Set()) setLoading(false) setTimeout(() => { if (graphRef.current) { @@ -156,20 +683,68 @@ function App() { const colorMapRef = useRef>({}) const edgeColorMapRef = useRef>({}) + const handleNodeClick = useCallback((nodeId: string) => { + const node = graphData.nodes.find(item => item.id === nodeId) + if (!node?.expansionKind || node.expansionKind !== 'node' || !node.expandNodeId) return + + setLoading(true) + setError(null) + invoke('expand_node', { + id: selectedId, + nodeId: node.expandNodeId, + visibleNodeIds: graphData.nodes.map(item => item.id), + offset: node.offset ?? 0, + }) + .then(data => { + const beforeNodeIds = realNodeIds(graphData.nodes) + const returnedNodeIds = realNodeIds(data.nodes) + const merged = mergeGraphData(graphData, data, node.id) + const highlightedNodeIds = realNodeIds(merged.nodes) + + beforeNodeIds.forEach(id => { + if (!returnedNodeIds.has(id)) highlightedNodeIds.delete(id) + }) + setGraphData(merged) + setLastExpandedNodeIds(highlightedNodeIds) + setLoading(false) + }) + .catch(err => { + setError(String(err)) + setLoading(false) + }) + }, [graphData, selectedId]) + + const normalizedGraphData = useMemo(() => normalizeGraphData(graphData), [graphData]) + const forceGraphData = useMemo>(() => ({ + nodes: normalizedGraphData.nodes.map(node => ({ ...node })), + links: normalizedGraphData.links.map(link => ({ ...link })), + }), [normalizedGraphData]) + const nodeDegree = useMemo(() => { const degrees: Record = {} - graphData.nodes.forEach(n => degrees[n.id] = 0) - graphData.links.forEach(link => { - const src = typeof link.source === 'object' ? (link.source as NodeObject).id : link.source - const dst = typeof link.target === 'object' ? (link.target as NodeObject).id : link.target - degrees[src as string] = (degrees[src as string] || 0) + 1 - degrees[dst as string] = (degrees[dst as string] || 0) + 1 + normalizedGraphData.nodes.forEach(n => degrees[n.id] = 0) + normalizedGraphData.links.forEach(link => { + degrees[link.source] = (degrees[link.source] || 0) + 1 + degrees[link.target] = (degrees[link.target] || 0) + 1 }) return degrees - }, [graphData]) + }, [normalizedGraphData]) const maxDegree = useMemo(() => Math.max(1, ...Object.values(nodeDegree)), [nodeDegree]) + const topLabelNodeIds = useMemo(() => { + return new Set( + [ + ...lastExpandedNodeIds, + ...[...normalizedGraphData.nodes] + .sort((a, b) => (nodeDegree[b.id] || 0) - (nodeDegree[a.id] || 0)) + .filter(node => !isExpanderNode(node)) + .slice(0, 5) + .map(node => node.id), + ] + ) + }, [lastExpandedNodeIds, normalizedGraphData.nodes, nodeDegree]) + const getNodeColor = useCallback((label: string) => { if (!colorMapRef.current[label]) { const colors = ['#4e79a7', '#f28e2c', '#e15759', '#76b7b2', '#59a14f', '#edc949', '#af7aa1', '#ff9da7', '#9c755f', '#bab0ab'] @@ -191,37 +766,34 @@ function App() { return 4 + (degree / maxDegree) * 12 }, [nodeDegree, maxDegree]) - const labelSizeThreshold = useMemo(() => { - const sizes = graphData.nodes.map(n => { - const degree = nodeDegree[n.id] || 0 - return 4 + (degree / maxDegree) * 12 - }) - sizes.sort((a, b) => b - a) - // Label the top 20% of nodes, but at least the top 5 - const cutoffIndex = Math.max(4, Math.floor(sizes.length * 0.2) - 1) - return sizes[Math.min(cutoffIndex, sizes.length - 1)] ?? 16 - }, [graphData.nodes, nodeDegree, maxDegree]) - // eslint-disable-next-line @typescript-eslint/no-explicit-any const paintNode = useCallback((node: any, ctx: CanvasRenderingContext2D) => { const size = getNodeSize(node) - const color = getNodeColor(node.label) + const color = isExpanderNode(node) ? '#f59e0b' : getNodeColor(node.label) + const highlighted = lastExpandedNodeIds.has(node.id) + + if (highlighted) { + ctx.fillStyle = 'rgba(245, 158, 11, 0.22)' + ctx.beginPath() + ctx.arc(node.x, node.y, size + 7, 0, 2 * Math.PI) + ctx.fill() + } ctx.fillStyle = color ctx.beginPath() ctx.arc(node.x, node.y, size, 0, 2 * Math.PI) ctx.fill() - ctx.strokeStyle = darkMode ? '#222' : '#ddd' - ctx.lineWidth = 1 + ctx.strokeStyle = highlighted ? '#f59e0b' : darkMode ? '#222' : '#ddd' + ctx.lineWidth = highlighted ? 3 : 1 ctx.stroke() - if (size >= labelSizeThreshold && node.name) { + if ((isExpanderNode(node) || topLabelNodeIds.has(node.id)) && node.name) { const fontSize = 3 ctx.font = `${fontSize}px Sans-Serif` ctx.textAlign = 'center' ctx.textBaseline = 'middle' - ctx.fillStyle = '#fff' + ctx.fillStyle = highlighted ? '#f59e0b' : '#fff' const maxWidth = size * 1.6 let label = node.name @@ -234,7 +806,7 @@ function App() { } ctx.fillText(label, node.x, node.y) } - }, [getNodeSize, getNodeColor, darkMode, labelSizeThreshold]) + }, [getNodeSize, getNodeColor, darkMode, topLabelNodeIds, lastExpandedNodeIds]) return (
@@ -259,7 +831,9 @@ function App() {
  • setSelectedId(db.id)} + onClick={() => { + setSelectedId(db.id) + }} title={db.relativePath} > {db.name} @@ -280,6 +854,20 @@ function App() {
  • +
    + + +
    -
    - {!loading && !error && graphData.nodes.length > 0 && ( +
    + {!loading && !error && graphData.nodes.length > 0 && renderer === 'sigma' && ( + + )} + {!loading && !error && graphData.nodes.length > 0 && renderer === 'force' && ( handleNodeClick(String(node.id))} nodeVal={(node) => { const s = getNodeSize(node); return s * s; }} nodeRelSize={1} nodeLabel={(node) => `${node.label}: ${node.name}`} diff --git a/src/vendor/sigma-runtime.d.ts b/src/vendor/sigma-runtime.d.ts new file mode 100644 index 0000000..c528130 --- /dev/null +++ b/src/vendor/sigma-runtime.d.ts @@ -0,0 +1,53 @@ +export interface SigmaSettings { + allowInvalidContainer?: boolean + defaultEdgeType?: string + enableEdgeEvents?: boolean + labelColor?: { color: string } | { attribute: string; color?: string } + renderEdgeLabels?: boolean + edgeLabelColor?: { color: string } | { attribute: string; color?: string } + edgeLabelSize?: number + edgeLabelWeight?: string + labelRenderedSizeThreshold?: number + minCameraRatio?: number + maxCameraRatio?: number + defaultDrawEdgeLabel?: ( + context: CanvasRenderingContext2D, + edgeData: SigmaEdgeLabelData, + sourceData: SigmaEdgeLabelNodeData, + targetData: SigmaEdgeLabelNodeData, + ) => void + defaultDrawNodeLabel?: (context: CanvasRenderingContext2D, data: SigmaLabelData) => void + defaultDrawNodeHover?: (context: CanvasRenderingContext2D, data: SigmaLabelData) => void +} + +export interface SigmaEdgeLabelData { + key?: string + label?: string + size: number + forceLabel?: boolean +} + +export interface SigmaEdgeLabelNodeData { + x: number + y: number + size: number +} + +export interface SigmaLabelData { + x: number + y: number + size: number + label?: string + hoverLabel?: string + color: string + isNewlyExpanded?: boolean +} + +export class Sigma { + constructor(graph: unknown, container: HTMLElement, settings?: SigmaSettings) + on(event: 'clickNode', callback: (payload: { node: string }) => void): void + on(event: 'enterEdge', callback: (payload: { edge: string }) => void): void + on(event: 'leaveEdge', callback: (payload: { edge: string }) => void): void + refresh(): void + kill(): void +} diff --git a/src/vendor/sigma-runtime.js b/src/vendor/sigma-runtime.js new file mode 100644 index 0000000..660eba0 --- /dev/null +++ b/src/vendor/sigma-runtime.js @@ -0,0 +1,3 @@ +import Sigma from 'sigma' + +export { Sigma }