commit acdbb8b34010c97f3db1b2ef67c8c2ca65bc1557 Author: Manoj K Date: Tue Jun 2 15:49:29 2026 +0530 Initial commit: LAS Stream Viewer (Quarkus backend + React log-plot UI) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1eb2f55 --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +# ---- Java / Maven ---- +target/ +*.class + +# ---- Node / Vite (frontend) ---- +frontend/node_modules/ +frontend/dist/ + +# ---- Built SPA bundled into resources (regenerated by build.ps1 / `npm run build`) ---- +src/main/resources/META-INF/resources/ + +# ---- logs / local working files ---- +*.log +.uptest/ + +# ---- IDE / OS ---- +.idea/ +.vscode/ +*.iml +.DS_Store +Thumbs.db diff --git a/README.md b/README.md new file mode 100644 index 0000000..7bbc3d4 --- /dev/null +++ b/README.md @@ -0,0 +1,86 @@ +# LAS Stream Viewer + +Stream very large (10 GB+) LAS well-log files **line by line** in the browser. A Java 21 / +Quarkus backend indexes and streams the file with a constant, tiny memory footprint; a React + +Vite UI renders millions of lines via virtualization and plays them back as a live SSE stream. + +Built for the Pason-style LAS 2.0 logs in `Desktop\LAS files` (up to ~12.5 GB, 426 curves, +~2.5 M rows). Nothing about the design assumes the file fits in memory. + +## Why it scales + +| Concern | Approach | +|---|---| +| Open a 12.5 GB file | **Open-in-place** (no copy) — or resumable chunked upload for remote files | +| Random access into the file | One-pass **sparse byte-offset index** (a checkpoint every 256 lines ≈ 80 KB for 2.5 M lines) | +| Indexing memory | Single streaming pass, 1 MiB buffer — independent of file size | +| Streaming memory | One `BufferedReader` advanced sequentially; lines pushed over **SSE** with backpressure | +| Browser memory | Virtualized list (only visible rows in the DOM) + a capped (20 K-line) LRU cache | +| Reading a line range | Seek to nearest checkpoint, skip ≤ 255 lines — effectively O(1) | + +## Layout + +``` +las-stream-viewer/ +├─ pom.xml Quarkus 3.34.3, Java 21 +├─ run.ps1 dev: Vite (:5173) + Quarkus (:8090) +├─ build.ps1 prod: bundle UI into the jar, serve on :8090 +├─ src/main/java/com/oiusa/las/ +│ ├─ model/ LasFile, Curve, HeaderSection +│ ├─ index/ LineIndex (sparse offsets), LineReader (random access) +│ ├─ service/ FileStore, IndexService (one-pass scan), LasHeaderParser, UploadService +│ └─ web/ File / Lines / Stream(SSE) / Search(SSE) / Upload resources +└─ frontend/ React + Vite + TS, @tanstack/react-virtual +``` + +## Run (dev) + +```powershell +cd C:\Users\Dell\Desktop\las-stream-viewer +.\run.ps1 +``` + +This installs UI deps on first run, starts Vite in a new window, and runs the Quarkus dev server. +Open **http://localhost:5173**. + +To run the pieces by hand: + +```powershell +# backend +$env:JAVA_HOME="C:\Program Files\Java\jdk-21.0.11" +$mvn = "C:\Users\Dell\.m2\wrapper\dists\apache-maven-3.9.9-bin\4nf9hui3q3djbarqar9g711ggc\apache-maven-3.9.9\bin\mvn.cmd" +& $mvn quarkus:dev +# frontend (separate window) +cd frontend; npm install; npm run dev +``` + +## Run (production, single port) + +```powershell +.\build.ps1 +$env:JAVA_HOME="C:\Program Files\Java\jdk-21.0.11" +java -jar target\quarkus-app\quarkus-run.jar +# open http://localhost:8090 +``` + +## Using it + +1. **Open on disk** — browse the server filesystem (constrained to `las.allowed-roots`, default your + home dir) and click a `.las` file. It opens in place; indexing starts immediately and the header + appears as soon as it's parsed. +2. **Upload** — drag a file in; it's streamed to the server in 16 MiB chunks. +3. Watch the **LAS header** (version/well/curve metadata) populate in the sidebar. +4. In the viewer: **▶ Stream** plays lines server-side over SSE (speed slider = lines/sec), scroll + freely through all lines (ranges fetched on demand), jump to **Top / Data / End** or any line, + and **Search** the whole file (streamed matches, click to jump). + +## Configuration (`src/main/resources/application.properties`) + +| Key | Default | Notes | +|---|---|---| +| `quarkus.http.port` | `8090` | API (and prod UI) port | +| `las.data-dir` | `${user.home}/.las-stream-viewer` | where uploads live | +| `las.allowed-roots` | `${user.home}` | local files may only be opened from under these roots | +| `las.index-stride` | `256` | lines per index checkpoint (smaller = faster seeks, larger index) | +| `las.upload-chunk-size` | `16777216` | upload chunk size hint (16 MiB) | +``` diff --git a/build.ps1 b/build.ps1 new file mode 100644 index 0000000..9145fa5 --- /dev/null +++ b/build.ps1 @@ -0,0 +1,20 @@ +# Production build: bundles the React UI into the Quarkus app, packages a runnable jar, +# then serves everything (UI + API) from a single process on :8090. +$ErrorActionPreference = "Stop" +$root = $PSScriptRoot +$env:JAVA_HOME = "C:\Program Files\Java\jdk-21.0.11" +$mvn = "C:\Users\Dell\.m2\wrapper\dists\apache-maven-3.9.9-bin\4nf9hui3q3djbarqar9g711ggc\apache-maven-3.9.9\bin\mvn.cmd" + +Write-Host "[1/3] Building React UI -> src/main/resources/META-INF/resources ..." -ForegroundColor Cyan +Push-Location "$root\frontend" +if (-not (Test-Path "node_modules")) { npm install } +npm run build +Pop-Location + +Write-Host "[2/3] Packaging Quarkus app ..." -ForegroundColor Cyan +Set-Location $root +& $mvn -q -DskipTests package + +Write-Host "[3/3] Done. Run it with:" -ForegroundColor Green +Write-Host ' $env:JAVA_HOME="C:\Program Files\Java\jdk-21.0.11"; java -jar target\quarkus-app\quarkus-run.jar' -ForegroundColor Yellow +Write-Host "Then open http://localhost:8090" -ForegroundColor Green diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..7673817 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + LAS Stream Viewer + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..cee8343 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1799 @@ +{ + "name": "las-stream-viewer-frontend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "las-stream-viewer-frontend", + "version": "1.0.0", + "dependencies": { + "@tanstack/react-virtual": "^3.10.8", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.3", + "typescript": "^5.6.3", + "vite": "^5.4.10" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.61.0.tgz", + "integrity": "sha512-dnxczajOqt0gesZlN5pGQ1s1imQVrsmCw5G2Ci4oM+0WvNz3pyRnlWrT7McoZIb8VlFwCawdmbWRmxRn7HI+VQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.61.0.tgz", + "integrity": "sha512-Bp3JpGP00Vu3f238ivRrjf7z3xSzVPXqCmaJYA9t2c+c8vKYvOzmXF7LkkeUalTEGd6cZcSWe+PFIP3Vy48fRg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.61.0.tgz", + "integrity": "sha512-zaYIpr670mUmmZ1tVzUFplbQbG7h3Gugx3L5FoqhsC2m/YnLlR1a7zVLmXNPy+iY1tFPEbNG+HHBXZGyId0G5w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.61.0.tgz", + "integrity": "sha512-+P49fvkv2dSoeevUW+lgZ/I2JHSsJCK1Lyjj7Cu6E4UHG4tS9XIefzIjo5qhgELjAclnen1rLzK2PMKJdo+Dyg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.61.0.tgz", + "integrity": "sha512-l3FAAOyKJXH2ea6KNFN+MMgC/rnE94YGLXs2ehYqDcCoHt1DpvgWX75BhUJxN38XojP7Ul+4H8PRn7EdyqSDrw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.61.0.tgz", + "integrity": "sha512-VokPN3TSctKj65cyCNPaUh4vMFA8awxOot/0sp+4J7ZlNRKQEhXhawqPwajoi8H5ZFt61i0ugZJuTKXBjGJ17Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.61.0.tgz", + "integrity": "sha512-DxH0P3wxm+Yzs/p3zrk9dw1rURu8p0Nv5+MRK/L7OtnLNg5rLZraSBFZ8iUXOd9f2BlhJyEpIZUH/emjq4UJ4g==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.61.0.tgz", + "integrity": "sha512-T6ZvMNe84kAz6TBWHC7hGAoEtzP1LWYw/AqayGWEF6uISt3Abk/st06LqRD9THd7Xz3NxzurUpzAuEAUbZf+nw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.61.0.tgz", + "integrity": "sha512-q/4hzvQkDs8b4jIBab1pnLiiM0ayTZsN2amBFPDzuyZxjEd4wDwx0UJFYM3cOZzSf5Kw8fnWSprJzIBMkcR44Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.61.0.tgz", + "integrity": "sha512-vvYWX3akdEAY6km+9wAqFDnk6pQsbJKVnj7xawcvs/+fdlYBGp+U+Qq/lLfpIxYIZvZLHMAKD9HLdacSx/r3dw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.61.0.tgz", + "integrity": "sha512-DePa5cqOxDP/Zp0VOXpeWaGew5iIv5DXp9NYbzkX5PFQyWVX9184WCTh3hvr/7lhXo8ZVlbFLkz8+o/q1dU6gA==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.61.0.tgz", + "integrity": "sha512-LV8aWMB8UChglMCEzs7RkN0GsH29RJaLLqwm9fCIjlqwxQTiWAqNcc7wjBkH31hV0PU/yVxGYvrYsgfea2qw6g==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.61.0.tgz", + "integrity": "sha512-QoNSnwQtaeNu5grdBbsL0tt1uyl5EnS8DA8Mr3nluMXbhdQNyhN+G4tBax7VCdxLKj8YJ0/4OO9Ho84jMnJtKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.61.0.tgz", + "integrity": "sha512-/zZp5MKapIIApE8trN8qLGNSiRN9TUoaUZ1cmVu4XnVdd5LQLOXTtyi+vtfUbNnT3iyjzpPqYeKXmvJ+gJGYWw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.61.0.tgz", + "integrity": "sha512-RbrzcD3aJ1k3UbtMRRBNwojdVVyXjuVAFTfn/xPa6EEl6GE9Sm/akPgFTb9aAC9pMKGJ6CtWxaGrqWcabH+ySg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.61.0.tgz", + "integrity": "sha512-ZF+onDsBso8PJf1XaG9lB+O9RnBpKGnY6OrzC4CSHrtC1jb6jWLTKK4bRqdoCXHd22gyr2hiYmEAm8Wns/BOCw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.61.0.tgz", + "integrity": "sha512-Atk0aSIk5Zx2Wuh9dgRQgLP0Koc8hOeYpbWryMXyk8G8/HmPkwPPkMqIIDhrXHHYqfUzSJA/I7IWSBv8xSmRBA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.61.0.tgz", + "integrity": "sha512-0uMOcf3eZ5K+K4cYHkdxShFMPlPXCOdfDFEFn9dNYAEEd2cVvmOfH7zFgRVoDgmtQ1m9k5q7qfrHzyMAubKYUA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.61.0.tgz", + "integrity": "sha512-mvFtE4A/t/7hRJ7X8Ozmu8FsIkAUat2nzl12pgU337BRmq87AQUJztwHz2Zv5/tjo9/C95E66CK03SI/ToEDJw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.61.0.tgz", + "integrity": "sha512-z9b9+aTxvt8n2rNltMPvyaUfB8NJ+CVyOrGK/MdIKHx7B+lXmZpm/XbRsU7Rpf3fRqJ2uS6mBJiJveCtq8LHDg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.61.0.tgz", + "integrity": "sha512-jXaXFqKMehsOc+g8R6oo33RRC6w07G9jDBxAE5eAKX7mOcCbZloYIPNhfG9Wl+P9O9IWHFO4OJgPi1Ml2qkt7w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.61.0.tgz", + "integrity": "sha512-OXNWVFocS2IA4+QplhTZZ2a+8hPZR7T8KuozsNmJKK8y7cp83StHvGksfHzPG3wczWTczyWHVQuqeiTUbjiyBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.61.0.tgz", + "integrity": "sha512-AlAbNtBO637LxSldqV43z0FfXoGfl2TW1DgAg/bs7aQswFbDewz2SJm3BUhiGfbOVtW571xbc9p+REdxhyN/Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.61.0.tgz", + "integrity": "sha512-QRSrQXyJ1M4tjNXdR0/G/IgV6lzfQQJYBjlWIEYkY2Xs86DRl/iEpQ4blMDjJxSl7n19eDKKXMg0AmuBVYy8pQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.61.0.tgz", + "integrity": "sha512-tkuFxhvKO/HlGd0VsINF6vHSYH8AF8W0TcNxKDK6JZmrehngFj78pToc8iemtnvwilDjs2G/qSzYFhe9U8q+fw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tanstack/react-virtual": { + "version": "3.14.1", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.1.tgz", + "integrity": "sha512-VJCw2DzKd16eMFoijSYpyAnwy0o8lzHTbYlGQOaSVHCWNptiZj4jlIsTE/LT7tzEHAMMRJC1ZIQA4OxAc99jLg==", + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.16.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.16.1", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.16.1.tgz", + "integrity": "sha512-br4gmJ3eI2IXth0fAAVWNdi/Iqb5ZDIUG9409Q17qnXegeHHc9vPEeMsSwpGf29GqayfmqFPtv9S4rxcZOub3Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.30", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.30.tgz", + "integrity": "sha512-3ek6mwJL5/VBewBcY4S66cqlCtK3qi4WIq37Z0m/NHw1hjhI7274Mx1qz/+ggSzyBCOEf7eHjBN6INjPAWYfYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.33", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.33.tgz", + "integrity": "sha512-bA6+tcSLpz2tIEdDXZPpPTIuxBcC4+w6SieaYyfigIa4h8GlFxbA17v22Vx3JUtuZQj9SgOsnbK+aTBzyDyEuw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001793", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", + "integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.364", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.364.tgz", + "integrity": "sha512-G/dYE3+AYhyHwzTwg8UbnXf7zqMERYh7l2jJ3QujhFsH8agSYwtnGAR2aZ7f0AakIKJXd5En/Hre4igIUrdlYw==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.47", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz", + "integrity": "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.61.0.tgz", + "integrity": "sha512-T9mWdbWfQtp0B5lv/HX+wrhYsmXRlcWnXXmJbXqKJhlRaoS6KMhq0gpyzW4UJfclcxrEdLnTgjT2NjruLONu0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.61.0", + "@rollup/rollup-android-arm64": "4.61.0", + "@rollup/rollup-darwin-arm64": "4.61.0", + "@rollup/rollup-darwin-x64": "4.61.0", + "@rollup/rollup-freebsd-arm64": "4.61.0", + "@rollup/rollup-freebsd-x64": "4.61.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.61.0", + "@rollup/rollup-linux-arm-musleabihf": "4.61.0", + "@rollup/rollup-linux-arm64-gnu": "4.61.0", + "@rollup/rollup-linux-arm64-musl": "4.61.0", + "@rollup/rollup-linux-loong64-gnu": "4.61.0", + "@rollup/rollup-linux-loong64-musl": "4.61.0", + "@rollup/rollup-linux-ppc64-gnu": "4.61.0", + "@rollup/rollup-linux-ppc64-musl": "4.61.0", + "@rollup/rollup-linux-riscv64-gnu": "4.61.0", + "@rollup/rollup-linux-riscv64-musl": "4.61.0", + "@rollup/rollup-linux-s390x-gnu": "4.61.0", + "@rollup/rollup-linux-x64-gnu": "4.61.0", + "@rollup/rollup-linux-x64-musl": "4.61.0", + "@rollup/rollup-openbsd-x64": "4.61.0", + "@rollup/rollup-openharmony-arm64": "4.61.0", + "@rollup/rollup-win32-arm64-msvc": "4.61.0", + "@rollup/rollup-win32-ia32-msvc": "4.61.0", + "@rollup/rollup-win32-x64-gnu": "4.61.0", + "@rollup/rollup-win32-x64-msvc": "4.61.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.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", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..0e651e0 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,23 @@ +{ + "name": "las-stream-viewer-frontend", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "@tanstack/react-virtual": "^3.10.8", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.3", + "typescript": "^5.6.3", + "vite": "^5.4.10" + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..bc7071c --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,139 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { api, fmtBytes, fmtNum } from './api' +import type { AppConfig, FileMeta, FileSummary } from './types' +import IngestPanel from './components/IngestPanel' +import FileList from './components/FileList' +import HeaderPanel from './components/HeaderPanel' +import Viewer from './components/Viewer' +import LogPlot from './components/LogPlot' +import Crossplot from './components/Crossplot' +import Section from './components/Section' +import WellInfo from './components/WellInfo' +import ChannelList from './components/ChannelList' + +export default function App() { + const [config, setConfig] = useState(null) + const [files, setFiles] = useState([]) + const [selectedId, setSelectedId] = useState(null) + const [meta, setMeta] = useState(null) + const [error, setError] = useState(null) + const [mainTab, setMainTab] = useState<'plot' | 'cross' | 'raw'>('plot') + const [sidebarOpen, setSidebarOpen] = useState(true) + const errTimer = useRef(undefined) + + const showError = useCallback((e: unknown) => { + const msg = e instanceof Error ? e.message : String(e) + setError(msg) + window.clearTimeout(errTimer.current) + errTimer.current = window.setTimeout(() => setError(null), 5000) + }, []) + + const refresh = useCallback(async () => { + try { + setFiles(await api.listFiles()) + } catch (e) { /* keep last good list; transient */ } + }, []) + + // initial config + poll the file list so indexing progress / status stay live + useEffect(() => { + api.config().then(setConfig).catch(showError) + refresh() + const t = window.setInterval(refresh, 1500) + return () => window.clearInterval(t) + }, [refresh, showError]) + + const selected = files.find(f => f.id === selectedId) ?? null + + // load metadata once the header is parsed (and refresh if it flips ready) + const headerReady = selected?.headerReady + useEffect(() => { + if (!selectedId || !headerReady) { setMeta(null); return } + let alive = true + api.meta(selectedId).then(m => { if (alive) setMeta(m) }).catch(showError) + return () => { alive = false } + }, [selectedId, headerReady, showError]) + + const onOpened = useCallback((f: FileSummary) => { + setSelectedId(f.id) + refresh() + }, [refresh]) + + const onRemove = useCallback(async (id: string) => { + try { + await api.remove(id) + if (selectedId === id) { setSelectedId(null); setMeta(null) } + refresh() + } catch (e) { showError(e) } + }, [selectedId, refresh, showError]) + + const totalIndexed = files.reduce((a, f) => a + (f.availableLines || 0), 0) + + return ( +
+
+ +
LS
+
+

LAS Stream Viewer

+
+ large-file well-log line streaming +
+ {files.length} file{files.length === 1 ? '' : 's'} · {fmtNum(totalIndexed)} lines indexed +
+ +
+ 0} /> +
+ +
+ {meta && selected && ( + <> +
+ +
+
+ +
+
+ +
+ + )} +
+ +
+ {selected + ? <> +
+
+ + + +
+
+ {selected.name} +
+
+ {mainTab === 'plot' && } + {mainTab === 'cross' && } + {mainTab === 'raw' && } +
+ + :
+
+
𝍌
+
Open a LAS file to begin.
+
+ Upload a file or open one already on disk — handles 10 GB+ logs. +
+
+
} +
+ + {error &&
⚠ {error}
} +
+ ) +} diff --git a/frontend/src/api.ts b/frontend/src/api.ts new file mode 100644 index 0000000..d0054f3 --- /dev/null +++ b/frontend/src/api.ts @@ -0,0 +1,86 @@ +import type { + AppConfig, BrowseResponse, CrossData, CurveData, FileMeta, FileSummary, LinesResponse, RolesResponse, +} from './types' + +async function j(res: Response): Promise { + if (!res.ok) { + let msg = `HTTP ${res.status}` + try { + const body = await res.json() + if (body?.error) msg = body.error + } catch { /* ignore */ } + throw new Error(msg) + } + return res.json() as Promise +} + +export const api = { + config: () => fetch('/api/files/config').then(r => j(r)), + + listFiles: () => fetch('/api/files').then(r => j(r)), + + meta: (id: string) => fetch(`/api/files/${id}`).then(r => j(r)), + + browse: (dir?: string) => + fetch(`/api/files/browse${dir ? `?dir=${encodeURIComponent(dir)}` : ''}`).then(r => j(r)), + + openLocal: (path: string) => + fetch('/api/files/local', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path }), + }).then(r => j(r)), + + remove: (id: string) => fetch(`/api/files/${id}`, { method: 'DELETE' }), + + lines: (id: string, start: number, count: number) => + fetch(`/api/files/${id}/lines?start=${start}&count=${count}`).then(r => j(r)), + + roles: (id: string) => fetch(`/api/files/${id}/roles`).then(r => j(r)), + + curveData: (id: string, axis: 'time' | 'depth', curves: string[], from: number, to: number, width: number) => { + const p = new URLSearchParams({ axis, curves: curves.join(','), width: String(Math.round(width)) }) + if (Number.isFinite(from)) p.set('from', String(from)) + if (Number.isFinite(to)) p.set('to', String(to)) + return fetch(`/api/files/${id}/curve-data?${p}`).then(r => j(r)) + }, + + crossplot: (id: string, x: string, y: string, color: string, onBottom: boolean, max = 5000) => { + const p = new URLSearchParams({ x, y, color, onBottom: String(onBottom), max: String(max) }) + return fetch(`/api/files/${id}/crossplot?${p}`).then(r => j(r)) + }, + + // --- chunked upload --- + uploadInit: (name: string, size: number) => + fetch('/api/uploads/init', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, size }), + }).then(r => j<{ uploadId: string; received: number }>(r)), + + uploadStatus: (id: string) => + fetch(`/api/uploads/${id}`).then(r => j<{ uploadId: string; received: number; size: number }>(r)), + + uploadChunk: (id: string, offset: number, blob: Blob) => + fetch(`/api/uploads/${id}/chunk?offset=${offset}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/octet-stream' }, + body: blob, + }).then(r => j<{ received: number }>(r)), + + uploadComplete: (id: string) => + fetch(`/api/uploads/${id}/complete`, { method: 'POST' }).then(r => j(r)), +} + +export function fmtBytes(n: number): string { + if (n < 1024) return `${n} B` + const u = ['KB', 'MB', 'GB', 'TB'] + let v = n / 1024 + let i = 0 + while (v >= 1024 && i < u.length - 1) { v /= 1024; i++ } + return `${v.toFixed(v < 10 ? 2 : 1)} ${u[i]}` +} + +export function fmtNum(n: number): string { + return n.toLocaleString('en-US') +} diff --git a/frontend/src/components/ChannelList.tsx b/frontend/src/components/ChannelList.tsx new file mode 100644 index 0000000..cc3568b --- /dev/null +++ b/frontend/src/components/ChannelList.tsx @@ -0,0 +1,67 @@ +import { useEffect, useMemo, useState } from 'react' +import { api } from '../api' +import { GROUP_COLOR, GROUP_LABEL } from '../las' +import type { Curve, RoleInfo } from '../types' + +interface Props { fileId: string; curves: Curve[] } + +/** Searchable channel browser with drilling-role badges; replaces the raw 426-row curve dump. */ +export default function ChannelList({ fileId, curves }: Props) { + const [roleByMnem, setRoleByMnem] = useState>(new Map()) + const [q, setQ] = useState('') + const [drillingOnly, setDrillingOnly] = useState(false) + + useEffect(() => { + let alive = true + setRoleByMnem(new Map()) + api.roles(fileId).then(r => { + if (!alive) return + const m = new Map() + r.roles.forEach(role => m.set(role.mnemonic.toUpperCase(), role)) + setRoleByMnem(m) + }).catch(() => {}) + return () => { alive = false } + }, [fileId]) + + const filtered = useMemo(() => { + const needle = q.trim().toLowerCase() + return curves.filter(c => { + if (drillingOnly && !roleByMnem.has(c.mnemonic.toUpperCase())) return false + if (!needle) return true + return c.mnemonic.toLowerCase().includes(needle) || (c.description || '').toLowerCase().includes(needle) + }) + }, [curves, q, drillingOnly, roleByMnem]) + + const drillingCount = roleByMnem.size + + return ( +
+
+ setQ(e.target.value)} /> +
+ +
+ {filtered.map(c => { + const role = roleByMnem.get(c.mnemonic.toUpperCase()) + const color = role ? GROUP_COLOR[role.group] : undefined + return ( +
+ {c.mnemonic} + {c.unit} + {c.description} + {role && ( + + {GROUP_LABEL[role.group]} + + )} +
+ ) + })} + {filtered.length === 0 &&
no matches
} +
+
+ ) +} diff --git a/frontend/src/components/Crossplot.tsx b/frontend/src/components/Crossplot.tsx new file mode 100644 index 0000000..d7a69a0 --- /dev/null +++ b/frontend/src/components/Crossplot.tsx @@ -0,0 +1,293 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { api, fmtNum } from '../api' +import type { CrossData, FileSummary, RoleInfo, RolesResponse } from '../types' + +interface Props { file: FileSummary; onError: (e: unknown) => void } + +const M = { l: 60, r: 84, t: 14, b: 44 } // canvas margins + +export default function Crossplot({ file, onError }: Props) { + const [roles, setRoles] = useState(null) + const roleMap = useMemo(() => { + const m = new Map() + roles?.roles.forEach(r => m.set(r.key, r)) + return m + }, [roles]) + const dataRoles = useMemo(() => (roles?.roles ?? []).filter(r => r.group !== 'index'), [roles]) + const hasOnBottom = useMemo(() => (roles?.roles ?? []).some(r => r.key === 'onBottom'), [roles]) + + const [x, setX] = useState('wob') + const [y, setY] = useState('rop') + const [color, setColor] = useState('depth') + const [onBottom, setOnBottom] = useState(false) + const [physical, setPhysical] = useState(false) + const [data, setData] = useState(null) + const [hover, setHover] = useState<{ px: number; py: number; i: number } | null>(null) + + const containerRef = useRef(null) + const canvasRef = useRef(null) + const sizeRef = useRef({ w: 800, h: 600 }) + + /* roles + sensible defaults */ + useEffect(() => { + let alive = true + setRoles(null); setData(null); setHover(null) + api.roles(file.id).then(r => { + if (!alive) return + setRoles(r) + const keys = new Set(r.roles.map(k => k.key)) + setX(keys.has('wob') ? 'wob' : (r.roles.find(k => k.group === 'mechanics')?.key ?? r.roles[0]?.key ?? 'wob')) + setY(keys.has('rop') ? 'rop' : (r.roles.find(k => k.group === 'mechanics' && k.key !== 'wob')?.key ?? 'rop')) + setColor(r.hasDepthAxis ? 'depth' : r.hasTimeAxis ? 'time' : (r.roles[0]?.key ?? 'depth')) + setOnBottom(r.roles.some(k => k.key === 'onBottom')) + }).catch(onError) + return () => { alive = false } + }, [file.id, onError]) + + useEffect(() => { + if (!roles || roles.ready) return + const t = window.setInterval(() => { api.roles(file.id).then(r => { if (r.ready) setRoles(r) }).catch(() => {}) }, 1500) + return () => window.clearInterval(t) + }, [roles, file.id]) + + /* fetch crossplot */ + useEffect(() => { + if (!roles?.ready) return + let alive = true + const t = window.setTimeout(() => { + api.crossplot(file.id, x, y, color, onBottom, 6000) + .then(d => { if (alive) { setData(d); setHover(null) } }) + .catch(onError) + }, 120) + return () => { alive = false; window.clearTimeout(t) } + }, [roles, file.id, x, y, color, onBottom, onError]) + + /* sizing */ + useEffect(() => { + const el = containerRef.current + if (!el) return + const ro = new ResizeObserver(() => { + sizeRef.current = { w: el.clientWidth, h: el.clientHeight } + const c = canvasRef.current + if (c) { + const dpr = window.devicePixelRatio || 1 + c.width = Math.round(el.clientWidth * dpr); c.height = Math.round(el.clientHeight * dpr) + c.style.width = el.clientWidth + 'px'; c.style.height = el.clientHeight + 'px' + } + draw() + }) + ro.observe(el) + return () => ro.disconnect() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + const scaleX = useMemo(() => pickScale(physical, roleMap.get(x), data?.xRange), [physical, roleMap, x, data]) + const scaleY = useMemo(() => pickScale(physical, roleMap.get(y), data?.yRange), [physical, roleMap, y, data]) + + const draw = useCallback(() => { + const c = canvasRef.current + if (!c) return + const ctx = c.getContext('2d')! + const dpr = window.devicePixelRatio || 1 + ctx.setTransform(dpr, 0, 0, dpr, 0, 0) + const { w, h } = sizeRef.current + ctx.clearRect(0, 0, w, h); ctx.fillStyle = '#0a0e13'; ctx.fillRect(0, 0, w, h) + const pw = w - M.l - M.r, ph = h - M.t - M.b + if (pw <= 0 || ph <= 0) return + + const [x0, x1] = scaleX, [y0, y1] = scaleY + const px = (v: number) => M.l + ((v - x0) / (x1 - x0)) * pw + const py = (v: number) => M.t + ph - ((v - y0) / (y1 - y0)) * ph + + // grid + ticks + ctx.font = '10px ui-monospace, monospace'; ctx.fillStyle = '#5c6b7c' + ctx.strokeStyle = '#162028' + for (const tk of niceTicks(x0, x1, 8)) { + const xx = px(tk); if (xx < M.l || xx > w - M.r) continue + ctx.beginPath(); ctx.moveTo(xx, M.t); ctx.lineTo(xx, M.t + ph); ctx.stroke() + ctx.textAlign = 'center'; ctx.fillText(fmtTick(tk), xx, M.t + ph + 14) + } + for (const tk of niceTicks(y0, y1, 6)) { + const yy = py(tk); if (yy < M.t || yy > M.t + ph) continue + ctx.beginPath(); ctx.moveTo(M.l, yy); ctx.lineTo(M.l + pw, yy); ctx.stroke() + ctx.textAlign = 'right'; ctx.fillText(fmtTick(tk), M.l - 6, yy + 3) + } + + // points + const cr = data?.cRange ?? [0, 1] + if (data) { + for (let i = 0; i < data.x.length; i++) { + const vx = data.x[i], vy = data.y[i] + if (vx < x0 || vx > x1 || vy < y0 || vy > y1) continue + const cv = data.c[i] + ctx.fillStyle = cv == null ? '#6b7785' : turbo((cv - cr[0]) / (cr[1] - cr[0])) + ctx.globalAlpha = 0.6 + ctx.beginPath(); ctx.arc(px(vx), py(vy), 2.3, 0, 6.2832); ctx.fill() + } + ctx.globalAlpha = 1 + } + + // hovered point + if (hover && data) { + ctx.strokeStyle = '#fff'; ctx.lineWidth = 1.5 + ctx.beginPath(); ctx.arc(hover.px, hover.py, 4.5, 0, 6.2832); ctx.stroke() + } + + // axis frame + labels + ctx.strokeStyle = '#25323f'; ctx.lineWidth = 1 + ctx.strokeRect(M.l, M.t, pw, ph) + ctx.fillStyle = '#d7e0ea'; ctx.font = '12px Inter, sans-serif' + ctx.textAlign = 'center' + ctx.fillText(axisLabel(roleMap.get(x), x), M.l + pw / 2, h - 6) + ctx.save(); ctx.translate(14, M.t + ph / 2); ctx.rotate(-Math.PI / 2) + ctx.fillText(axisLabel(roleMap.get(y), y), 0, 0); ctx.restore() + + // colorbar + const cbx = w - M.r + 22, cbw = 12, cbh = ph + const grad = ctx.createLinearGradient(0, M.t, 0, M.t + cbh) + for (let s = 0; s <= 10; s++) grad.addColorStop(s / 10, turbo(1 - s / 10)) + ctx.fillStyle = grad; ctx.fillRect(cbx, M.t, cbw, cbh) + ctx.strokeStyle = '#25323f'; ctx.strokeRect(cbx, M.t, cbw, cbh) + ctx.fillStyle = '#8696a8'; ctx.font = '10px ui-monospace, monospace'; ctx.textAlign = 'left' + ctx.fillText(fmtColor(cr[1], color), cbx + cbw + 4, M.t + 8) + ctx.fillText(fmtColor(cr[0], color), cbx + cbw + 4, M.t + cbh) + ctx.save(); ctx.translate(w - 8, M.t + cbh / 2); ctx.rotate(-Math.PI / 2) + ctx.fillStyle = '#5c6b7c'; ctx.textAlign = 'center' + ctx.fillText(colorLabel(roleMap.get(color), color), 0, 0); ctx.restore() + }, [scaleX, scaleY, data, hover, roleMap, x, y, color]) + + useEffect(() => { draw() }, [draw]) + + /* hover: nearest point in screen space */ + const onMove = (e: React.MouseEvent) => { + if (!data) return + const rect = canvasRef.current!.getBoundingClientRect() + const mx = e.clientX - rect.left, my = e.clientY - rect.top + const { w, h } = sizeRef.current + const pw = w - M.l - M.r, ph = h - M.t - M.b + const [x0, x1] = scaleX, [y0, y1] = scaleY + const px = (v: number) => M.l + ((v - x0) / (x1 - x0)) * pw + const py = (v: number) => M.t + ph - ((v - y0) / (y1 - y0)) * ph + let best = -1, bd = 100 + for (let i = 0; i < data.x.length; i++) { + const sx = px(data.x[i]), sy = py(data.y[i]) + const d = (sx - mx) ** 2 + (sy - my) ** 2 + if (d < bd) { bd = d; best = i } + } + if (best >= 0) setHover({ px: px(data.x[best]), py: py(data.y[best]), i: best }) + else setHover(null) + } + + if (roles && !roles.ready) { + return
Building curve overview…
+ } + if (roles && dataRoles.length < 2) { + return
Not enough numeric channels for a crossplot.
+ } + + const rx = roleMap.get(x), ry = roleMap.get(y) + + return ( +
+
+ + + + + +
+ + {data ? `${fmtNum(data.returned)} pts${data.total > data.returned ? ` / ${fmtNum(data.total)}` : ''}${data.onBottomFiltered ? ' · on-btm' : ''}` : '…'} + +
+
+ setHover(null)} style={{ cursor: 'crosshair' }} /> + {hover && data && ( +
+
{rx?.mnemonic ?? x}{trim(data.x[hover.i])} {rx?.unit}
+
{ry?.mnemonic ?? y}{trim(data.y[hover.i])} {ry?.unit}
+ {data.c[hover.i] != null &&
{color === 'time' ? 'time' : color === 'depth' ? 'depth' : (roleMap.get(color)?.mnemonic ?? color)}{fmtColor(data.c[hover.i]!, color)}
} +
+ )} +
+
+ ) +} + +function Sel({ label, value, onChange, options, extra = [] }: + { label: string; value: string; onChange: (v: string) => void; options: RoleInfo[]; extra?: [string, string][] }) { + return ( + + ) +} + +/* ---------- helpers ---------- */ +function pickScale(physical: boolean, role: RoleInfo | undefined, dataRange?: [number, number]): [number, number] { + if (physical && role && role.defMax > role.defMin) return pad(role.defMin, role.defMax) + if (dataRange && dataRange[1] > dataRange[0]) return pad(dataRange[0], dataRange[1]) + if (role && role.defMax > role.defMin) return pad(role.defMin, role.defMax) + return [0, 1] +} +function pad(a: number, b: number): [number, number] { const p = (b - a) * 0.04; return [a - p, b + p] } + +function axisLabel(role: RoleInfo | undefined, key: string): string { + if (!role) return key + return `${role.label}${role.unit ? ` (${role.unit})` : ''}` +} +function colorLabel(role: RoleInfo | undefined, key: string): string { + if (key === 'depth') return 'Depth (ft)' + if (key === 'time') return 'Time' + return role ? `${role.label}${role.unit ? ` (${role.unit})` : ''}` : key +} +function fmtColor(v: number, key: string): string { + if (key === 'time') { const d = new Date(v * 1000); return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}` } + return fmtTick(v) +} +function fmtTick(v: number): string { + const a = Math.abs(v) + if (a >= 1000) return fmtNum(Math.round(v)) + if (a >= 10) return (Math.round(v * 10) / 10).toString() + return (Math.round(v * 100) / 100).toString() +} +function trim(v: number): string { return Math.abs(v) >= 1000 ? fmtNum(Math.round(v)) : (Math.round(v * 100) / 100).toString() } + +function niceTicks(from: number, to: number, count: number): number[] { + const span = to - from + if (span <= 0) return [] + const raw = span / count + const mag = Math.pow(10, Math.floor(Math.log10(raw))) + const norm = raw / mag + const step = (norm < 1.5 ? 1 : norm < 3 ? 2 : norm < 7 ? 5 : 10) * mag + const start = Math.ceil(from / step) * step + const out: number[] = [] + for (let v = start; v <= to; v += step) out.push(v) + return out +} + +/** Turbo-ish colormap, t in [0,1]. */ +function turbo(t: number): string { + t = Math.max(0, Math.min(1, t)) + const stops: [number, number, number][] = [ + [48, 18, 59], [62, 73, 213], [38, 188, 224], [62, 214, 130], + [180, 222, 44], [252, 184, 41], [233, 90, 35], [122, 4, 3], + ] + const s = t * (stops.length - 1) + const i = Math.floor(s), f = s - i + const a = stops[i], b = stops[Math.min(stops.length - 1, i + 1)] + const r = Math.round(a[0] + (b[0] - a[0]) * f) + const g = Math.round(a[1] + (b[1] - a[1]) * f) + const bl = Math.round(a[2] + (b[2] - a[2]) * f) + return `rgb(${r},${g},${bl})` +} diff --git a/frontend/src/components/FileList.tsx b/frontend/src/components/FileList.tsx new file mode 100644 index 0000000..6a382a9 --- /dev/null +++ b/frontend/src/components/FileList.tsx @@ -0,0 +1,45 @@ +import { fmtBytes, fmtNum } from '../api' +import type { FileSummary } from '../types' + +interface Props { + files: FileSummary[] + selectedId: string | null + onSelect: (id: string) => void + onRemove: (id: string) => void +} + +export default function FileList({ files, selectedId, onSelect, onRemove }: Props) { + return ( + <> + {files.length === 0 &&
No files yet.
} + {files.map(f => { + const pct = f.sizeBytes > 0 ? Math.min(100, (f.indexedBytes / f.sizeBytes) * 100) : 0 + return ( +
onSelect(f.id)} + > +
{ e.stopPropagation(); onRemove(f.id) }}>×
+
{f.name}
+
+ {f.status} + {fmtBytes(f.sizeBytes)} + {f.availableLines > 0 && {fmtNum(f.availableLines)} lines} + {f.curveCount > 0 && {f.curveCount} curves} + {!f.uploaded && in-place} +
+ {f.status === 'INDEXING' && ( +
+ +
+ )} + {f.status === 'ERROR' && f.error && ( +
{f.error}
+ )} +
+ ) + })} + + ) +} diff --git a/frontend/src/components/HeaderPanel.tsx b/frontend/src/components/HeaderPanel.tsx new file mode 100644 index 0000000..79ee814 --- /dev/null +++ b/frontend/src/components/HeaderPanel.tsx @@ -0,0 +1,59 @@ +import { useState } from 'react' +import { parseHeaderLine } from '../las' +import type { FileMeta, HeaderSection } from '../types' + +/** + * LAS header sections rendered for readability: each metadata line is parsed into + * mnemonic / unit / value / description and laid out as key-value rows (with a toggle back to the + * exact raw text for QC). + */ +export default function HeaderPanel({ meta }: { meta: FileMeta }) { + const [openSec, setOpenSec] = useState(null) + const [raw, setRaw] = useState(false) + + return ( +
+
+
+ + +
+
+ {meta.sections.map(s => { + const open = openSec === s.name + return ( +
{ if ((e.target as HTMLDetailsElement).open) setOpenSec(s.name) }}> + {s.name} ({s.lines.filter(l => l.trim()).length}) + {open && (raw ?
{s.lines.join('\n')}
: )} +
+ ) + })} +
+ ) +} + +function StructuredSection({ section }: { section: HeaderSection }) { + const rows = section.lines.filter(l => l.trim().length > 0) + return ( +
+ {rows.map((line, i) => { + const t = line.trimStart() + // skip the comment column-header / separator banners (#MNEM.UNIT … / #----) + if (t.startsWith('#')) return null + const p = parseHeaderLine(line) + if (!p) return
{line.trim()}
+ return ( +
+
+ {p.mnem} + {p.unit && ·{p.unit}} + {p.value || '—'} +
+ {p.desc &&
{p.desc}
} +
+ ) + })} +
+ ) +} diff --git a/frontend/src/components/IngestPanel.tsx b/frontend/src/components/IngestPanel.tsx new file mode 100644 index 0000000..eacc27b --- /dev/null +++ b/frontend/src/components/IngestPanel.tsx @@ -0,0 +1,166 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { api, fmtBytes } from '../api' +import type { AppConfig, BrowseResponse, FileSummary } from '../types' + +interface Props { + config: AppConfig | null + onOpened: (f: FileSummary) => void + onError: (e: unknown) => void + collapsedDefault?: boolean +} + +type Mode = 'upload' | 'disk' + +export default function IngestPanel({ config, onOpened, onError, collapsedDefault = false }: Props) { + const [mode, setMode] = useState('disk') + const [open, setOpen] = useState(!collapsedDefault) + const handleOpened = (f: FileSummary) => { onOpened(f); setOpen(false) } + return ( +
+
setOpen(o => !o)}> + + {collapsedDefault ? 'Open another file' : 'Open a LAS file'} +
+ {open && ( +
+
+ + +
+ {mode === 'upload' + ? + : } +
+ )} +
+ ) +} + +/* ---------------- chunked uploader ---------------- */ +function Uploader({ config, onOpened, onError }: Props) { + const [over, setOver] = useState(false) + const [busy, setBusy] = useState<{ name: string; sent: number; total: number } | null>(null) + const inputRef = useRef(null) + + const upload = useCallback(async (file: File) => { + const chunkSize = config?.uploadChunkSize ?? 16 * 1024 * 1024 + setBusy({ name: file.name, sent: 0, total: file.size }) + try { + const { uploadId } = await api.uploadInit(file.name, file.size) + let offset = 0 + while (offset < file.size) { + const end = Math.min(offset + chunkSize, file.size) + await api.uploadChunk(uploadId, offset, file.slice(offset, end)) + offset = end + setBusy({ name: file.name, sent: offset, total: file.size }) + } + const summary = await api.uploadComplete(uploadId) + onOpened(summary) + } catch (e) { + onError(e) + } finally { + setBusy(null) + if (inputRef.current) inputRef.current.value = '' + } + }, [config, onOpened, onError]) + + const pct = busy ? (busy.total > 0 ? (busy.sent / busy.total) * 100 : 0) : 0 + + return ( + <> +
!busy && inputRef.current?.click()} + onDragOver={(e) => { e.preventDefault(); setOver(true) }} + onDragLeave={() => setOver(false)} + onDrop={(e) => { + e.preventDefault(); setOver(false) + if (busy) return + const f = e.dataTransfer.files?.[0] + if (f) upload(f) + }} + > + {busy ? ( + <> +
{busy.name}
+
{fmtBytes(busy.sent)} / {fmtBytes(busy.total)} · {pct.toFixed(1)}%
+
+ + ) : ( + <> +
⤓ Drop a LAS file here
+
or click to choose · streamed in 16 MB chunks (any size)
+ + )} +
+ { const f = e.target.files?.[0]; if (f) upload(f) }} + /> +
+ Tip: for files already on this machine, use Open on disk — no copy, opens instantly. +
+ + ) +} + +/* ---------------- server-side disk browser ---------------- */ +function DiskBrowser({ config, onOpened, onError }: Props) { + const [data, setData] = useState(null) + const [loading, setLoading] = useState(false) + const [opening, setOpening] = useState(null) + + const go = useCallback(async (dir?: string) => { + setLoading(true) + try { + setData(await api.browse(dir)) + } catch (e) { + onError(e) + } finally { + setLoading(false) + } + }, [onError]) + + useEffect(() => { go(config?.homeDir) }, [config, go]) + + const open = useCallback(async (path: string) => { + setOpening(path) + try { + onOpened(await api.openLocal(path)) + } catch (e) { + onError(e) + } finally { + setOpening(null) + } + }, [onOpened, onError]) + + return ( +
+
{data?.dir ?? '…'}
+
+ {loading &&
reading…
} + {!loading && data?.parent && ( +
go(data.parent!)}> + .. +
+ )} + {!loading && data?.entries.map(e => ( +
e.dir ? go(e.path) : open(e.path)} + title={e.path} + > + {e.dir ? '📁' : (e.looksLikeLas ? '📈' : '📄')} + {e.name} + {!e.dir && {opening === e.path ? '…' : fmtBytes(e.sizeBytes)}} +
+ ))} + {!loading && data && data.entries.length === 0 && ( +
(empty)
+ )} +
+
+ ) +} diff --git a/frontend/src/components/LogPlot.tsx b/frontend/src/components/LogPlot.tsx new file mode 100644 index 0000000..01b2851 --- /dev/null +++ b/frontend/src/components/LogPlot.tsx @@ -0,0 +1,629 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { api, fmtNum } from '../api' +import type { CurveData, FileSummary, RoleInfo, RolesResponse } from '../types' + +const PALETTE = ['#4aa3ff', '#36c6a0', '#e0a23c', '#e9645f', '#b48ead', '#8fbcbb', '#a3be8c', '#d08770'] +const GUTTER = 72 + +interface Curve { key: string; color: string } +interface Track { id: string; curves: Curve[] } + +interface Props { file: FileSummary; onError: (e: unknown) => void } + +export default function LogPlot({ file, onError }: Props) { + const [roles, setRoles] = useState(null) + const roleMap = useMemo(() => { + const m = new Map() + roles?.roles.forEach(r => m.set(r.key, r)) + return m + }, [roles]) + + const [axis, setAxis] = useState<'depth' | 'time'>('depth') + const [tracks, setTracks] = useState([]) + const [autoscale, setAutoscale] = useState(false) + const [playing, setPlaying] = useState(false) + const [speed, setSpeed] = useState(600) // index units / sec + const [pickerOpen, setPickerOpen] = useState(false) + const [readout, setReadout] = useState<{ x: number; y: number; index: number; rows: { label: string; color: string; v: number | null; unit: string }[] } | null>(null) + const [detail, setDetail] = useState(false) + const [showStats, setShowStats] = useState(false) + const [stats, setStats] = useState<{ key: string; mnemonic: string; unit: string; min: number | null; avg: number | null; max: number | null }[]>([]) + + const containerRef = useRef(null) + const canvasRef = useRef(null) + const viewRef = useRef<{ from: number; to: number }>({ from: 0, to: 1 }) + const dataRef = useRef(null) + const sizeRef = useRef<{ w: number; h: number }>({ w: 800, h: 600 }) + const drawPending = useRef(false) + const fetchState = useRef<{ last: number; timer: number; seq: number }>({ last: 0, timer: 0, seq: 0 }) + const trackRef = useRef(null) + const thumbRef = useRef(null) + const windowRangeRef = useRef>(new Map()) + + const extent = useMemo(() => { + const e = axis === 'depth' ? roles?.depthExtent : roles?.timeExtent + return e && e.max > e.min ? { min: e.min, max: e.max } : null + }, [roles, axis]) + + /* ---------- load roles for this file ---------- */ + useEffect(() => { + let alive = true + setRoles(null); setTracks([]); setReadout(null); setPlaying(false) + api.roles(file.id).then(r => { + if (!alive) return + setRoles(r) + setAxis(r.hasDepthAxis ? 'depth' : 'time') + // build default tracks from resolved roles + const m = new Map(r.roles.map(x => [x.key, x])) + let ci = 0 + const ts: Track[] = [] + for (const group of r.defaultTracks) { + const curves = group.filter(k => m.has(k)).map(k => ({ key: k, color: PALETTE[ci++ % PALETTE.length] })) + if (curves.length) ts.push({ id: 't' + ts.length, curves }) + } + setTracks(ts) + }).catch(onError) + return () => { alive = false } + }, [file.id, onError]) + + // poll roles until ready (pyramid finishes building) while indexing + useEffect(() => { + if (!roles || roles.ready) return + const t = window.setInterval(() => { + api.roles(file.id).then(r => { if (r.ready) setRoles(r) }).catch(() => {}) + }, 1500) + return () => window.clearInterval(t) + }, [roles, file.id]) + + /* ---------- initialize view when extent becomes available ---------- */ + useEffect(() => { + if (extent) { viewRef.current = { from: extent.min, to: extent.max }; scheduleFetch(true); requestDraw() } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [extent]) + + /* ---------- size / resize ---------- */ + useEffect(() => { + const el = containerRef.current + if (!el) return + const ro = new ResizeObserver(() => { + sizeRef.current = { w: el.clientWidth, h: el.clientHeight } + const c = canvasRef.current + if (c) { + const dpr = window.devicePixelRatio || 1 + c.width = Math.round(sizeRef.current.w * dpr) + c.height = Math.round(sizeRef.current.h * dpr) + c.style.width = sizeRef.current.w + 'px' + c.style.height = sizeRef.current.h + 'px' + } + scheduleFetch(true); requestDraw() + }) + ro.observe(el) + return () => ro.disconnect() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + /* ---------- data fetch (throttled so replay doesn't spam) ---------- */ + const keysSig = useMemo(() => tracks.flatMap(t => t.curves.map(c => c.key)).join(','), [tracks]) + + const doFetch = useCallback(async () => { + if (!extent) return + fetchState.current.last = performance.now() + const keys = Array.from(new Set(tracks.flatMap(t => t.curves.map(c => c.key)))) + if (keys.length === 0) { dataRef.current = null; requestDraw(); return } + const v = viewRef.current + const span = v.to - v.from + const pad = span * 0.3 + const from = Math.max(extent.min, v.from - pad) + const to = Math.min(extent.max, v.to + pad) + const h = sizeRef.current.h + const width = Math.min(4000, Math.max(64, Math.round(h * 1.6))) + const seq = ++fetchState.current.seq + try { + const cd = await api.curveData(file.id, axis, keys, from, to, width) + if (seq !== fetchState.current.seq) return // stale + dataRef.current = cd + setDetail(cd.detail) + // window statistics over the *visible* range (min/avg/max per channel) + per-curve fit range + const vw = viewRef.current + const wr = new Map() + setStats(cd.curves.map(s => { + let mn = Infinity, mx = -Infinity, sum = 0, cnt = 0 + for (let j = 0; j < cd.n; j++) { + const p = cd.pos[j]; if (p == null || p < vw.from || p > vw.to) continue + const a = s.min[j], b = s.max[j]; if (a == null || b == null) continue + if (a < mn) mn = a; if (b > mx) mx = b; sum += (a + b) / 2; cnt++ + } + if (cnt) wr.set(s.key, [mn, mx]) + return { key: s.key, mnemonic: s.mnemonic, unit: s.unit, min: cnt ? mn : null, avg: cnt ? sum / cnt : null, max: cnt ? mx : null } + })) + windowRangeRef.current = wr + requestDraw() + } catch (e) { onError(e) } + }, [extent, tracks, axis, file.id, onError]) + + const scheduleFetch = useCallback((force = false) => { + const interval = 150 + const now = performance.now() + const since = now - fetchState.current.last + window.clearTimeout(fetchState.current.timer) + if (force || since >= interval) doFetch() + else fetchState.current.timer = window.setTimeout(doFetch, interval - since) + }, [doFetch]) + + useEffect(() => { scheduleFetch(true) }, [keysSig, axis, scheduleFetch]) + + /* ---------- drawing ---------- */ + const v2y = (v: number, h: number) => { + const { from, to } = viewRef.current + return ((v - from) / (to - from)) * h + } + const y2v = (y: number, h: number) => { + const { from, to } = viewRef.current + return from + (y / h) * (to - from) + } + + const scaleFor = useCallback((key: string): [number, number] => { + // "Auto-fit": scale each track to the data actually visible in the current window. + if (autoscale) { + const wr = windowRangeRef.current.get(key) + if (wr) { + if (wr[1] > wr[0]) { const p = (wr[1] - wr[0]) * 0.06; return [wr[0] - p, wr[1] + p] } + return [wr[0] - 1, wr[1] + 1] + } + } + const r = roleMap.get(key) + if (!r) return [0, 1] + if (r.defMax > r.defMin) return [r.defMin, r.defMax] // fixed physical scale + if (r.dataMin != null && r.dataMax != null && r.dataMax > r.dataMin) return [r.dataMin, r.dataMax] + return [0, 1] + }, [roleMap, autoscale]) + + const draw = useCallback(() => { + drawPending.current = false + const c = canvasRef.current + if (!c || !extent) return + const ctx = c.getContext('2d')! + const dpr = window.devicePixelRatio || 1 + ctx.setTransform(dpr, 0, 0, dpr, 0, 0) + const { w, h } = sizeRef.current + ctx.clearRect(0, 0, w, h) + ctx.fillStyle = '#0a0e13'; ctx.fillRect(0, 0, w, h) + + const nT = tracks.length + const plotW = w - GUTTER + const trackW = nT > 0 ? plotW / nT : plotW + const data = dataRef.current + const { from, to } = viewRef.current + + // index gridlines + gutter labels + const ticks = niceTicks(from, to, 8) + ctx.font = '10px ui-monospace, monospace' + ctx.textBaseline = 'middle' + for (const tk of ticks) { + const y = v2y(tk, h) + if (y < 0 || y > h) continue + ctx.strokeStyle = '#162028'; ctx.beginPath(); ctx.moveTo(GUTTER, y); ctx.lineTo(w, y); ctx.stroke() + ctx.fillStyle = '#5c6b7c'; ctx.textAlign = 'right' + ctx.fillText(fmtIndex(tk, axis), GUTTER - 6, y) + } + // gutter divider + ctx.strokeStyle = '#25323f'; ctx.beginPath(); ctx.moveTo(GUTTER, 0); ctx.lineTo(GUTTER, h); ctx.stroke() + + // tracks + for (let ti = 0; ti < nT; ti++) { + const x0 = GUTTER + ti * trackW + ctx.strokeStyle = '#1b2531'; ctx.beginPath(); ctx.moveTo(x0, 0); ctx.lineTo(x0, h); ctx.stroke() + const t = tracks[ti] + for (const cv of t.curves) { + const s = data?.curves.find(x => x.key === cv.key) + if (!s || !data) continue + const [cmin, cmax] = scaleFor(cv.key) + const pad = 4 + const sx = (val: number) => { + let f = (val - cmin) / (cmax - cmin) + if (f < 0) f = 0; else if (f > 1) f = 1 + return x0 + pad + f * (trackW - 2 * pad) + } + // min/max envelope (preserves spikes) + ctx.strokeStyle = cv.color; ctx.globalAlpha = 0.45; ctx.lineWidth = 1; ctx.beginPath() + for (let j = 0; j < data.n; j++) { + const pv = data.pos[j]; if (pv == null) continue + const mn = s.min[j], mx = s.max[j] + if (mn == null || mx == null) continue + const y = v2y(pv, h); if (y < -2 || y > h + 2) continue + ctx.moveTo(sx(mn), y); ctx.lineTo(sx(mx), y) + } + ctx.stroke() + // mid trace + ctx.globalAlpha = 1; ctx.beginPath(); let started = false + for (let j = 0; j < data.n; j++) { + const pv = data.pos[j]; if (pv == null) { started = false; continue } + const mn = s.min[j], mx = s.max[j] + if (mn == null || mx == null) { started = false; continue } + const y = v2y(pv, h) + const x = sx((mn + mx) / 2) + if (!started) { ctx.moveTo(x, y); started = true } else ctx.lineTo(x, y) + } + ctx.stroke() + } + } + ctx.globalAlpha = 1 + + // crosshair + if (readout) { + ctx.strokeStyle = '#36c6a0'; ctx.globalAlpha = 0.6; ctx.setLineDash([4, 3]) + ctx.beginPath(); ctx.moveTo(GUTTER, readout.y); ctx.lineTo(w, readout.y); ctx.stroke() + ctx.setLineDash([]); ctx.globalAlpha = 1 + } + // playhead at bottom while replaying + if (playing) { + ctx.strokeStyle = '#e0a23c'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(GUTTER, h - 1); ctx.lineTo(w, h - 1); ctx.stroke() + } + + // position the scrollbar thumb to mirror the visible window + const track = trackRef.current, thumb = thumbRef.current + if (track && thumb) { + const trackH = track.clientHeight + const full = extent.max - extent.min + const span = to - from + const th = Math.max(28, full > 0 ? (trackH * span) / full : trackH) + const maxTop = trackH - th + const top = full > span ? ((from - extent.min) / (full - span)) * maxTop : 0 + thumb.style.height = th + 'px' + thumb.style.top = top + 'px' + } + }, [extent, tracks, axis, scaleFor, readout, playing]) + + const requestDraw = useCallback(() => { + if (drawPending.current) return + drawPending.current = true + requestAnimationFrame(() => draw()) + }, [draw]) + + useEffect(() => { requestDraw() }, [requestDraw]) + + /* ---------- interactions ---------- */ + const clampView = useCallback((from: number, to: number) => { + if (!extent) return { from, to } + const full = extent.max - extent.min + let span = to - from + if (span >= full) return { from: extent.min, to: extent.max } + if (span < 1e-9) span = 1e-9 + if (from < extent.min) { from = extent.min; to = from + span } + if (to > extent.max) { to = extent.max; from = to - span } + return { from, to } + }, [extent]) + + const setView = useCallback((from: number, to: number) => { + viewRef.current = clampView(from, to) + scheduleFetch(); requestDraw() + }, [clampView, scheduleFetch, requestDraw]) + + const onWheel = useCallback((e: React.WheelEvent) => { + if (!extent) return + e.preventDefault() + const rect = canvasRef.current!.getBoundingClientRect() + const y = e.clientY - rect.top + const h = sizeRef.current.h + const v = y2v(y, h) + const { from, to } = viewRef.current + const factor = e.deltaY > 0 ? 1.2 : 0.83 + let span = (to - from) * factor + const full = extent.max - extent.min + const minSpan = axis === 'time' ? 5 : 1 + span = Math.max(minSpan, Math.min(full, span)) + const frac = y / h + setView(v - frac * span, v - frac * span + span) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [extent, axis, setView]) + + const onMouseMove = (e: React.MouseEvent) => { + const rect = canvasRef.current!.getBoundingClientRect() + const h = sizeRef.current.h + const y = e.clientY - rect.top + // hover readout + const data = dataRef.current + const idx = y2v(y, h) + const rows: { label: string; color: string; v: number | null; unit: string }[] = [] + if (data) { + const j = nearest(data.pos, idx) + for (const t of tracks) for (const cv of t.curves) { + const s = data.curves.find(x => x.key === cv.key) + const r = roleMap.get(cv.key) + let val: number | null = null + if (s && j >= 0) { const mn = s.min[j], mx = s.max[j]; if (mn != null && mx != null) val = (mn + mx) / 2 } + rows.push({ label: r?.mnemonic ?? cv.key, color: cv.color, v: val, unit: r?.unit ?? '' }) + } + } + setReadout({ x: e.clientX - rect.left, y, index: idx, rows }) + } + const onLeave = () => { setReadout(null) } + + const zoomBy = useCallback((factor: number) => { + if (!extent) return + const { from, to } = viewRef.current + const c = (from + to) / 2 + const full = extent.max - extent.min + const minSpan = axis === 'time' ? 5 : 1 + const span = Math.max(minSpan, Math.min(full, (to - from) * factor)) + setView(c - span / 2, c + span / 2) + }, [extent, axis, setView]) + + /* ---------- scrollbar (pan through depth/time) ---------- */ + const thumbGeom = useCallback(() => { + const trackH = trackRef.current?.clientHeight ?? 1 + const full = extent ? extent.max - extent.min : 1 + const span = viewRef.current.to - viewRef.current.from + const th = Math.max(28, full > 0 ? (trackH * span) / full : trackH) + return { trackH, full, span, th, maxTop: trackH - th } + }, [extent]) + + const sbDrag = useRef<{ y: number; from: number } | null>(null) + const onThumbDown = (e: React.MouseEvent) => { + e.preventDefault(); e.stopPropagation() + if (!extent) return + sbDrag.current = { y: e.clientY, from: viewRef.current.from } + const move = (ev: MouseEvent) => { + if (!sbDrag.current) return + const g = thumbGeom() + const dy = ev.clientY - sbDrag.current.y + const dFrac = g.maxTop > 0 ? dy / g.maxTop : 0 + const nf = sbDrag.current.from + dFrac * (g.full - g.span) + setView(nf, nf + g.span) + } + const up = () => { sbDrag.current = null; document.removeEventListener('mousemove', move); document.removeEventListener('mouseup', up) } + document.addEventListener('mousemove', move) + document.addEventListener('mouseup', up) + } + const onTrackDown = (e: React.MouseEvent) => { + if (!extent) return + const rect = trackRef.current!.getBoundingClientRect() + const clickY = e.clientY - rect.top + const g = thumbGeom() + const top = g.full > g.span ? ((viewRef.current.from - extent.min) / (g.full - g.span)) * g.maxTop : 0 + const page = g.span * 0.9 + if (clickY < top) setView(viewRef.current.from - page, viewRef.current.to - page) + else if (clickY > top + g.th) setView(viewRef.current.from + page, viewRef.current.to + page) + } + + /* ---------- replay ---------- */ + useEffect(() => { + if (!playing || !extent) return + let raf = 0 + let last = performance.now() + const tick = (t: number) => { + const dt = (t - last) / 1000; last = t + const { from, to } = viewRef.current + const span = to - from + let nt = to + speed * dt + if (nt >= extent.max) { + viewRef.current = { from: extent.max - span, to: extent.max } + scheduleFetch(); requestDraw(); setPlaying(false); return + } + viewRef.current = clampView(nt - span, nt) + scheduleFetch(); requestDraw() + raf = requestAnimationFrame(tick) + } + raf = requestAnimationFrame(tick) + return () => cancelAnimationFrame(raf) + }, [playing, speed, extent, clampView, scheduleFetch, requestDraw]) + + const resetView = () => { if (extent) setView(extent.min, extent.max) } + + /* ---------- track editing ---------- */ + const toggleRole = (key: string) => { + setTracks(prev => { + const has = prev.some(t => t.curves.some(c => c.key === key)) + if (has) { + return prev.map(t => ({ ...t, curves: t.curves.filter(c => c.key !== key) })).filter(t => t.curves.length) + } + const used = new Set(prev.flatMap(t => t.curves.map(c => c.key))) + const color = PALETTE[used.size % PALETTE.length] + return [...prev, { id: 't' + Date.now(), curves: [{ key, color }] }] + }) + } + const removeCurve = (key: string) => + setTracks(prev => prev.map(t => ({ ...t, curves: t.curves.filter(c => c.key !== key) })).filter(t => t.curves.length)) + + /* ---------- render ---------- */ + const nT = tracks.length + const speedLabel = axis === 'time' + ? `${fmtNum(Math.round(speed))} s/s` + : `${fmtNum(Math.round(speed))} ft/s` + + if (roles && !roles.ready) { + return
+
Building curve overview…
+
parsing channels for the log plot
+ } + if (roles && roles.roles.length === 0) { + return
No drilling channels recognized in this file.
Use the Raw / QC tab to inspect it.
+ } + + return ( +
+
+ {roles?.hasDepthAxis && roles?.hasTimeAxis && ( +
+ + +
+ )} + +
+ 🐌 + setSpeed(parseInt(e.target.value, 10))} /> + 🚀 + {speedLabel} +
+
+ + + + + + +
+ + {detail ? 'detail' : 'overview'} · {axis} + +
+ + {/* track headers aligned with canvas tracks */} +
+
+ {axis === 'depth' ? 'DEPTH ft' : 'TIME'} +
+ {tracks.map((t) => ( +
+ {t.curves.map(cv => { + const r = roleMap.get(cv.key) + const [mn, mx] = scaleFor(cv.key) + return ( +
+ + {r?.mnemonic ?? cv.key} + {fmtNum(Math.round(mn))}–{fmtNum(Math.round(mx))}{r?.unit ? ' ' + r.unit : ''} + removeCurve(cv.key)}>× +
+ ) + })} +
+ ))} +
+ +
+
+ + {showStats && stats.length > 0 && ( +
+
Window stats setShowStats(false)}>×
+ + + + {stats.map(s => ( + + + + + + + ))} + +
chanminavgmax
{s.mnemonic}{s.min == null ? '—' : trim(s.min)}{s.avg == null ? '—' : trim(s.avg)}{s.max == null ? '—' : trim(s.max)}
+
+ )} + {readout && ( +
+
{fmtIndex(readout.index, axis)}{axis === 'depth' ? ' ft' : ''}
+ {readout.rows.map((r, i) => ( +
+ + {r.label} + {r.v == null ? '—' : trim(r.v)} +
+ ))} +
+ )} +
+
+
+
+
+ + {pickerOpen && roles && ( + t.curves.map(c => c.key)))} + onToggle={toggleRole} onClose={() => setPickerOpen(false)} /> + )} +
+ ) +} + +/* ---------- curve picker popover ---------- */ +function CurvePicker({ roles, active, onToggle, onClose }: + { roles: RolesResponse; active: Set; onToggle: (k: string) => void; onClose: () => void }) { + const groups = ['mechanics', 'hydraulics', 'gas', 'directional', 'index'] + const labels: Record = { + mechanics: 'Drilling mechanics', hydraulics: 'Hydraulics & well control', + gas: 'Mud gas / formation', directional: 'Directional & dynamics', index: 'Index / state', + } + return ( +
+
Curves ×
+
+ {groups.map(g => { + const rs = roles.roles.filter(r => r.group === g) + if (!rs.length) return null + return ( +
+
{labels[g]}
+ {rs.map(r => ( + + ))} +
+ ) + })} +
+
+ ) +} + +/* ---------- helpers ---------- */ +function nearest(pos: (number | null)[], v: number): number { + let lo = 0, hi = pos.length - 1, best = -1, bestD = Infinity + // pos is ascending but may contain nulls; linear-ish guarded binary + while (lo <= hi) { + const mid = (lo + hi) >> 1 + const pv = pos[mid] + if (pv == null) { // probe outward + let k = mid + 1; while (k <= hi && pos[k] == null) k++ + if (k > hi) { hi = mid - 1; continue } + const d = Math.abs((pos[k] as number) - v); if (d < bestD) { bestD = d; best = k } + if ((pos[k] as number) < v) lo = k + 1; else hi = mid - 1 + continue + } + const d = Math.abs(pv - v); if (d < bestD) { bestD = d; best = mid } + if (pv < v) lo = mid + 1; else hi = mid - 1 + } + return best +} + +function niceTicks(from: number, to: number, count: number): number[] { + const span = to - from + if (span <= 0) return [] + const raw = span / count + const mag = Math.pow(10, Math.floor(Math.log10(raw))) + const norm = raw / mag + const step = (norm < 1.5 ? 1 : norm < 3 ? 2 : norm < 7 ? 5 : 10) * mag + const start = Math.ceil(from / step) * step + const out: number[] = [] + for (let v = start; v <= to; v += step) out.push(v) + return out +} + +function fmtIndex(v: number, axis: 'depth' | 'time'): string { + if (axis === 'depth') return fmtNum(Math.round(v)) + const d = new Date(v * 1000) + const hh = String(d.getHours()).padStart(2, '0') + const mm = String(d.getMinutes()).padStart(2, '0') + const ss = String(d.getSeconds()).padStart(2, '0') + return `${hh}:${mm}:${ss}` +} + +function trim(v: number): string { + if (Math.abs(v) >= 1000) return fmtNum(Math.round(v)) + return (Math.round(v * 100) / 100).toString() +} diff --git a/frontend/src/components/Section.tsx b/frontend/src/components/Section.tsx new file mode 100644 index 0000000..883a967 --- /dev/null +++ b/frontend/src/components/Section.tsx @@ -0,0 +1,23 @@ +import { ReactNode, useState } from 'react' + +interface Props { + title: string + count?: ReactNode + defaultOpen?: boolean + children: ReactNode +} + +/** A collapsible sidebar section with a chevron header. */ +export default function Section({ title, count, defaultOpen = true, children }: Props) { + const [open, setOpen] = useState(defaultOpen) + return ( +
+
setOpen(o => !o)}> + + {title} + {count != null && {count}} +
+ {open &&
{children}
} +
+ ) +} diff --git a/frontend/src/components/Viewer.tsx b/frontend/src/components/Viewer.tsx new file mode 100644 index 0000000..162ea36 --- /dev/null +++ b/frontend/src/components/Viewer.tsx @@ -0,0 +1,296 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useVirtualizer } from '@tanstack/react-virtual' +import { api, fmtNum } from '../api' +import type { FileMeta, FileSummary, SearchMatch } from '../types' + +const ROW = 20 // px per line +const PAGE = 500 // lines fetched per range request +const MARGIN_PAGES = 1 // prefetch a page above/below the viewport +const CACHE_CAP = 20000 // max cached lines (bounds browser memory on 10GB+ files) +const STREAM_INTERVAL = 70 + +interface Props { + file: FileSummary + meta: FileMeta | null + onError: (e: unknown) => void +} + +export default function Viewer({ file, meta, onError }: Props) { + const parentRef = useRef(null) + const cache = useRef>(new Map()) + const requested = useRef>(new Set()) + const [, setVersion] = useState(0) + const bump = useCallback(() => setVersion(v => v + 1), []) + + const available = file.availableLines + const dataStart = file.dataStartLine + + // reset everything when the selected file changes + useEffect(() => { + cache.current.clear() + requested.current.clear() + setPlaying(false) + setCurrentLine(-1) + setMatches([]) + parentRef.current?.scrollTo({ top: 0 }) + bump() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [file.id]) + + const virtualizer = useVirtualizer({ + count: available, + getScrollElement: () => parentRef.current, + estimateSize: () => ROW, + overscan: 24, + }) + + const items = virtualizer.getVirtualItems() + const first = items.length ? items[0].index : 0 + const last = items.length ? items[items.length - 1].index : 0 + const topLine = first + + /* ---------- range loading for browse / scroll ---------- */ + const ensureLoaded = useCallback(async (lo: number, hi: number) => { + const startPage = Math.max(0, Math.floor(lo / PAGE) - MARGIN_PAGES) + const endPage = Math.floor(hi / PAGE) + MARGIN_PAGES + for (let p = startPage; p <= endPage; p++) { + if (requested.current.has(p)) continue + const start = p * PAGE + if (start >= available) continue + requested.current.add(p) + try { + const resp = await api.lines(file.id, start, PAGE) + for (let i = 0; i < resp.lines.length; i++) cache.current.set(start + i, resp.lines[i]) + // A short page fetched before indexing finished is the moving frontier — don't pin it, + // so it re-fetches in full once more lines are available. + if (resp.lines.length < PAGE && file.status !== 'READY') requested.current.delete(p) + trimCache() + bump() + } catch (e) { + requested.current.delete(p) // allow retry + onError(e) + } + } + }, [file.id, available, file.status, onError, bump]) + + const trimCache = useCallback(() => { + const m = cache.current + if (m.size <= CACHE_CAP) return + const target = Math.floor(CACHE_CAP * 0.8) + const it = m.keys() + while (m.size > target) { + const k = it.next().value + if (k === undefined) break + m.delete(k) + requested.current.delete(Math.floor(k / PAGE)) + } + }, []) + + useEffect(() => { + if (available > 0) ensureLoaded(first, last) + }, [first, last, available, ensureLoaded]) + + /* ---------- streaming (SSE play) ---------- */ + const [playing, setPlaying] = useState(false) + const [speedV, setSpeedV] = useState(35) + const [currentLine, setCurrentLine] = useState(-1) + const esRef = useRef(null) + + const linesPerSec = useMemo(() => Math.round(5 * Math.pow(4000, speedV / 100)), [speedV]) + + const closeStream = useCallback(() => { + esRef.current?.close() + esRef.current = null + }, []) + + const openStream = useCallback((startLine: number) => { + closeStream() + const batch = Math.max(1, Math.round((linesPerSec * STREAM_INTERVAL) / 1000)) + const url = `/api/files/${file.id}/stream?start=${startLine}&batch=${batch}&intervalMs=${STREAM_INTERVAL}` + const es = new EventSource(url) + esRef.current = es + es.addEventListener('lines', (ev) => { + const d = JSON.parse((ev as MessageEvent).data) as { start: number; lines: string[] } + for (let i = 0; i < d.lines.length; i++) cache.current.set(d.start + i, d.lines[i]) + const next = d.start + d.lines.length + trimCache() + setCurrentLine(next - 1) + virtualizer.scrollToIndex(Math.min(next, available > 0 ? available - 1 : next), { align: 'end' }) + bump() + }) + es.addEventListener('eof', () => { setPlaying(false); closeStream() }) + es.onerror = () => { /* connection dropped or closed; stop following */ setPlaying(false); closeStream() } + }, [file.id, linesPerSec, available, closeStream, trimCache, virtualizer, bump]) + + const togglePlay = useCallback(() => { + if (playing) { setPlaying(false); closeStream(); return } + const start = currentLine >= 0 ? currentLine + 1 : topLine + setPlaying(true) + openStream(start) + }, [playing, currentLine, topLine, openStream, closeStream]) + + // restart stream when speed changes mid-play + useEffect(() => { + if (playing) openStream(currentLine >= 0 ? currentLine + 1 : topLine) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [linesPerSec]) + + useEffect(() => () => closeStream(), [closeStream]) + + /* ---------- navigation ---------- */ + const [gotoVal, setGotoVal] = useState('') + const jumpTo = useCallback((line: number, align: 'start' | 'center' = 'start') => { + const target = Math.max(0, Math.min(available - 1, line)) + virtualizer.scrollToIndex(target, { align }) + ensureLoaded(Math.max(0, target - PAGE), target + PAGE) + }, [available, virtualizer, ensureLoaded]) + + const onGoto = useCallback(() => { + const n = parseInt(gotoVal.replace(/[^0-9]/g, ''), 10) + if (!isNaN(n)) jumpTo(n - 1) + }, [gotoVal, jumpTo]) + + /* ---------- search (SSE) ---------- */ + const [query, setQuery] = useState('') + const [matches, setMatches] = useState([]) + const [searching, setSearching] = useState(false) + const [scanned, setScanned] = useState(0) + const searchEs = useRef(null) + const matchSet = useMemo(() => new Set(matches.map(m => m.line)), [matches]) + + const cancelSearch = useCallback(() => { + searchEs.current?.close() + searchEs.current = null + setSearching(false) + }, []) + + const runSearch = useCallback(() => { + cancelSearch() + if (!query.trim()) { setMatches([]); return } + setMatches([]); setScanned(0); setSearching(true) + const es = new EventSource(`/api/files/${file.id}/search?q=${encodeURIComponent(query)}&max=500`) + searchEs.current = es + es.addEventListener('match', (ev) => { + const m = JSON.parse((ev as MessageEvent).data) as SearchMatch + setMatches(prev => (prev.length < 500 ? [...prev, m] : prev)) + }) + es.addEventListener('progress', (ev) => { + const p = JSON.parse((ev as MessageEvent).data) as { scanned: number } + setScanned(p.scanned) + }) + es.addEventListener('done', () => { setSearching(false); es.close(); searchEs.current = null }) + es.onerror = () => { setSearching(false); es.close(); searchEs.current = null } + }, [query, file.id, cancelSearch]) + + useEffect(() => () => cancelSearch(), [cancelSearch]) + + /* ---------- render ---------- */ + const totalSize = virtualizer.getTotalSize() + const posPct = available > 1 ? ((topLine / (available - 1)) * 100) : 0 + + return ( + <> +
+ +
+ 🐌 + setSpeedV(parseInt(e.target.value, 10))} /> + 🚀 + {fmtNum(linesPerSec)}/s +
+ +
+ + + {dataStart >= 0 && ( + + )} + + + setGotoVal(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') onGoto() }} /> + + +
+ + setQuery(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') runSearch() }} /> + {searching + ? + : } + +
+ + {file.status === 'INDEXING' && indexing… } + line {fmtNum(topLine + 1)} / {fmtNum(available)} · {posPct.toFixed(1)}% + +
+ +
+ {available === 0 ? ( +
+
+
+
+ {file.status === 'ERROR' ? (file.error || 'Failed to index file') : 'Indexing file…'} +
+
+
+ ) : ( +
+ {items.map(vi => { + const line = vi.index + const text = cache.current.get(line) + return ( +
+ {fmtNum(line + 1)} + + {text ?? '⋯'} + +
+ ) + })} +
+ )} +
+ + {(matches.length > 0 || searching) && ( +
+
+ {searching ? <> : null} + + {matches.length} match{matches.length === 1 ? '' : 'es'} + {searching ? ` · scanned ${fmtNum(scanned)} lines…` : ''} + {matches.length >= 500 ? ' (showing first 500)' : ''} + + setMatches([])}>clear +
+ {matches.map((m, i) => ( +
jumpTo(m.line, 'center')}> + {fmtNum(m.line + 1)} + {m.text} +
+ ))} +
+ )} + + ) +} + +function lineClass(text: string | undefined, line: number, dataStart: number): string { + if (text === undefined) return 'ltext loading' + if (dataStart >= 0 && line === dataStart - 1) return 'ltext head' // the ~A column header + const t = text.trimStart() + if (t.startsWith('~')) return 'ltext sec' + if (t.startsWith('#')) return 'ltext comment' + return 'ltext' +} diff --git a/frontend/src/components/WellInfo.tsx b/frontend/src/components/WellInfo.tsx new file mode 100644 index 0000000..2bffc77 --- /dev/null +++ b/frontend/src/components/WellInfo.tsx @@ -0,0 +1,61 @@ +import { Fragment } from 'react' +import { fmtBytes, fmtNum } from '../api' +import { sectionValues } from '../las' +import type { FileMeta, FileSummary } from '../types' + +/** Compact well summary card extracted from the LAS WELL block. */ +export default function WellInfo({ meta, file }: { meta: FileMeta; file: FileSummary }) { + const w = sectionValues(meta, 'WELL') + const get = (k: string) => w.get(k) || '' + + const rows: [string, string][] = [] + const add = (label: string, k: string) => { const v = get(k); if (v) rows.push([label, v]) } + add('Field', 'FLD') + const cnty = get('CNTY'), stat = get('STAT') + if (cnty || stat) rows.push(['County', [cnty, stat].filter(Boolean).join(', ')]) + add('Country', 'CTRY') + add('Operator', 'COMP') + add('Service', 'SRVC') + add('Rig', 'RIG') + + const strt = get('STRT'), stop = get('STOP'), step = get('STEP') + const idxUnit = (() => { + const sec = meta.sections.find(s => s.name.toUpperCase().includes('WELL')) + const line = sec?.lines.find(l => l.trim().toUpperCase().startsWith('STRT')) + return line ? (line.split('.')[1] || '').trim().split(/\s/)[0] : '' + })() + + return ( +
+

{meta.summary.wellName || file.name}

+ {get('API') &&
API {get('API')}
} +
+ {rows.map(([l, v]) => (
{l}
{v}
))} +
+
+ {fmtNum(meta.curves.length)} curves + {fmtNum(file.availableLines)} rows + {fmtBytes(file.sizeBytes)} + {meta.nullValue && NULL {meta.nullValue}} + {meta.wrap && WRAP {meta.wrap}} +
+ {(strt || stop) && ( +
+ {strt && STRT {fmtIdx(strt, idxUnit)}} + {stop && STOP {fmtIdx(stop, idxUnit)}} + {step && step !== '0' && STEP {step} {idxUnit}} +
+ )} +
+ ) +} + +function fmtIdx(v: string, unit: string): string { + const n = Number(v) + if (unit.toLowerCase().startsWith('sec') && Number.isFinite(n) && n > 1e9) { + // epoch seconds -> readable + const d = new Date(n * 1000) + return d.toLocaleString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }) + } + return `${v}${unit ? ' ' + unit : ''}` +} diff --git a/frontend/src/las.ts b/frontend/src/las.ts new file mode 100644 index 0000000..ebbb09b --- /dev/null +++ b/frontend/src/las.ts @@ -0,0 +1,43 @@ +import type { FileMeta } from './types' + +/** Parse one LAS metadata line: `MNEM.UNIT VALUE : DESCRIPTION`. */ +export function parseHeaderLine(line: string): { mnem: string; unit: string; value: string; desc: string } | null { + if (!line || line[0] === '~' || line[0] === '#') return null + const dot = line.indexOf('.') + if (dot < 0) return null + const colon = line.indexOf(':') + let ue = dot + 1 + while (ue < line.length && !/\s/.test(line[ue])) ue++ + const mnem = line.slice(0, dot).trim() + const unit = line.slice(dot + 1, ue).trim() + let value = '', desc = '' + if (colon >= 0 && colon >= ue) { value = line.slice(ue, colon).trim(); desc = line.slice(colon + 1).trim() } + else if (colon >= 0) { desc = line.slice(colon + 1).trim() } + else { value = line.slice(ue).trim() } + if (!mnem) return null + return { mnem, unit, value, desc } +} + +/** Build a mnemonic -> value map from a named header section (e.g. "WELL"). */ +export function sectionValues(meta: FileMeta, nameIncludes: string): Map { + const m = new Map() + const sec = meta.sections.find(s => s.name.toUpperCase().includes(nameIncludes.toUpperCase())) + if (!sec) return m + for (const line of sec.lines) { + const p = parseHeaderLine(line) + if (p && p.value) m.set(p.mnem.toUpperCase(), p.value) + } + return m +} + +export const GROUP_COLOR: Record = { + mechanics: '#4aa3ff', + hydraulics: '#36c6a0', + gas: '#e0a23c', + directional: '#b48ead', + index: '#8696a8', +} + +export const GROUP_LABEL: Record = { + mechanics: 'MECH', hydraulics: 'HYD', gas: 'GAS', directional: 'DIR', index: 'IDX', +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..6906b28 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,10 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import App from './App' +import './styles.css' + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/frontend/src/styles.css b/frontend/src/styles.css new file mode 100644 index 0000000..59bf063 --- /dev/null +++ b/frontend/src/styles.css @@ -0,0 +1,394 @@ +:root { + --bg: #0b0f14; + --bg-1: #111824; + --bg-2: #18212e; + --bg-3: #1f2a3a; + --line: #25323f; + --txt: #d7e0ea; + --txt-dim: #8696a8; + --txt-faint: #5c6b7c; + --accent: #36c6a0; + --accent-2: #4aa3ff; + --warn: #e0a23c; + --err: #e9645f; + --mono: 'JetBrains Mono', 'Cascadia Code', 'Consolas', ui-monospace, monospace; + --sans: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; +} + +* { box-sizing: border-box; } + +html, body, #root { + height: 100%; + margin: 0; +} + +body { + background: var(--bg); + color: var(--txt); + font-family: var(--sans); + font-size: 14px; + -webkit-font-smoothing: antialiased; +} + +.app { + display: grid; + grid-template-columns: 360px 1fr; + grid-template-rows: 52px 1fr; + grid-template-areas: "top top" "side main"; + height: 100vh; + transition: grid-template-columns .16s ease; +} +.app.nosidebar { grid-template-columns: 0 1fr; } +.app.nosidebar .sidebar { display: none; } + +.sidebar-toggle { + background: var(--bg-3); color: var(--txt-dim); border: 1px solid var(--line); + border-radius: 7px; width: 30px; height: 28px; cursor: pointer; font-size: 13px; + display: grid; place-items: center; flex: 0 0 auto; +} +.sidebar-toggle:hover { background: #27374a; color: var(--txt); } + +/* ---- top bar ---- */ +.topbar { + grid-area: top; + display: flex; + align-items: center; + gap: 12px; + padding: 0 18px; + background: linear-gradient(180deg, var(--bg-2), var(--bg-1)); + border-bottom: 1px solid var(--line); +} +.topbar .logo { + width: 26px; height: 26px; + border-radius: 7px; + background: linear-gradient(135deg, var(--accent), var(--accent-2)); + display: grid; place-items: center; + font-weight: 800; color: #06231d; font-size: 13px; +} +.topbar h1 { font-size: 15px; font-weight: 650; margin: 0; letter-spacing: .2px; } +.topbar .sub { color: var(--txt-faint); font-size: 12px; } +.topbar .spacer { flex: 1; } +.topbar .stat { color: var(--txt-dim); font-size: 12px; font-family: var(--mono); } + +/* ---- sidebar ---- */ +.sidebar { + grid-area: side; + background: var(--bg-1); + border-right: 1px solid var(--line); + overflow-y: auto; + display: flex; + flex-direction: column; +} +.panel { border-bottom: 1px solid var(--line); } +.panel-h { + padding: 12px 16px 8px; + font-size: 11px; + font-weight: 700; + letter-spacing: .8px; + text-transform: uppercase; + color: var(--txt-faint); +} +.panel-b { padding: 0 14px 14px; } + +/* segmented control */ +.seg { + display: flex; + background: var(--bg-2); + border: 1px solid var(--line); + border-radius: 9px; + padding: 3px; + gap: 3px; + margin-bottom: 12px; +} +.seg button { + flex: 1; + background: transparent; + border: 0; + color: var(--txt-dim); + padding: 6px 8px; + border-radius: 7px; + cursor: pointer; + font-size: 12.5px; + font-weight: 600; +} +.seg button.on { background: var(--bg-3); color: var(--txt); box-shadow: 0 1px 2px rgba(0,0,0,.3); } + +/* dropzone */ +.drop { + border: 1.5px dashed var(--line); + border-radius: 12px; + padding: 22px 14px; + text-align: center; + color: var(--txt-dim); + cursor: pointer; + transition: border-color .15s, background .15s; +} +.drop:hover, .drop.over { border-color: var(--accent); background: rgba(54,198,160,.06); color: var(--txt); } +.drop .big { font-size: 13px; font-weight: 600; color: var(--txt); } +.drop .small { font-size: 11.5px; margin-top: 4px; } + +/* browser list */ +.crumbs { font-family: var(--mono); font-size: 11px; color: var(--txt-faint); margin: 0 0 8px; word-break: break-all; } +.fbrow { + display: flex; align-items: center; gap: 8px; + padding: 7px 9px; border-radius: 8px; cursor: pointer; + font-size: 12.5px; +} +.fbrow:hover { background: var(--bg-2); } +.fbrow .ic { width: 16px; text-align: center; opacity: .8; } +.fbrow .nm { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.fbrow .sz { color: var(--txt-faint); font-family: var(--mono); font-size: 11px; } +.fbrow.las .nm { color: var(--accent); } + +/* file cards */ +.fcard { + background: var(--bg-2); + border: 1px solid var(--line); + border-radius: 11px; + padding: 11px 12px; + margin-bottom: 9px; + cursor: pointer; + position: relative; + transition: border-color .15s; +} +.fcard:hover { border-color: #33485e; } +.fcard.sel { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent) inset; } +.fcard .nm { font-weight: 650; font-size: 13px; word-break: break-all; padding-right: 20px; } +.fcard .meta { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 6px; color: var(--txt-dim); font-size: 11.5px; font-family: var(--mono); } +.fcard .x { position: absolute; top: 8px; right: 9px; color: var(--txt-faint); cursor: pointer; font-size: 14px; line-height: 1; } +.fcard .x:hover { color: var(--err); } + +.badge { padding: 1px 7px; border-radius: 20px; font-size: 10.5px; font-weight: 700; letter-spacing: .3px; } +.b-READY { background: rgba(54,198,160,.16); color: var(--accent); } +.b-INDEXING { background: rgba(74,163,255,.16); color: var(--accent-2); } +.b-REGISTERED { background: rgba(134,150,168,.16); color: var(--txt-dim); } +.b-ERROR { background: rgba(233,100,95,.16); color: var(--err); } + +.prog { height: 4px; background: var(--bg-3); border-radius: 4px; overflow: hidden; margin-top: 8px; } +.prog > span { display: block; height: 100%; background: linear-gradient(90deg, var(--accent-2), var(--accent)); transition: width .3s; } + +/* ---- main ---- */ +.main { grid-area: main; display: flex; flex-direction: column; overflow: hidden; background: var(--bg); } + +.viewer-bar { + display: flex; align-items: center; gap: 10px; + padding: 9px 14px; + background: var(--bg-1); + border-bottom: 1px solid var(--line); + flex-wrap: wrap; +} +.btn { + background: var(--bg-3); color: var(--txt); border: 1px solid var(--line); + border-radius: 8px; padding: 6px 11px; cursor: pointer; font-size: 12.5px; font-weight: 600; + display: inline-flex; align-items: center; gap: 6px; +} +.btn:hover { background: #27374a; } +.btn.primary { background: var(--accent); color: #06231d; border-color: transparent; } +.btn.primary:hover { filter: brightness(1.08); } +.btn:disabled { opacity: .45; cursor: not-allowed; } +.btn.ghost { background: transparent; } + +.field { + background: var(--bg-2); border: 1px solid var(--line); color: var(--txt); + border-radius: 8px; padding: 6px 9px; font-size: 12.5px; font-family: var(--mono); +} +.field:focus { outline: none; border-color: var(--accent-2); } +.field.sm { width: 110px; } + +.speed { display: flex; align-items: center; gap: 8px; color: var(--txt-dim); font-size: 12px; } +.speed input[type=range] { width: 130px; accent-color: var(--accent); } + +.sep { width: 1px; height: 22px; background: var(--line); } +.grow { flex: 1; } + +/* ---- the line viewer ---- */ +.viewport { + flex: 1; + overflow: auto; + background: #0a0e13; + font-family: var(--mono); + font-size: 12.5px; + line-height: 20px; + position: relative; +} +.rows { position: relative; width: 100%; } +.row { + position: absolute; left: 0; top: 0; + width: max-content; min-width: 100%; + display: flex; white-space: pre; align-items: center; +} +.row:hover { background: rgba(255,255,255,.03); } +.row.cur { background: rgba(54,198,160,.10); } +.row.hit { background: rgba(224,162,60,.14); } +.gutter { + position: sticky; left: 0; + flex: 0 0 auto; width: 86px; + text-align: right; padding-right: 12px; + color: var(--txt-faint); user-select: none; + background: #0a0e13; + border-right: 1px solid var(--line); +} +.ltext { padding-left: 12px; padding-right: 24px; } +.ltext.sec { color: var(--accent); font-weight: 600; } +.ltext.comment { color: var(--txt-faint); } +.ltext.head { color: var(--accent-2); } +.ltext.loading { color: var(--txt-faint); font-style: italic; } + +/* ---- header / curves panel ---- */ +.hp { padding: 0 0 6px; } +.hp .grp { border-bottom: 1px solid var(--line); } +.hp .grp > summary { + list-style: none; cursor: pointer; padding: 10px 16px; + font-size: 12px; font-weight: 700; color: var(--txt); + display: flex; align-items: center; gap: 8px; +} +.hp .grp > summary::-webkit-details-marker { display: none; } +.hp .grp > summary .cnt { color: var(--txt-faint); font-weight: 500; font-family: var(--mono); font-size: 11px; } +.hp pre { + margin: 0; padding: 0 16px 12px; font-family: var(--mono); font-size: 11.5px; + color: var(--txt-dim); white-space: pre-wrap; word-break: break-word; max-height: 280px; overflow: auto; +} +.curve-tbl { width: 100%; border-collapse: collapse; font-family: var(--mono); font-size: 11.5px; } +.curve-tbl th { text-align: left; color: var(--txt-faint); font-weight: 600; padding: 4px 16px; position: sticky; top: 0; background: var(--bg-1); } +.curve-tbl td { padding: 3px 16px; border-top: 1px solid var(--line); color: var(--txt-dim); } +.curve-tbl td.mn { color: var(--accent); font-weight: 600; } +.curve-wrap { max-height: 360px; overflow: auto; } + +/* search results */ +.search-results { max-height: 200px; overflow: auto; border-top: 1px solid var(--line); background: var(--bg-1); } +.sr-row { padding: 5px 14px; cursor: pointer; font-family: var(--mono); font-size: 11.5px; display: flex; gap: 10px; border-bottom: 1px solid var(--bg-2); } +.sr-row:hover { background: var(--bg-2); } +.sr-row .ln { color: var(--accent-2); flex: 0 0 auto; } +.sr-row .tx { color: var(--txt-dim); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +/* misc */ +.empty { display: grid; place-items: center; height: 100%; color: var(--txt-faint); text-align: center; } +.empty .e-ic { font-size: 40px; margin-bottom: 10px; opacity: .5; } +.spin { display: inline-block; width: 12px; height: 12px; border: 2px solid var(--txt-faint); border-top-color: var(--accent); border-radius: 50%; animation: sp .7s linear infinite; } +@keyframes sp { to { transform: rotate(360deg); } } +.toast { position: fixed; bottom: 18px; left: 50%; transform: translateX(-50%); background: var(--bg-3); border: 1px solid var(--err); color: var(--txt); padding: 9px 16px; border-radius: 10px; font-size: 12.5px; z-index: 50; box-shadow: 0 8px 24px rgba(0,0,0,.4); } +.muted { color: var(--txt-faint); } + +/* ---- log plot ---- */ +.lp { display: flex; flex-direction: column; height: 100%; min-height: 0; } +.lp-heads { display: flex; background: var(--bg-1); border-bottom: 1px solid var(--line); min-height: 30px; } +.lp-gutter-sp { flex: 0 0 auto; display: grid; place-items: center; border-right: 1px solid var(--line); } +.lp-head { border-right: 1px solid #1b2531; padding: 3px 5px; overflow: hidden; min-width: 0; } +.lp-curve { display: flex; align-items: center; gap: 4px; font-size: 10.5px; font-family: var(--mono); line-height: 1.45; } +.lp-curve .dot { width: 8px; height: 8px; border-radius: 2px; flex: 0 0 auto; } +.lp-curve .mn { font-weight: 700; } +.lp-curve .rng { color: var(--txt-faint); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.lp-curve .rm { margin-left: auto; color: var(--txt-faint); cursor: pointer; padding: 0 2px; } +.lp-curve .rm:hover { color: var(--err); } +.lp-body { flex: 1; display: flex; min-height: 0; overflow: hidden; } +.lp-canvas-wrap { flex: 1; position: relative; min-width: 0; overflow: hidden; } +.lp-body canvas { display: block; } +.lp-scrollbar { flex: 0 0 15px; width: 15px; background: var(--bg-1); border-left: 1px solid var(--line); position: relative; cursor: pointer; } +.lp-thumb { position: absolute; left: 2px; right: 2px; min-height: 28px; border-radius: 6px; background: #33485e; cursor: grab; } +.lp-thumb:hover { background: #3e566e; } +.lp-thumb:active { cursor: grabbing; background: var(--accent-2); } +.lp-readout { + position: absolute; pointer-events: none; z-index: 5; + background: rgba(17,24,36,.95); border: 1px solid var(--line); border-radius: 8px; + padding: 6px 8px; font-family: var(--mono); font-size: 11px; min-width: 150px; + box-shadow: 0 6px 20px rgba(0,0,0,.5); +} +.lp-readout .ix { color: var(--accent); font-weight: 700; margin-bottom: 4px; border-bottom: 1px solid var(--line); padding-bottom: 3px; } +.lp-readout .ro { display: flex; align-items: center; gap: 5px; } +.lp-readout .ro .dot { width: 7px; height: 7px; border-radius: 2px; flex: 0 0 auto; } +.lp-readout .ro .l { color: var(--txt-dim); width: 52px; overflow: hidden; text-overflow: ellipsis; } +.lp-readout .ro .vv { margin-left: auto; color: var(--txt); } + +.lp-picker { + position: absolute; top: 96px; right: 16px; z-index: 20; width: 300px; max-height: 70vh; + background: var(--bg-2); border: 1px solid var(--line); border-radius: 12px; overflow: hidden; + box-shadow: 0 12px 40px rgba(0,0,0,.5); display: flex; flex-direction: column; +} +.lp-picker .hd { padding: 9px 12px; font-weight: 700; font-size: 12px; border-bottom: 1px solid var(--line); display: flex; } +.lp-picker .hd .x { margin-left: auto; cursor: pointer; color: var(--txt-faint); } +.lp-picker .hd .x:hover { color: var(--err); } +.lp-picker .bd { padding: 8px; overflow-y: auto; } +.lp-picker .grp { margin-bottom: 8px; } +.lp-picker .gh { font-size: 10px; text-transform: uppercase; letter-spacing: .6px; color: var(--txt-faint); margin: 4px 4px 5px; } +.lp-picker .chip { + display: flex; align-items: center; gap: 7px; padding: 4px 7px; border-radius: 7px; cursor: pointer; font-size: 12px; +} +.lp-picker .chip:hover { background: var(--bg-3); } +.lp-picker .chip.on { background: rgba(54,198,160,.1); } +.lp-picker .chip .mn { font-family: var(--mono); font-weight: 700; width: 56px; } +.lp-picker .chip .ds { color: var(--txt-dim); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +.main-tabs { display: flex; gap: 2px; } +.main-tabs button { + background: transparent; border: 0; border-bottom: 2px solid transparent; color: var(--txt-dim); + padding: 7px 14px; cursor: pointer; font-size: 13px; font-weight: 600; +} +.main-tabs button.on { color: var(--txt); border-bottom-color: var(--accent); } + +/* ---- collapsible sidebar section ---- */ +.sec { border-bottom: 1px solid var(--line); } +.sec-h { + display: flex; align-items: center; gap: 8px; cursor: pointer; user-select: none; + padding: 11px 16px; font-size: 11px; font-weight: 700; letter-spacing: .7px; + text-transform: uppercase; color: var(--txt-faint); +} +.sec-h:hover { color: var(--txt-dim); } +.sec-h .chev { transition: transform .15s; font-size: 10px; } +.sec-h.closed .chev { transform: rotate(-90deg); } +.sec-h .cnt { margin-left: auto; font-weight: 600; font-family: var(--mono); text-transform: none; letter-spacing: 0; } +.sec-b { padding: 0 14px 14px; } + +/* well info card */ +.wi { padding: 0; } +.wi h2 { font-size: 15px; margin: 0 0 2px; font-weight: 650; } +.wi .api { font-family: var(--mono); font-size: 11px; color: var(--txt-faint); margin-bottom: 10px; } +.wi-grid { display: grid; grid-template-columns: auto 1fr; gap: 4px 10px; font-size: 12px; } +.wi-grid dt { color: var(--txt-faint); } +.wi-grid dd { margin: 0; color: var(--txt); overflow: hidden; text-overflow: ellipsis; } +.wi-chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 11px; } +.wi-chip { background: var(--bg-2); border: 1px solid var(--line); border-radius: 7px; padding: 4px 8px; font-size: 11px; font-family: var(--mono); } +.wi-chip b { color: var(--accent); } + +/* channel browser */ +.ch-tools { display: flex; gap: 6px; margin-bottom: 8px; } +.ch-tools .field { flex: 1; } +.ch-filter { display: flex; align-items: center; gap: 5px; font-size: 11.5px; color: var(--txt-dim); margin-bottom: 8px; cursor: pointer; } +.ch-list { max-height: 320px; overflow-y: auto; margin: 0 -6px; } +.ch-row { display: flex; align-items: center; gap: 7px; padding: 5px 8px; border-radius: 7px; font-size: 12px; } +.ch-row:hover { background: var(--bg-2); } +.ch-row .mn { font-family: var(--mono); font-weight: 700; min-width: 56px; } +.ch-row .u { color: var(--txt-faint); font-family: var(--mono); font-size: 10.5px; min-width: 38px; } +.ch-row .d { color: var(--txt-dim); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.ch-row .badge2 { font-size: 9.5px; font-weight: 700; padding: 1px 6px; border-radius: 20px; white-space: nowrap; } + +/* stats panel on the plot */ +.lp-stats { + position: absolute; top: 8px; right: 8px; z-index: 6; width: 232px; max-height: calc(100% - 16px); overflow-y: auto; + background: rgba(17,24,36,.95); border: 1px solid var(--line); border-radius: 10px; padding: 8px 10px; + font-size: 11px; box-shadow: 0 8px 24px rgba(0,0,0,.45); +} +.lp-stats .sh { display: flex; align-items: center; font-weight: 700; font-size: 11px; color: var(--txt-dim); margin-bottom: 7px; } +.lp-stats .sh .x { margin-left: auto; cursor: pointer; color: var(--txt-faint); } +.lp-stats table { width: 100%; border-collapse: collapse; font-family: var(--mono); font-size: 10.5px; } +.lp-stats th { color: var(--txt-faint); font-weight: 600; text-align: right; padding: 2px 3px; } +.lp-stats th:first-child { text-align: left; } +.lp-stats td { text-align: right; padding: 2px 3px; color: var(--txt-dim); border-top: 1px solid var(--bg-3); } +.lp-stats td:first-child { text-align: left; font-weight: 700; } + +/* ---- structured raw-header view ---- */ +.hp-toggle { display: flex; padding: 6px 16px 2px; } +.hp-toggle .seg { margin: 0; width: 170px; } +.kv-list { padding: 4px 16px 12px; } +.kv { padding: 5px 0; border-bottom: 1px solid var(--bg-2); } +.kv:last-child { border-bottom: 0; } +.kv .top { display: flex; align-items: baseline; gap: 7px; } +.kv .m { font-family: var(--mono); font-weight: 700; font-size: 11.5px; color: var(--txt); white-space: nowrap; } +.kv .u { font-family: var(--mono); font-size: 10px; color: var(--txt-faint); } +.kv .v { margin-left: auto; font-family: var(--mono); font-size: 11.5px; color: var(--accent); text-align: right; word-break: break-word; } +.kv .v.empty { color: var(--txt-faint); } +.kv .d { font-size: 10.5px; color: var(--txt-faint); margin-top: 1px; } +.kv-free { font-family: var(--mono); font-size: 11px; color: var(--txt-dim); white-space: pre-wrap; word-break: break-word; padding: 2px 0; } +.scroll::-webkit-scrollbar { width: 11px; height: 11px; } +.scroll::-webkit-scrollbar-thumb { background: #2a3a4c; border-radius: 6px; border: 2px solid var(--bg); } +.viewport::-webkit-scrollbar { width: 12px; height: 12px; } +.viewport::-webkit-scrollbar-thumb { background: #2a3a4c; border-radius: 6px; border: 3px solid #0a0e13; } diff --git a/frontend/src/types.ts b/frontend/src/types.ts new file mode 100644 index 0000000..20e7618 --- /dev/null +++ b/frontend/src/types.ts @@ -0,0 +1,131 @@ +export interface FileSummary { + id: string + name: string + sizeBytes: number + status: 'REGISTERED' | 'INDEXING' | 'READY' | 'ERROR' + error: string | null + uploaded: boolean + headerReady: boolean + indexedLines: number + indexedBytes: number + totalLines: number + availableLines: number + dataStartLine: number + curveCount: number + wellName: string | null +} + +export interface Curve { + column: number + mnemonic: string + unit: string + apiCode: string + description: string +} + +export interface HeaderSection { + name: string + lines: string[] +} + +export interface FileMeta { + summary: FileSummary + sections: HeaderSection[] + curves: Curve[] + dataColumns: string[] + wrap: string | null + nullValue: string | null +} + +export interface LinesResponse { + start: number + lines: string[] + eof: boolean + availableLines: number +} + +export interface AppConfig { + allowedRoots: string[] + homeDir: string + uploadChunkSize: number + indexStride: number +} + +export interface BrowseEntry { + name: string + path: string + dir: boolean + sizeBytes: number + looksLikeLas: boolean +} + +export interface BrowseResponse { + dir: string + parent: string | null + entries: BrowseEntry[] +} + +export interface SearchMatch { + line: number + text: string +} + +// ---- log plot ---- +export interface RoleInfo { + key: string + label: string + group: string + mnemonic: string + unit: string + description: string + column: number + dataMin: number | null + dataMax: number | null + defMin: number + defMax: number +} + +export interface AxisExtent { min: number; max: number } + +export interface RolesResponse { + ready: boolean + hasTimeAxis: boolean + hasDepthAxis: boolean + timeExtent: AxisExtent | null + depthExtent: AxisExtent | null + roles: RoleInfo[] + defaultTracks: string[][] +} + +export interface CurveSeries { + key: string + mnemonic: string + unit: string + column: number + min: (number | null)[] + max: (number | null)[] + dataMin: number | null + dataMax: number | null +} + +export interface CurveData { + axis: string + detail: boolean + from: number + to: number + n: number + pos: (number | null)[] + curves: CurveSeries[] +} + +export interface CrossData { + x: number[] + y: number[] + c: (number | null)[] + xRange: [number, number] + yRange: [number, number] + cRange: [number, number] + total: number + returned: number + onBottomFiltered: boolean +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..c1183c9 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2021", + "useDefineForClassFields": true, + "lib": ["ES2021", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..391d001 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,22 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +// Dev: Vite serves the UI on :5173 and proxies /api (including SSE) to the Quarkus backend on :8090. +// Prod: `npm run build` emits to ../src/main/resources/META-INF/resources so Quarkus serves it on :8090. +export default defineConfig({ + plugins: [react()], + server: { + port: 5173, + proxy: { + '/api': { + target: 'http://localhost:8090', + changeOrigin: true, + }, + }, + }, + build: { + outDir: '../src/main/resources/META-INF/resources', + emptyOutDir: true, + chunkSizeWarningLimit: 1500, + }, +}) diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..6bbca44 --- /dev/null +++ b/pom.xml @@ -0,0 +1,78 @@ + + + 4.0.0 + + com.oiusa + las-stream-viewer + 1.0.0 + jar + LAS Stream Viewer (Quarkus) + Streams very large (10GB+) LAS well-log files line by line, with a React UI. + + + 21 + UTF-8 + UTF-8 + io.quarkus.platform + quarkus-bom + 3.34.3 + true + + + + + + ${quarkus.platform.group-id} + ${quarkus.platform.artifact-id} + ${quarkus.platform.version} + pom + import + + + + + + + + io.quarkus + quarkus-rest + + + io.quarkus + quarkus-rest-jackson + + + + io.quarkus + quarkus-smallrye-context-propagation + + + + io.quarkus + quarkus-junit5 + test + + + + + + + ${quarkus.platform.group-id} + quarkus-maven-plugin + ${quarkus.platform.version} + true + + + + build + generate-code + generate-code-tests + + + + + + + diff --git a/run.ps1 b/run.ps1 new file mode 100644 index 0000000..635c889 --- /dev/null +++ b/run.ps1 @@ -0,0 +1,18 @@ +# Dev launcher: starts the Vite UI (:5173) in a new window and the Quarkus API (:8090) here. +# Open http://localhost:5173 once both are up. Ctrl-C stops the backend. +$ErrorActionPreference = "Stop" +$root = $PSScriptRoot +$env:JAVA_HOME = "C:\Program Files\Java\jdk-21.0.11" +$mvn = "C:\Users\Dell\.m2\wrapper\dists\apache-maven-3.9.9-bin\4nf9hui3q3djbarqar9g711ggc\apache-maven-3.9.9\bin\mvn.cmd" + +if (-not (Test-Path "$root\frontend\node_modules")) { + Write-Host "Installing frontend dependencies..." -ForegroundColor Cyan + Push-Location "$root\frontend"; npm install; Pop-Location +} + +Write-Host "Starting Vite dev server (http://localhost:5173) in a new window..." -ForegroundColor Cyan +Start-Process powershell -ArgumentList '-NoExit', '-Command', "Set-Location `"$root\frontend`"; npm run dev" + +Write-Host "Starting Quarkus dev (API on http://localhost:8090)..." -ForegroundColor Cyan +Set-Location $root +& $mvn quarkus:dev diff --git a/src/main/java/com/oiusa/las/index/LineIndex.java b/src/main/java/com/oiusa/las/index/LineIndex.java new file mode 100644 index 0000000..67d2cf6 --- /dev/null +++ b/src/main/java/com/oiusa/las/index/LineIndex.java @@ -0,0 +1,70 @@ +package com.oiusa.las.index; + +import java.util.Arrays; + +/** + * Sparse byte-offset index over the lines of a file. + * + *

One checkpoint is stored every {@code stride} lines: {@code offsets[k]} is the byte + * offset of the first byte of line {@code k * stride} (0-based line numbering). To read an + * arbitrary line we seek to the nearest preceding checkpoint and scan forward at most + * {@code stride-1} lines — so memory stays tiny (e.g. 2.5M lines / stride 256 ≈ 10K longs + * ≈ 80 KB) while random access into a multi-gigabyte file is effectively O(stride). + * + *

Checkpoints are appended by a single indexing thread while reader threads look them up + * concurrently; both sides synchronize on this instance, which is cheap because lookups happen + * once per range request, not per line. + */ +public final class LineIndex { + + private final int stride; + private long[] offsets = new long[1024]; + private int count = 0; + private volatile long totalLines = -1; // -1 until the full scan completes + + public LineIndex(int stride) { + if (stride < 1) throw new IllegalArgumentException("stride must be >= 1"); + this.stride = stride; + } + + public int stride() { + return stride; + } + + /** Records that line {@code count * stride} starts at {@code byteOffset}. Must be called in line order. */ + public synchronized void addCheckpoint(long byteOffset) { + if (count == offsets.length) { + offsets = Arrays.copyOf(offsets, offsets.length * 2); + } + offsets[count++] = byteOffset; + } + + /** Byte offset of the checkpoint at or before {@code line} (i.e. the start of line {@code checkpointLine(line)}). */ + public synchronized long offsetForLine(long line) { + if (line < 0) line = 0; + int cp = (int) (line / stride); + if (cp >= count) cp = count - 1; + if (cp < 0) return 0; + return offsets[cp]; + } + + /** The line number of the checkpoint at or before {@code line}; reading starts here and skips forward. */ + public long checkpointLine(long line) { + if (line < 0) line = 0; + long cp = line / stride; + return cp * stride; + } + + /** Number of lines that have been indexed so far (checkpoints * stride is a lower bound; this is exact once set). */ + public synchronized long checkpointCount() { + return count; + } + + public long totalLines() { + return totalLines; + } + + public void setTotalLines(long total) { + this.totalLines = total; + } +} diff --git a/src/main/java/com/oiusa/las/index/LineReader.java b/src/main/java/com/oiusa/las/index/LineReader.java new file mode 100644 index 0000000..9094901 --- /dev/null +++ b/src/main/java/com/oiusa/las/index/LineReader.java @@ -0,0 +1,67 @@ +package com.oiusa.las.index; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.channels.Channels; +import java.nio.channels.FileChannel; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.ArrayList; +import java.util.List; + +import com.oiusa.las.model.LasFile; + +/** + * Reads an arbitrary range of lines from a (possibly huge) file using its {@link LineIndex}. + * + *

Opens a fresh {@link FileChannel}, seeks to the byte offset of the nearest checkpoint at or + * before the requested start line, then skips the remaining {@code start - checkpointLine} lines + * and reads {@code count} lines. Only the requested window is ever held in memory. Bytes are + * decoded as ISO-8859-1 (1:1, lossless for the ASCII content of LAS files) so no offset can land + * mid-character. + */ +public final class LineReader { + + public record Range(long start, List lines, boolean eof) {} + + private LineReader() {} + + public static Range read(LasFile file, long start, int count) throws IOException { + if (start < 0) start = 0; + if (count < 0) count = 0; + + long available = file.availableLines(); + if (available >= 0 && start >= available) { + return new Range(start, List.of(), true); + } + + long checkpointLine = file.index.checkpointLine(start); + long offset = file.index.offsetForLine(start); + long toSkip = start - checkpointLine; + + List lines = new ArrayList<>(Math.min(count, 4096)); + boolean eof = false; + + try (FileChannel ch = FileChannel.open(file.path, StandardOpenOption.READ)) { + ch.position(offset); + InputStream in = Channels.newInputStream(ch); + // Buffer generously: LAS data rows can be several KB wide. + BufferedReader r = new BufferedReader(new InputStreamReader(in, StandardCharsets.ISO_8859_1), 1 << 20); + + for (long i = 0; i < toSkip; i++) { + if (r.readLine() == null) { eof = true; break; } + } + if (!eof) { + for (int i = 0; i < count; i++) { + String line = r.readLine(); + if (line == null) { eof = true; break; } + lines.add(line); + } + } + } + return new Range(start, lines, eof); + } +} diff --git a/src/main/java/com/oiusa/las/index/Pyramid.java b/src/main/java/com/oiusa/las/index/Pyramid.java new file mode 100644 index 0000000..1a0b007 --- /dev/null +++ b/src/main/java/com/oiusa/las/index/Pyramid.java @@ -0,0 +1,188 @@ +package com.oiusa.las.index; + +import java.util.ArrayList; +import java.util.List; + +/** + * A min/max overview of the data, aggregated every {@code K} rows ("base buckets"). For each needed + * channel slot the bucket keeps the min and max over its rows (NULLs ignored), so when the whole + * well is squeezed into a few hundred pixels a 2-second gas kick or a torque transient still shows — + * the cardinal rule of honest log decimation is min/max per pixel, never averaging. + * + *

Each bucket also records its position on both axes: {@code axisTime} (first row's time, + * monotonic) and {@code axisDepth} (running-max hole depth = "drilled depth", also monotonic), plus + * the bucket's start line and on-bottom fraction. Switching the plot's x-axis just re-positions the + * same buckets along a different monotonic axis. + */ +public final class Pyramid { + + /** Hole depth is monotonic & can't jump; cap per-row growth so a garbage spike can't poison the axis. */ + public static final double MAX_DEPTH_JUMP = 5.0; // ft per row (1 s) — ROP up to ~18,000 ft/hr + public static final double MAX_PLAUSIBLE_DEPTH = 60000; + + public final int K; + public final int[] columns; // data-column index for each slot (sorted ascending) + public final int slots; + private final int timeSlot, depthSlot, onBottomSlot; + + // builder state + private float[] curMin, curMax; + private double[] curSum; + private int[] curCount; + private int rowsInBucket = 0; + private long bucketStartLine = 0; + private double bucketStartTime = Double.NaN; + private double runningMaxDepth = Double.NEGATIVE_INFINITY; + private int onBottomCount = 0; + + // accumulated buckets + private final List bMin = new ArrayList<>(); + private final List bMax = new ArrayList<>(); + private final List bMean = new ArrayList<>(); // per-bucket mean (for crossplots/stats) + private final List axis = new ArrayList<>(); // [time, depth] per bucket + private final List meta = new ArrayList<>(); // [startLine, onBottomPermille] per bucket + + private final float[] gMin, gMax; // global per-slot min/max (for autoscale) + + // finished arrays + private float[][] fMin, fMax, fMean; + private double[] fTime, fDepth; + private long[] fStartLine; + private float[] fOnBottom; + private int count = 0; + private volatile boolean ready = false; + + public Pyramid(int k, int[] columns, int timeSlot, int depthSlot, int onBottomSlot) { + this.K = Math.max(1, k); + this.columns = columns; + this.slots = columns.length; + this.timeSlot = timeSlot; + this.depthSlot = depthSlot; + this.onBottomSlot = onBottomSlot; + this.gMin = new float[slots]; + this.gMax = new float[slots]; + java.util.Arrays.fill(gMin, Float.POSITIVE_INFINITY); + java.util.Arrays.fill(gMax, Float.NEGATIVE_INFINITY); + resetBucket(); + } + + private void resetBucket() { + if (curMin == null) { + curMin = new float[slots]; + curMax = new float[slots]; + curSum = new double[slots]; + curCount = new int[slots]; + } + java.util.Arrays.fill(curMin, Float.POSITIVE_INFINITY); + java.util.Arrays.fill(curMax, Float.NEGATIVE_INFINITY); + java.util.Arrays.fill(curSum, 0); + java.util.Arrays.fill(curCount, 0); + rowsInBucket = 0; + onBottomCount = 0; + } + + /** Feed one data row's already-extracted slot values (NaN = NULL). */ + public void addRow(double[] vals, long lineNo) { + if (rowsInBucket == 0) { + bucketStartLine = lineNo; + bucketStartTime = timeSlot >= 0 ? vals[timeSlot] : lineNo; + } + for (int s = 0; s < slots; s++) { + double v = vals[s]; + if (!Double.isNaN(v)) { + float f = (float) v; + if (f < curMin[s]) curMin[s] = f; + if (f > curMax[s]) curMax[s] = f; + if (f < gMin[s]) gMin[s] = f; + if (f > gMax[s]) gMax[s] = f; + curSum[s] += v; + curCount[s]++; + } + } + if (depthSlot >= 0) { + runningMaxDepth = advanceDepth(runningMaxDepth, vals[depthSlot]); + } + if (onBottomSlot >= 0 && vals[onBottomSlot] > 0.5) onBottomCount++; + rowsInBucket++; + if (rowsInBucket >= K) flush(); + } + + private void flush() { + if (rowsInBucket == 0) return; + float[] mn = new float[slots]; + float[] mx = new float[slots]; + float[] me = new float[slots]; + for (int s = 0; s < slots; s++) { + if (curCount[s] == 0) { mn[s] = Float.NaN; mx[s] = Float.NaN; me[s] = Float.NaN; } + else { mn[s] = curMin[s]; mx[s] = curMax[s]; me[s] = (float) (curSum[s] / curCount[s]); } + } + bMin.add(mn); + bMax.add(mx); + bMean.add(me); + double depth = runningMaxDepth == Double.NEGATIVE_INFINITY ? Double.NaN : runningMaxDepth; + axis.add(new double[]{ bucketStartTime, depth }); + long permille = (long) Math.round((onBottomCount * 1000.0) / rowsInBucket); + meta.add(new long[]{ bucketStartLine, permille }); + resetBucket(); + } + + public void finish() { + flush(); + count = bMin.size(); + fMin = bMin.toArray(new float[0][]); + fMax = bMax.toArray(new float[0][]); + fMean = bMean.toArray(new float[0][]); + fTime = new double[count]; + fDepth = new double[count]; + fStartLine = new long[count]; + fOnBottom = new float[count]; + for (int i = 0; i < count; i++) { + fTime[i] = axis.get(i)[0]; + fDepth[i] = axis.get(i)[1]; + fStartLine[i] = meta.get(i)[0]; + fOnBottom[i] = meta.get(i)[1] / 1000f; + } + bMin.clear(); bMax.clear(); bMean.clear(); axis.clear(); meta.clear(); + ready = true; + } + + public boolean ready() { return ready; } + public int bucketCount() { return count; } + + public int slotOfColumn(int col) { + for (int s = 0; s < slots; s++) if (columns[s] == col) return s; + return -1; + } + + public float minAt(int bucket, int slot) { return fMin[bucket][slot]; } + public float maxAt(int bucket, int slot) { return fMax[bucket][slot]; } + public float meanAt(int bucket, int slot) { return fMean[bucket][slot]; } + public double[] axisArray(boolean depthAxis) { return depthAxis ? fDepth : fTime; } + public long startLine(int bucket) { return fStartLine[bucket]; } + public float onBottom(int bucket) { return fOnBottom[bucket]; } + + public float globalMin(int slot) { return gMin[slot] == Float.POSITIVE_INFINITY ? Float.NaN : gMin[slot]; } + public float globalMax(int slot) { return gMax[slot] == Float.NEGATIVE_INFINITY ? Float.NaN : gMax[slot]; } + + public double axisMin(boolean depthAxis) { + double[] a = depthAxis ? fDepth : fTime; + for (double v : a) if (!Double.isNaN(v)) return v; + return 0; + } + public double axisMax(boolean depthAxis) { + double[] a = depthAxis ? fDepth : fTime; + for (int i = a.length - 1; i >= 0; i--) if (!Double.isNaN(a[i])) return a[i]; + return 0; + } + + /** + * Robust monotonic hole-depth tracker: seeds on the first plausible reading, then only advances + * by at most {@link #MAX_DEPTH_JUMP} per row, so a single bad sample can't pin the axis. + */ + public static double advanceDepth(double runningMax, double d) { + if (Double.isNaN(d) || d < 0 || d > MAX_PLAUSIBLE_DEPTH) return runningMax; + if (runningMax == Double.NEGATIVE_INFINITY) return d; // seed + if (d > runningMax && d <= runningMax + MAX_DEPTH_JUMP) return d; + return runningMax; + } +} diff --git a/src/main/java/com/oiusa/las/index/RowParser.java b/src/main/java/com/oiusa/las/index/RowParser.java new file mode 100644 index 0000000..e1716f1 --- /dev/null +++ b/src/main/java/com/oiusa/las/index/RowParser.java @@ -0,0 +1,103 @@ +package com.oiusa.las.index; + +/** + * Extracts a chosen, sorted subset of whitespace-delimited columns from a data row, parsing each as + * a double (NULL sentinel and non-numeric tokens become {@code NaN}). Works over any + * {@link CharSequence} — a {@code String} (detail reads) or a zero-copy byte wrapper (indexing) — + * and avoids per-token substring allocation, since it runs ~40 columns × millions of rows. + */ +public final class RowParser { + + private RowParser() {} + + /** Zero-copy ISO-8859-1 view over a byte buffer, so the indexer can parse without allocating Strings. */ + public static final class ByteCharSeq implements CharSequence { + private byte[] b; + private int len; + public void set(byte[] buf, int length) { this.b = buf; this.len = length; } + public int length() { return len; } + public char charAt(int i) { return (char) (b[i] & 0xFF); } + public CharSequence subSequence(int s, int e) { return new String(b, s, e - s, java.nio.charset.StandardCharsets.ISO_8859_1); } + public String toString() { return new String(b, 0, len, java.nio.charset.StandardCharsets.ISO_8859_1); } + } + + /** + * @param line the row text + * @param columns sorted-ascending 0-based column indices to extract + * @param nullValue LAS NULL sentinel (e.g. -999.25); matching values map to NaN + * @param out output array, length == columns.length; filled with values or NaN + */ + public static void extract(CharSequence line, int[] columns, double nullValue, double[] out) { + for (int i = 0; i < out.length; i++) out[i] = Double.NaN; + final int n = line.length(); + int ptr = 0; // index into columns[] + int tok = 0; // current token index + int i = 0; + while (i < n && ptr < columns.length) { + // skip whitespace + while (i < n && isWs(line.charAt(i))) i++; + if (i >= n) break; + int start = i; + while (i < n && !isWs(line.charAt(i))) i++; + int end = i; // token = [start, end) + if (tok == columns[ptr]) { + double v = parse(line, start, end); + if (!Double.isNaN(v) && Math.abs(v - nullValue) < 1e-6) v = Double.NaN; + out[ptr] = v; + ptr++; + } + tok++; + } + } + + private static boolean isWs(char c) { return c == ' ' || c == '\t' || c == '\r' || c == '\n'; } + + /** Lightweight double parser; returns NaN on any non-numeric content (dates, flags, etc.). */ + static double parse(CharSequence s, int start, int end) { + int i = start; + if (i >= end) return Double.NaN; + boolean neg = false; + char c = s.charAt(i); + if (c == '+' || c == '-') { neg = c == '-'; i++; } + long mant = 0; + int digits = 0, scale = 0; + boolean dot = false; + for (; i < end; i++) { + c = s.charAt(i); + if (c >= '0' && c <= '9') { + if (digits < 16) { mant = mant * 10 + (c - '0'); if (dot) scale++; } + else { if (!dot) scale--; } // beyond long precision: track magnitude only + digits++; + } else if (c == '.') { + if (dot) return Double.NaN; + dot = true; + } else if (c == 'e' || c == 'E') { + return parseWithExp(s, start, end); + } else { + return Double.NaN; // non-numeric token (e.g. an ISO timestamp) + } + } + if (digits == 0) return Double.NaN; + double val = mant * pow10(-scale); + return neg ? -val : val; + } + + private static double parseWithExp(CharSequence s, int start, int end) { + // rare path; correctness over speed + try { + return Double.parseDouble(s.subSequence(start, end).toString()); + } catch (NumberFormatException e) { + return Double.NaN; + } + } + + private static final double[] POW10 = new double[]{ + 1e0, 1e1, 1e2, 1e3, 1e4, 1e5, 1e6, 1e7, 1e8, 1e9, 1e10, 1e11, 1e12, 1e13, 1e14, 1e15, 1e16 + }; + + private static double pow10(int e) { + if (e >= 0 && e < POW10.length) return POW10[e]; + if (e < 0 && -e < POW10.length) return 1.0 / POW10[-e]; + return Math.pow(10, e); + } +} diff --git a/src/main/java/com/oiusa/las/model/Curve.java b/src/main/java/com/oiusa/las/model/Curve.java new file mode 100644 index 0000000..aaefc77 --- /dev/null +++ b/src/main/java/com/oiusa/las/model/Curve.java @@ -0,0 +1,14 @@ +package com.oiusa.las.model; + +/** + * One channel from the LAS {@code ~CURVE} section, e.g. + * {@code TIME .seconds : 1 Time Logged}. + * + * @param column 0-based position of this curve among the data columns + * @param mnemonic short name (text before the first '.') + * @param unit unit of measure (between '.' and the next whitespace), may be empty + * @param apiCode API code / data field (between the unit and the ':'), often blank + * @param description free-text description (after the ':') + */ +public record Curve(int column, String mnemonic, String unit, String apiCode, String description) { +} diff --git a/src/main/java/com/oiusa/las/model/HeaderSection.java b/src/main/java/com/oiusa/las/model/HeaderSection.java new file mode 100644 index 0000000..2486991 --- /dev/null +++ b/src/main/java/com/oiusa/las/model/HeaderSection.java @@ -0,0 +1,13 @@ +package com.oiusa.las.model; + +import java.util.List; + +/** + * A LAS header section (a line starting with '~' plus the raw lines beneath it, + * e.g. {@code ~WELL INFORMATION BLOCK}). Lines are kept verbatim for display. + * + * @param name the section title without the leading '~' (e.g. "WELL INFORMATION BLOCK") + * @param lines the raw lines belonging to the section, excluding the header line itself + */ +public record HeaderSection(String name, List lines) { +} diff --git a/src/main/java/com/oiusa/las/model/LasFile.java b/src/main/java/com/oiusa/las/model/LasFile.java new file mode 100644 index 0000000..f21e5cf --- /dev/null +++ b/src/main/java/com/oiusa/las/model/LasFile.java @@ -0,0 +1,74 @@ +package com.oiusa.las.model; + +import java.nio.file.Path; +import java.util.List; +import java.util.Map; + +import com.oiusa.las.index.LineIndex; +import com.oiusa.las.index.Pyramid; + +/** + * A LAS file known to the server, plus its progressively-built index and parsed header. + * + *

Fields written by the background indexer are {@code volatile} so REST threads observe + * progress without locking. The header (sections/curves) becomes visible as soon as the + * indexer crosses the {@code ~A} data marker; the full {@link #totalLines} fills in when the + * one-pass scan completes. + */ +public final class LasFile { + + public enum Status { REGISTERED, INDEXING, READY, ERROR } + + public final String id; + public volatile String name; + public final Path path; + public final long sizeBytes; + /** true if the bytes were uploaded into our data dir (safe to delete); false if opened in-place. */ + public final boolean uploaded; + public final LineIndex index; + + public volatile Status status = Status.REGISTERED; + public volatile String error; + + // --- header (available once headerReady) --- + public volatile boolean headerReady = false; + public volatile List sections = List.of(); + public volatile List curves = List.of(); + /** Column names parsed from the {@code ~A} line, in data order. */ + public volatile List dataColumns = List.of(); + /** 0-based line number of the first data row (line after {@code ~A}); -1 if unknown. */ + public volatile long dataStartLine = -1; + public volatile String wrap; // YES / NO + public volatile String nullValue; // e.g. -999.25 + public volatile String wellName; + + // --- drilling roles + curve overview (for the log-plot view) --- + public volatile Map roles = Map.of(); + public volatile Pyramid pyramid; + public volatile boolean hasTimeAxis = false; + public volatile boolean hasDepthAxis = false; + public volatile int timeCol = -1; + public volatile int holeDepthCol = -1; + public volatile int bitDepthCol = -1; + public volatile int onBottomCol = -1; + + // --- index progress --- + public volatile long indexedLines = 0; + public volatile long indexedBytes = 0; + + public LasFile(String id, String name, Path path, long sizeBytes, boolean uploaded, int stride) { + this.id = id; + this.name = name; + this.path = path; + this.sizeBytes = sizeBytes; + this.uploaded = uploaded; + this.index = new LineIndex(stride); + } + + /** Lines safe to read right now: the full count once READY, otherwise what's been indexed so far. */ + public long availableLines() { + long total = index.totalLines(); + if (total >= 0) return total; + return indexedLines; + } +} diff --git a/src/main/java/com/oiusa/las/model/ResolvedRole.java b/src/main/java/com/oiusa/las/model/ResolvedRole.java new file mode 100644 index 0000000..76db077 --- /dev/null +++ b/src/main/java/com/oiusa/las/model/ResolvedRole.java @@ -0,0 +1,16 @@ +package com.oiusa.las.model; + +/** + * A drilling "role" (ROP, WOB, total gas, stick-slip, …) bound to a concrete curve in a file. + * + * @param key stable role id (e.g. "rop") + * @param label human label (e.g. "ROP") + * @param group track group ("mechanics", "hydraulics", "gas", "directional", "index") + * @param mnemonic the LAS mnemonic this role resolved to + * @param unit the resolved curve's unit + * @param description the resolved curve's description + * @param column 0-based data column index + */ +public record ResolvedRole(String key, String label, String group, String mnemonic, + String unit, String description, int column) { +} diff --git a/src/main/java/com/oiusa/las/service/ChannelRoles.java b/src/main/java/com/oiusa/las/service/ChannelRoles.java new file mode 100644 index 0000000..b46a00e --- /dev/null +++ b/src/main/java/com/oiusa/las/service/ChannelRoles.java @@ -0,0 +1,153 @@ +package com.oiusa.las.service; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import com.oiusa.las.model.Curve; +import com.oiusa.las.model.ResolvedRole; + +/** + * Maps the raw LAS channel mnemonics to standard drilling "roles" (ROP, WOB, total gas, stick-slip, + * …) so the UI can build a proper multi-track log plot instead of a wall of numbers. + * + *

Resolution is by exact mnemonic first (Pason names are stable), then a description-keyword + * fallback for robustness across exports. Whatever auto-resolves is just the default — the UI lets + * the engineer reassign any track to any of the file's curves. + */ +public final class ChannelRoles { + + public record RoleDef(String key, String label, String group, String unit, + double defMin, double defMax, List aliases) {} + + /** group keys, in display order */ + public static final List GROUPS = List.of("index", "mechanics", "hydraulics", "gas", "directional"); + + // The role table. unit is expected unit (resolved curve's own unit wins for display); defMin/defMax + // are sensible physical scales so a single garbage spike can't flatten the real trace. + public static final List ROLES = List.of( + // ---- index / state ---- + new RoleDef("holeDepth", "Hole Depth", "index", "ft", 0, 25000, List.of("DEPT")), + new RoleDef("bitDepth", "Bit Depth", "index", "ft", 0, 25000, List.of("BDEP")), + new RoleDef("time", "Time", "index", "s", 0, 0, List.of("TIME")), + new RoleDef("tvd", "TVD", "index", "ft", 0, 15000, List.of("TVDHD", "TVDBD")), + new RoleDef("onBottom", "On Bottom", "index", "", 0, 1, List.of("ONBTM")), + + // ---- drilling mechanics ---- + new RoleDef("rop", "ROP", "mechanics", "ft/hr", 0, 300, List.of("ROP", "IROP", "OBR", "OROP")), + new RoleDef("wob", "WOB", "mechanics", "klbs", 0, 80, List.of("WOB", "ADWOB")), + new RoleDef("rpm", "Rotary RPM", "mechanics", "RPM", 0, 250, List.of("RPM", "TDROT")), + new RoleDef("bitRpm", "Bit RPM", "mechanics", "RPM", 0, 300, List.of("BR", "MTRPM")), + new RoleDef("torque", "Torque", "mechanics", "kft-lbf", 0, 50, List.of("TDTOR", "TOR", "BITOR")), + new RoleDef("mse", "MSE", "mechanics", "kpsi", 0, 60, List.of("MSED")), + new RoleDef("hookload", "Hook Load", "mechanics", "klbs", 0, 500, List.of("HL", "CSW", "STRWT")), + new RoleDef("blockHeight", "Block Height", "mechanics", "ft", 0, 140, List.of("BHT", "ADBLP")), + new RoleDef("diffPress", "Diff Press", "mechanics", "psi", 0, 2000, List.of("DIFP")), + new RoleDef("doc", "Depth of Cut", "mechanics", "in", 0, 1, List.of("DOC")), + new RoleDef("overpull", "Over Pull", "mechanics", "klbs", 0, 100, List.of("OVRP")), + + // ---- hydraulics / well control ---- + new RoleDef("spp", "Standpipe Press", "hydraulics", "psi", 0, 5000, List.of("SPP", "UFSPP")), + new RoleDef("flow", "Flow", "hydraulics", "%", 0, 100, List.of("FLOW", "FEST")), + new RoleDef("spm1", "Pump 1 SPM", "hydraulics", "SPM", 0, 150, List.of("SPM1")), + new RoleDef("spm2", "Pump 2 SPM", "hydraulics", "SPM", 0, 150, List.of("SPM2")), + new RoleDef("spmTotal", "Total SPM", "hydraulics", "SPM", 0, 400, List.of("SKTtl")), + new RoleDef("pumpOutput", "Pump Output", "hydraulics", "gpm", 0, 1200, List.of("TPO")), + new RoleDef("casingPress", "Casing Press", "hydraulics", "psi", 0, 3000, List.of("PCAS")), + new RoleDef("mudVolume", "Total Mud Vol", "hydraulics", "bbl", 0, 1500, List.of("MV", "SIMUD")), + new RoleDef("gainLoss", "Pit Gain/Loss", "hydraulics", "bbl", -50, 50, List.of("VTGL", "GLA1")), + new RoleDef("tripTank", "Trip Tank", "hydraulics", "bbl", 0, 200, List.of("MVTT", "MVTT1", "TTACC")), + + // ---- mud gas / formation ---- + new RoleDef("totalGas", "Total Gas", "gas", "%", 0, 100, List.of("PGAS", "3GAS", "WGASP")), + new RoleDef("c1", "C1 Methane", "gas", "ppm", 0, 50000, List.of("C1M")), + new RoleDef("c2", "C2 Ethane", "gas", "ppm", 0, 10000, List.of("C2M")), + new RoleDef("c3", "C3 Propane", "gas", "ppm", 0, 5000, List.of("C3M")), + new RoleDef("ic4", "iC4", "gas", "ppm", 0, 2000, List.of("IC4")), + new RoleDef("nc4", "nC4", "gas", "ppm", 0, 2000, List.of("NC4")), + new RoleDef("ic5", "iC5", "gas", "ppm", 0, 1000, List.of("IC5")), + new RoleDef("nc5", "nC5", "gas", "ppm", 0, 1000, List.of("NC5")), + new RoleDef("gamma", "Gamma", "gas", "gAPI", 0, 150, List.of("GAM", "GAMB")), + new RoleDef("h2s", "H2S", "gas", "ppm", 0, 100, List.of("H2S")), + + // ---- directional & drilling dynamics ---- + new RoleDef("incl", "Inclination", "directional", "deg", 0, 110, List.of("INCL", "DYNIN")), + new RoleDef("azi", "Azimuth", "directional", "deg", 0, 360, List.of("AZ", "DYNAZ")), + new RoleDef("toolface", "Tool Face", "directional", "deg", 0, 360, List.of("TF", "GTF", "MTF", "ATFAV")), + new RoleDef("stickSlip", "Stick-Slip", "directional", "%", 0, 100, List.of("SSSI", "DTSEA")), + new RoleDef("vibeAxial", "Axial Vibe", "directional", "g", 0, 10, List.of("DAVAM")), + new RoleDef("vibeLateral", "Lateral Vibe", "directional", "g", 0, 10, List.of("DAVLM")), + new RoleDef("vibeHfto", "HFTO Vibe", "directional", "g", 0, 10, List.of("DAVHM")), + new RoleDef("slideRotate", "Slide/Rotate", "directional", "", 0, 1, List.of("ASR")) + ); + + /** Physical default [min,max] display scale for a role key (0,0 if unknown / index role). */ + public static double[] defaultScale(String key) { + for (RoleDef r : ROLES) if (r.key().equals(key)) return new double[]{ r.defMin(), r.defMax() }; + return new double[]{ 0, 0 }; + } + + private ChannelRoles() {} + + /** Resolve every role against the file's curves; missing roles are simply omitted. */ + public static Map resolve(List curves) { + // index curves by upper mnemonic + Map byMnem = new LinkedHashMap<>(); + for (Curve c : curves) byMnem.put(c.mnemonic().toUpperCase(Locale.ROOT), c); + + Map out = new LinkedHashMap<>(); + for (RoleDef r : ROLES) { + Curve hit = null; + for (String alias : r.aliases()) { + Curve c = byMnem.get(alias.toUpperCase(Locale.ROOT)); + if (c != null) { hit = c; break; } + } + if (hit == null) hit = byDescription(curves, r); + if (hit != null) { + out.put(r.key(), new ResolvedRole(r.key(), r.label(), r.group(), + hit.mnemonic(), hit.unit(), hit.description(), hit.column())); + } + } + return out; + } + + private static Curve byDescription(List curves, RoleDef r) { + // very light keyword fallback derived from the role label + String kw = r.label().toLowerCase(Locale.ROOT); + for (Curve c : curves) { + String d = c.description() == null ? "" : c.description().toLowerCase(Locale.ROOT); + if (!d.isEmpty() && d.contains(kw)) return c; + } + return null; + } + + /** Distinct data columns that a set of resolved roles needs (sorted ascending). */ + public static int[] neededColumns(Map roles, int... extra) { + java.util.TreeSet set = new java.util.TreeSet<>(); + for (ResolvedRole r : roles.values()) if (r.column() >= 0) set.add(r.column()); + for (int e : extra) if (e >= 0) set.add(e); + int[] cols = new int[set.size()]; + int i = 0; + for (int v : set) cols[i++] = v; + return cols; + } + + /** Default track layout (ordered) used by the UI as the starting point. */ + public static List> defaultTracks() { + List> t = new ArrayList<>(); + t.add(List.of("gamma")); + t.add(List.of("rop")); + t.add(List.of("wob", "rpm")); + t.add(List.of("torque", "mse")); + t.add(List.of("spp", "flow")); + t.add(List.of("spmTotal", "pumpOutput")); + t.add(List.of("totalGas", "c1", "c2", "c3")); + t.add(List.of("gainLoss", "tripTank")); + t.add(List.of("incl", "azi")); + t.add(List.of("toolface")); + t.add(List.of("stickSlip", "vibeLateral", "vibeAxial")); + return t; + } +} diff --git a/src/main/java/com/oiusa/las/service/CurveDataService.java b/src/main/java/com/oiusa/las/service/CurveDataService.java new file mode 100644 index 0000000..496faa6 --- /dev/null +++ b/src/main/java/com/oiusa/las/service/CurveDataService.java @@ -0,0 +1,283 @@ +package com.oiusa.las.service; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.channels.Channels; +import java.nio.channels.FileChannel; +import java.nio.charset.StandardCharsets; +import java.nio.file.StandardOpenOption; +import java.util.ArrayList; +import java.util.List; +import java.util.TreeSet; + +import com.oiusa.las.index.Pyramid; +import com.oiusa.las.index.RowParser; +import com.oiusa.las.model.LasFile; + +import jakarta.enterprise.context.ApplicationScoped; + +/** + * Serves decimated curve data for the log-plot view. When the requested index range spans many base + * buckets it aggregates the in-memory {@link Pyramid} (min/max per output point — spikes preserved); + * when zoomed in past base resolution it reads the actual rows for that small window and decimates + * them live. Either way the payload is bounded to the requested pixel width. + */ +@ApplicationScoped +public class CurveDataService { + + private static final int MAX_DETAIL_ROWS = 400_000; + private static final double DEFAULT_NULL = -999.25; + + public record ReqCurve(String key, String mnemonic, String unit, int column) {} + + public record Series(String key, String mnemonic, String unit, int column, + double[] min, double[] max, double dataMin, double dataMax) {} + + public record CurveData(String axis, boolean detail, double from, double to, int n, + double[] pos, List curves) {} + + public record CrossData(double[] x, double[] y, double[] c, + double[] xRange, double[] yRange, double[] cRange, + int total, int returned, boolean onBottomFiltered) {} + + /** + * Bucket-mean scatter of one channel vs another (e.g. WOB vs ROP), each point colored by a third + * value (depth/time/channel). Optionally restricted to on-bottom buckets — the standard filter for + * a drilling-optimization / founder-point plot. + */ + public CrossData crossplot(LasFile f, int xCol, int yCol, Integer colorCol, + boolean colorDepth, boolean colorTime, boolean onBottomOnly, int max) { + Pyramid pyr = f.pyramid; + max = Math.max(100, Math.min(20000, max)); + if (pyr == null || !pyr.ready() || pyr.bucketCount() == 0) { + return new CrossData(new double[0], new double[0], new double[0], + new double[]{0, 1}, new double[]{0, 1}, new double[]{0, 1}, 0, 0, false); + } + int xs = pyr.slotOfColumn(xCol), ys = pyr.slotOfColumn(yCol); + if (xs < 0 || ys < 0) { + return new CrossData(new double[0], new double[0], new double[0], + new double[]{0, 1}, new double[]{0, 1}, new double[]{0, 1}, 0, 0, false); + } + int cs = colorCol != null ? pyr.slotOfColumn(colorCol) : -1; + double[] depthA = pyr.axisArray(true), timeA = pyr.axisArray(false); + boolean ob = onBottomOnly && f.onBottomCol >= 0; + int B = pyr.bucketCount(); + + List pts = new ArrayList<>(); + for (int b = 0; b < B; b++) { + if (ob && pyr.onBottom(b) < 0.5) continue; + double x = pyr.meanAt(b, xs), y = pyr.meanAt(b, ys); + if (Float.isNaN((float) x) || Float.isNaN((float) y)) continue; + double c = colorDepth ? depthA[b] : colorTime ? timeA[b] : (cs >= 0 ? pyr.meanAt(b, cs) : depthA[b]); + pts.add(new double[]{x, y, c}); + } + + int total = pts.size(); + // robust axis/color ranges (1st–99th pct) so a single garbage spike can't blow up the scatter + double[] xRange = pctRange(pts, 0, 0.01, 0.99); + double[] yRange = pctRange(pts, 1, 0.01, 0.99); + double[] cRange = pctRange(pts, 2, 0.02, 0.98); + + int stride = total > max ? (int) Math.ceil((double) total / max) : 1; + int n = (total + stride - 1) / stride; + double[] x = new double[n], y = new double[n], c = new double[n]; + int k = 0; + for (int i = 0; i < total && k < n; i += stride) { + double[] p = pts.get(i); + x[k] = p[0]; y[k] = p[1]; c[k] = p[2]; k++; + } + return new CrossData(x, y, c, xRange, yRange, cRange, total, k, ob); + } + + private static double[] pctRange(List pts, int idx, double lo, double hi) { + double[] a = new double[pts.size()]; + int m = 0; + for (double[] p : pts) if (!Double.isNaN(p[idx])) a[m++] = p[idx]; + if (m == 0) return new double[]{0, 1}; + double[] b = java.util.Arrays.copyOf(a, m); + java.util.Arrays.sort(b); + double mn = b[(int) Math.floor(lo * (m - 1))]; + double mx = b[(int) Math.ceil(hi * (m - 1))]; + if (mx <= mn) mx = mn + 1; + return new double[]{mn, mx}; + } + + public CurveData compute(LasFile f, boolean depthAxis, List req, + double fromReq, double toReq, int width) { + Pyramid pyr = f.pyramid; + width = Math.max(10, Math.min(4000, width)); + String axisName = depthAxis ? "depth" : "time"; + if (pyr == null || !pyr.ready() || pyr.bucketCount() == 0 || req.isEmpty()) { + return new CurveData(axisName, false, 0, 0, 0, new double[0], List.of()); + } + + double[] axis = pyr.axisArray(depthAxis); + int B = pyr.bucketCount(); + double aMin = pyr.axisMin(depthAxis); + double aMax = pyr.axisMax(depthAxis); + double from = Double.isNaN(fromReq) ? aMin : Math.max(aMin, fromReq); + double to = Double.isNaN(toReq) ? aMax : Math.min(aMax, toReq); + if (to <= from) { from = aMin; to = aMax; } + + int b0 = lowerBound(axis, from); + int b1 = upperBound(axis, to); + if (b1 < b0) b1 = b0; + int bucketsInRange = b1 - b0 + 1; + + // Zoomed in past base resolution → read raw rows for crisp detail. + CurveData r = null; + if (bucketsInRange < width) { + r = detail(f, pyr, depthAxis, req, from, to, width, b0, b1, B); + } + if (r == null) { + r = overview(pyr, depthAxis, req, axis, from, to, width, b0, b1); + } + return new CurveData(axisName, r.detail(), r.from(), r.to(), r.n(), r.pos(), r.curves()); + } + + /* ---------------- overview: aggregate base buckets ---------------- */ + private CurveData overview(Pyramid pyr, boolean depthAxis, List req, double[] axis, + double from, double to, int width, int b0, int b1) { + int bucketsInRange = b1 - b0 + 1; + int n = Math.min(width, bucketsInRange); + double[] pos = new double[n]; + + int c = req.size(); + double[][] mn = new double[c][n]; + double[][] mx = new double[c][n]; + int[] slot = new int[c]; + double[] dMin = new double[c]; + double[] dMax = new double[c]; + for (int k = 0; k < c; k++) { + slot[k] = pyr.slotOfColumn(req.get(k).column()); + dMin[k] = Double.POSITIVE_INFINITY; dMax[k] = Double.NEGATIVE_INFINITY; + } + + for (int j = 0; j < n; j++) { + int bs = b0 + (int) ((long) j * bucketsInRange / n); + int be = b0 + (int) ((long) (j + 1) * bucketsInRange / n); + if (be <= bs) be = bs + 1; + pos[j] = axis[bs]; + for (int k = 0; k < c; k++) { + int s = slot[k]; + double lo = Double.POSITIVE_INFINITY, hi = Double.NEGATIVE_INFINITY; + if (s >= 0) { + for (int b = bs; b < be; b++) { + float vmn = pyr.minAt(b, s), vmx = pyr.maxAt(b, s); + if (!Float.isNaN(vmn) && vmn < lo) lo = vmn; + if (!Float.isNaN(vmx) && vmx > hi) hi = vmx; + } + } + if (lo == Double.POSITIVE_INFINITY) { mn[k][j] = Double.NaN; mx[k][j] = Double.NaN; } + else { + mn[k][j] = lo; mx[k][j] = hi; + if (lo < dMin[k]) dMin[k] = lo; + if (hi > dMax[k]) dMax[k] = hi; + } + } + } + return assemble("", false, from, to, n, pos, req, mn, mx, dMin, dMax); + } + + /* ---------------- detail: read raw rows in a small window ---------------- */ + private CurveData detail(LasFile f, Pyramid pyr, boolean depthAxis, List req, + double from, double to, int width, int b0, int b1, int B) { + long lineStart = pyr.startLine(b0); + long lineEnd = (b1 + 1 < B) ? pyr.startLine(b1 + 1) : f.index.totalLines(); + long rows = lineEnd - lineStart; + if (rows <= 0 || rows > MAX_DETAIL_ROWS) return null; + + int axisCol = depthAxis ? (f.holeDepthCol >= 0 ? f.holeDepthCol : f.bitDepthCol) : f.timeCol; + if (axisCol < 0) return null; + + // build combined needed columns: axis + requested + TreeSet set = new TreeSet<>(); + set.add(axisCol); + for (ReqCurve r : req) set.add(r.column()); + int[] cols = set.stream().mapToInt(Integer::intValue).toArray(); + int axisSlot = indexOf(cols, axisCol); + int[] reqSlot = new int[req.size()]; + for (int k = 0; k < req.size(); k++) reqSlot[k] = indexOf(cols, req.get(k).column()); + + int n = width; + double binW = (to - from) / n; + if (binW <= 0) return null; + int c = req.size(); + double[][] mn = new double[c][n]; + double[][] mx = new double[c][n]; + double[] pos = new double[n]; + for (int j = 0; j < n; j++) { pos[j] = from + (j + 0.5) * binW; for (int k = 0; k < c; k++) { mn[k][j] = Double.NaN; mx[k][j] = Double.NaN; } } + double[] dMin = new double[c], dMax = new double[c]; + for (int k = 0; k < c; k++) { dMin[k] = Double.POSITIVE_INFINITY; dMax[k] = Double.NEGATIVE_INFINITY; } + + double nullVal = parseNull(f.nullValue); + double[] vals = new double[cols.length]; + try (FileChannel ch = FileChannel.open(f.path, StandardOpenOption.READ)) { + ch.position(f.index.offsetForLine(lineStart)); + BufferedReader rdr = new BufferedReader( + new InputStreamReader(Channels.newInputStream(ch), StandardCharsets.ISO_8859_1), 1 << 20); + long skip = lineStart - f.index.checkpointLine(lineStart); + for (long s = 0; s < skip; s++) if (rdr.readLine() == null) break; + + double runMaxDepth = Double.NEGATIVE_INFINITY; + for (long rrow = 0; rrow < rows; rrow++) { + String ln = rdr.readLine(); + if (ln == null) break; + RowParser.extract(ln, cols, nullVal, vals); + double a = vals[axisSlot]; + if (depthAxis) { // plot vs drilled depth (running max), like the overview + runMaxDepth = Pyramid.advanceDepth(runMaxDepth, a); + a = runMaxDepth == Double.NEGATIVE_INFINITY ? Double.NaN : runMaxDepth; + } + if (Double.isNaN(a)) continue; + int j = (int) ((a - from) / binW); + if (j < 0 || j >= n) continue; + for (int k = 0; k < c; k++) { + double v = vals[reqSlot[k]]; + if (Double.isNaN(v)) continue; + if (Double.isNaN(mn[k][j]) || v < mn[k][j]) mn[k][j] = v; + if (Double.isNaN(mx[k][j]) || v > mx[k][j]) mx[k][j] = v; + if (v < dMin[k]) dMin[k] = v; + if (v > dMax[k]) dMax[k] = v; + } + } + } catch (IOException e) { + return null; + } + return assemble("", true, from, to, n, pos, req, mn, mx, dMin, dMax); + } + + private CurveData assemble(String axisName, boolean detail, double from, double to, int n, double[] pos, + List req, double[][] mn, double[][] mx, double[] dMin, double[] dMax) { + List series = new ArrayList<>(req.size()); + for (int k = 0; k < req.size(); k++) { + ReqCurve r = req.get(k); + double dm = dMin[k] == Double.POSITIVE_INFINITY ? Double.NaN : dMin[k]; + double dx = dMax[k] == Double.NEGATIVE_INFINITY ? Double.NaN : dMax[k]; + series.add(new Series(r.key(), r.mnemonic(), r.unit(), r.column(), mn[k], mx[k], dm, dx)); + } + return new CurveData(axisName, detail, from, to, n, pos, series); + } + + /* first bucket with axis[b] >= v */ + private static int lowerBound(double[] axis, double v) { + for (int i = 0; i < axis.length; i++) if (!Double.isNaN(axis[i]) && axis[i] >= v) return i; + return axis.length - 1; + } + /* last bucket with axis[b] <= v */ + private static int upperBound(double[] axis, double v) { + for (int i = axis.length - 1; i >= 0; i--) if (!Double.isNaN(axis[i]) && axis[i] <= v) return i; + return 0; + } + + private static int indexOf(int[] a, int v) { + for (int i = 0; i < a.length; i++) if (a[i] == v) return i; + return -1; + } + + private static double parseNull(String nullValue) { + if (nullValue == null || nullValue.isBlank()) return DEFAULT_NULL; + try { return Double.parseDouble(nullValue.trim()); } catch (NumberFormatException e) { return DEFAULT_NULL; } + } +} diff --git a/src/main/java/com/oiusa/las/service/FileStore.java b/src/main/java/com/oiusa/las/service/FileStore.java new file mode 100644 index 0000000..1fae5f9 --- /dev/null +++ b/src/main/java/com/oiusa/las/service/FileStore.java @@ -0,0 +1,128 @@ +package com.oiusa.las.service; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.jboss.logging.Logger; + +import com.oiusa.las.model.LasFile; + +import jakarta.annotation.PostConstruct; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; + +/** + * In-memory registry of {@link LasFile}s plus filesystem policy: where uploads live and which + * roots a local file may be opened from. Opening a local file registers it in place + * (no copy) — essential for the multi-gigabyte logs that already sit on disk. + */ +@ApplicationScoped +public class FileStore { + + private static final Logger LOG = Logger.getLogger(FileStore.class); + + @Inject + IndexService indexService; + + @ConfigProperty(name = "las.data-dir") + String dataDirConfig; + + @ConfigProperty(name = "las.allowed-roots") + String allowedRootsConfig; + + @ConfigProperty(name = "las.index-stride", defaultValue = "256") + int stride; + + @ConfigProperty(name = "las.upload-chunk-size", defaultValue = "16777216") + long uploadChunkSize; + + private final ConcurrentHashMap files = new ConcurrentHashMap<>(); + private Path dataDir; + private Path uploadsDir; + private List allowedRoots; + + @PostConstruct + void init() throws IOException { + dataDir = Path.of(dataDirConfig).toAbsolutePath().normalize(); + uploadsDir = dataDir.resolve("uploads"); + Files.createDirectories(uploadsDir); + allowedRoots = new ArrayList<>(); + for (String r : allowedRootsConfig.split(",")) { + String t = r.trim(); + if (!t.isEmpty()) allowedRoots.add(Path.of(t).toAbsolutePath().normalize()); + } + // Uploaded files always live under our data dir, so allow it too. + allowedRoots.add(dataDir); + LOG.infof("Data dir: %s | allowed roots: %s", dataDir, allowedRoots); + } + + public int stride() { return stride; } + public long uploadChunkSize() { return uploadChunkSize; } + public Path uploadsDir() { return uploadsDir; } + + public List allowedRoots() { + List out = new ArrayList<>(); + for (Path p : allowedRoots) out.add(p.toString()); + return out; + } + + /** Validates that {@code candidate} resolves under an allowed root; throws otherwise. */ + public Path requireAllowed(Path candidate) { + Path norm = candidate.toAbsolutePath().normalize(); + for (Path root : allowedRoots) { + if (norm.startsWith(root)) return norm; + } + throw new SecurityException("Path is outside the allowed roots: " + norm); + } + + public Collection all() { + return files.values(); + } + + public LasFile get(String id) { + return files.get(id); + } + + public LasFile require(String id) { + LasFile f = files.get(id); + if (f == null) throw new IllegalArgumentException("No such file: " + id); + return f; + } + + /** Register a file already on disk, in place, and start indexing it. */ + public LasFile registerLocal(String pathStr) throws IOException { + Path path = requireAllowed(Path.of(pathStr)); + if (!Files.isRegularFile(path)) throw new IOException("Not a regular file: " + path); + long size = Files.size(path); + LasFile f = new LasFile(UUID.randomUUID().toString(), path.getFileName().toString(), path, size, false, stride); + files.put(f.id, f); + indexService.index(f); + LOG.infof("Registered local file %s (%,d bytes) as %s", path, size, f.id); + return f; + } + + /** Register a freshly-uploaded file (already moved into the uploads dir) and start indexing. */ + public LasFile registerUploaded(String name, Path path) throws IOException { + long size = Files.size(path); + LasFile f = new LasFile(UUID.randomUUID().toString(), name, path, size, true, stride); + files.put(f.id, f); + indexService.index(f); + LOG.infof("Registered uploaded file %s (%,d bytes) as %s", name, size, f.id); + return f; + } + + /** Forget a file. Deletes bytes only if we own them (uploaded); never touches in-place originals. */ + public void remove(String id) throws IOException { + LasFile f = files.remove(id); + if (f != null && f.uploaded) { + Files.deleteIfExists(f.path); + } + } +} diff --git a/src/main/java/com/oiusa/las/service/IndexService.java b/src/main/java/com/oiusa/las/service/IndexService.java new file mode 100644 index 0000000..264ce91 --- /dev/null +++ b/src/main/java/com/oiusa/las/service/IndexService.java @@ -0,0 +1,231 @@ +package com.oiusa.las.service; + +import java.io.BufferedInputStream; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import org.eclipse.microprofile.context.ManagedExecutor; +import org.jboss.logging.Logger; + +import com.oiusa.las.index.Pyramid; +import com.oiusa.las.index.RowParser; +import com.oiusa.las.model.LasFile; +import com.oiusa.las.model.ResolvedRole; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; + +/** + * Builds a {@link LasFile}'s line index AND its drilling-curve overview ({@link Pyramid}) in one + * streaming pass: counts lines, records a sparse byte-offset checkpoint every {@code stride} lines, + * parses the header up to {@code ~A}, then for every data row extracts the needed channel columns and + * folds them into min/max base buckets. Memory stays bounded by the 1 MiB read buffer plus the tiny + * sparse index and the (tens-of-MB) pyramid — so 12+ GB files index fine. + */ +@ApplicationScoped +public class IndexService { + + private static final Logger LOG = Logger.getLogger(IndexService.class); + + private static final int READ_BUF = 1 << 20; // 1 MiB scan buffer + private static final int MAX_HEADER_LINES = 200_000; // safety cap if a file has no ~A marker + private static final long MAX_HEADER_BYTES = 32L << 20; + private static final int MAX_LINE_BYTES = 8 << 20; // never buffer a single line bigger than 8 MiB + private static final int BUCKET_ROWS = 32; // base pyramid resolution (rows per bucket) + private static final double DEFAULT_NULL = -999.25; + + @Inject + ManagedExecutor executor; + + public void index(LasFile f) { + executor.execute(() -> { + try { + run(f); + } catch (Throwable t) { + f.status = LasFile.Status.ERROR; + f.error = t.getMessage() == null ? t.toString() : t.getMessage(); + LOG.errorf(t, "Indexing failed for %s", f.path); + } + }); + } + + private void run(LasFile f) throws Exception { + f.status = LasFile.Status.INDEXING; + f.index.addCheckpoint(0); + + final int stride = f.index.stride(); + byte[] buf = new byte[READ_BUF]; + + long readBase = 0; + long line = 0; + boolean sawByte = false; + + boolean inHeader = true; + long headerBytes = 0; + List headerLines = new ArrayList<>(); + + // reusable per-line accumulator (excludes \n and \r) + byte[] lineBytes = new byte[16 << 10]; + int lineLen = 0; + + // data-row parsing state, captured once the header ends + Pyramid pyr = null; + int[] cols = null; + double nullVal = DEFAULT_NULL; + double[] slotVals = null; + RowParser.ByteCharSeq seq = new RowParser.ByteCharSeq(); + + try (InputStream in = new BufferedInputStream(Files.newInputStream(f.path), READ_BUF)) { + int r; + while ((r = in.read(buf)) > 0) { + for (int i = 0; i < r; i++) { + byte b = buf[i]; + if (b == '\n') { + if (inHeader) { + String text = new String(lineBytes, 0, lineLen, StandardCharsets.ISO_8859_1); + boolean wasHeader = inHeader; + inHeader = onHeaderLine(f, headerLines, text, line); + headerBytes += text.length() + 1; + if (inHeader && (headerLines.size() >= MAX_HEADER_LINES || headerBytes >= MAX_HEADER_BYTES)) { + finishHeader(f, headerLines, -1); + inHeader = false; + } + if (wasHeader && !inHeader) { // header just ended: latch pyramid state + pyr = f.pyramid; + if (pyr != null) { cols = pyr.columns; slotVals = new double[cols.length]; } + nullVal = parseNull(f.nullValue); + } + } else if (pyr != null) { + seq.set(lineBytes, lineLen); + RowParser.extract(seq, cols, nullVal, slotVals); + pyr.addRow(slotVals, line); + } + line++; + long nextLineStart = readBase + i + 1; + if (line % stride == 0) f.index.addCheckpoint(nextLineStart); + if ((line & 0x3FFF) == 0) { f.indexedLines = line; f.indexedBytes = nextLineStart; } + lineLen = 0; + sawByte = false; + } else if (b != '\r') { + sawByte = true; + if (lineLen < MAX_LINE_BYTES) { + if (lineLen == lineBytes.length) { + lineBytes = java.util.Arrays.copyOf(lineBytes, lineBytes.length * 2); + } + lineBytes[lineLen++] = b; + } + } + } + readBase += r; + f.indexedBytes = readBase; + } + } + + // trailing line with no terminating newline + if (sawByte) { + if (inHeader) { + String text = new String(lineBytes, 0, lineLen, StandardCharsets.ISO_8859_1); + onHeaderLine(f, headerLines, text, line); + } else if (pyr != null) { + seq.set(lineBytes, lineLen); + RowParser.extract(seq, cols, nullVal, slotVals); + pyr.addRow(slotVals, line); + } + line++; + } + if (inHeader) finishHeader(f, headerLines, -1); + if (pyr != null) pyr.finish(); + + f.index.setTotalLines(line); + f.indexedLines = line; + f.indexedBytes = f.sizeBytes; + f.status = LasFile.Status.READY; + LOG.infof("Indexed %s: %,d lines, %,d checkpoints, dataStart=%d, roles=%d, pyramidBuckets=%,d", + f.name, line, f.index.checkpointCount(), f.dataStartLine, + f.roles.size(), pyr != null ? pyr.bucketCount() : 0); + } + + private boolean onHeaderLine(LasFile f, List headerLines, String text, long lineNo) { + headerLines.add(text); + String trimmed = text.stripLeading(); + if (trimmed.length() >= 2 && trimmed.charAt(0) == '~' + && (trimmed.charAt(1) == 'A' || trimmed.charAt(1) == 'a')) { + finishHeader(f, headerLines, lineNo + 1); + return false; + } + return true; + } + + private void finishHeader(LasFile f, List headerLines, long dataStartLine) { + List dataColumns = List.of(); + List sectionLines = headerLines; + if (dataStartLine >= 0 && !headerLines.isEmpty()) { + String aLine = headerLines.get(headerLines.size() - 1); + dataColumns = parseDataColumns(aLine); + sectionLines = headerLines.subList(0, headerLines.size() - 1); + } + LasHeaderParser.Result res = LasHeaderParser.parse(sectionLines, dataColumns); + f.sections = res.sections(); + f.curves = res.curves(); + f.dataColumns = dataColumns; + f.wrap = res.wrap(); + f.nullValue = res.nullValue(); + f.wellName = res.wellName(); + f.dataStartLine = dataStartLine; + f.headerReady = true; + if (dataStartLine >= 0) setupPyramid(f); + } + + /** Resolve drilling roles and allocate the pyramid over the needed columns. */ + private void setupPyramid(LasFile f) { + Map roles = ChannelRoles.resolve(f.curves); + f.roles = roles; + int timeCol = colOf(roles, "time"); + int holeDepthCol = colOf(roles, "holeDepth"); + int bitDepthCol = colOf(roles, "bitDepth"); + int onBottomCol = colOf(roles, "onBottom"); + f.timeCol = timeCol; + f.holeDepthCol = holeDepthCol; + f.bitDepthCol = bitDepthCol; + f.onBottomCol = onBottomCol; + f.hasTimeAxis = timeCol >= 0; + f.hasDepthAxis = holeDepthCol >= 0 || bitDepthCol >= 0; + + int[] cols = ChannelRoles.neededColumns(roles, timeCol, holeDepthCol, bitDepthCol, onBottomCol); + if (cols.length == 0) { f.pyramid = null; return; } + int timeSlot = slotOf(cols, timeCol); + int depthSlot = slotOf(cols, holeDepthCol >= 0 ? holeDepthCol : bitDepthCol); + int onBottomSlot = slotOf(cols, onBottomCol); + f.pyramid = new Pyramid(BUCKET_ROWS, cols, timeSlot, depthSlot, onBottomSlot); + } + + private static int colOf(Map roles, String key) { + ResolvedRole r = roles.get(key); + return r == null ? -1 : r.column(); + } + + private static int slotOf(int[] cols, int col) { + if (col < 0) return -1; + for (int i = 0; i < cols.length; i++) if (cols[i] == col) return i; + return -1; + } + + private static double parseNull(String nullValue) { + if (nullValue == null || nullValue.isBlank()) return DEFAULT_NULL; + try { return Double.parseDouble(nullValue.trim()); } catch (NumberFormatException e) { return DEFAULT_NULL; } + } + + private static List parseDataColumns(String aLine) { + String[] tok = aLine.trim().split("\\s+"); + List cols = new ArrayList<>(tok.length); + for (int i = 0; i < tok.length; i++) { + if (i == 0 && tok[i].startsWith("~")) continue; + if (!tok[i].isEmpty()) cols.add(tok[i]); + } + return cols; + } +} diff --git a/src/main/java/com/oiusa/las/service/LasHeaderParser.java b/src/main/java/com/oiusa/las/service/LasHeaderParser.java new file mode 100644 index 0000000..51cecba --- /dev/null +++ b/src/main/java/com/oiusa/las/service/LasHeaderParser.java @@ -0,0 +1,94 @@ +package com.oiusa.las.service; + +import java.util.ArrayList; +import java.util.List; + +import com.oiusa.las.model.Curve; +import com.oiusa.las.model.HeaderSection; + +/** + * Parses the header lines collected before the {@code ~A} data marker into structured + * {@link HeaderSection}s and {@link Curve}s. LAS 2.0 metadata lines look like: + *

+ *   MNEM.UNIT            DATA / API CODE : DESCRIPTION
+ *   WELL.               LUSCOMBRE 9H     : Well
+ *   TIME   .seconds                      :   1  Time Logged
+ * 
+ * Comment lines start with '#'; section headers start with '~'. + */ +public final class LasHeaderParser { + + public record Result(List sections, List curves, + String wrap, String nullValue, String wellName) {} + + private LasHeaderParser() {} + + /** Parses a single metadata line into [mnemonic, unit, data, description]; nulls if it isn't one. */ + public static String[] splitLine(String raw) { + String line = raw; + if (line.isEmpty() || line.charAt(0) == '~' || line.charAt(0) == '#') return null; + int colon = line.indexOf(':'); + int dot = line.indexOf('.'); + if (dot < 0) return null; + String mnem; + String unit = ""; + String data = ""; + String desc = ""; + // unit runs from just after the first '.' up to the next whitespace + int u = dot + 1; + int unitEnd = u; + while (unitEnd < line.length() && !Character.isWhitespace(line.charAt(unitEnd))) unitEnd++; + mnem = line.substring(0, dot).trim(); + unit = line.substring(u, unitEnd).trim(); + if (colon >= 0 && colon >= unitEnd) { + data = line.substring(unitEnd, colon).trim(); + desc = line.substring(colon + 1).trim(); + } else if (colon >= 0) { + desc = line.substring(colon + 1).trim(); + } else { + data = line.substring(unitEnd).trim(); + } + if (mnem.isEmpty()) return null; + return new String[]{mnem, unit, data, desc}; + } + + public static Result parse(List headerLines, List dataColumns) { + List sections = new ArrayList<>(); + List curves = new ArrayList<>(); + String wrap = null, nullValue = null, wellName = null; + + String currentName = null; + List currentLines = new ArrayList<>(); + boolean inCurves = false; + int curveCol = 0; + + for (String line : headerLines) { + if (!line.isEmpty() && line.charAt(0) == '~') { + if (currentName != null) sections.add(new HeaderSection(currentName, currentLines)); + currentName = line.substring(1).trim(); + currentLines = new ArrayList<>(); + String upper = currentName.toUpperCase(); + inCurves = upper.startsWith("CURVE"); + continue; + } + if (currentName == null) continue; // pre-section comment banner (e.g. "#Pason DataHub") + currentLines.add(line); + + String[] f = splitLine(line); + if (f == null) continue; + if (inCurves) { + curves.add(new Curve(curveCol++, f[0], f[1], f[2], f[3])); + } else { + switch (f[0].toUpperCase()) { + case "WRAP" -> wrap = f[2].isEmpty() ? f[3] : f[2]; + case "NULL" -> nullValue = f[2].isEmpty() ? f[3] : f[2]; + case "WELL" -> wellName = f[2].isEmpty() ? f[3] : f[2]; + default -> { /* keep raw only */ } + } + } + } + if (currentName != null) sections.add(new HeaderSection(currentName, currentLines)); + + return new Result(sections, curves, wrap, nullValue, wellName); + } +} diff --git a/src/main/java/com/oiusa/las/service/UploadService.java b/src/main/java/com/oiusa/las/service/UploadService.java new file mode 100644 index 0000000..12e9107 --- /dev/null +++ b/src/main/java/com/oiusa/las/service/UploadService.java @@ -0,0 +1,111 @@ +package com.oiusa.las.service; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +import org.jboss.logging.Logger; + +import com.oiusa.las.model.LasFile; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; + +/** + * Handles resumable chunked uploads. The browser slices a file and PUTs each chunk at a byte + * offset; chunks are written straight to a {@code .part} file via a positioned {@link FileChannel} + * (never buffering the whole file). {@code received} tracks the high-water mark so an interrupted + * upload can resume. On completion the {@code .part} is renamed and handed to {@link FileStore}. + */ +@ApplicationScoped +public class UploadService { + + private static final Logger LOG = Logger.getLogger(UploadService.class); + + @Inject + FileStore store; + + public static final class Session { + public final String id; + public final String name; + public final long size; + public final Path partPath; + public volatile long received; + + Session(String id, String name, long size, Path partPath) { + this.id = id; + this.name = name; + this.size = size; + this.partPath = partPath; + } + } + + private final ConcurrentHashMap sessions = new ConcurrentHashMap<>(); + + public Session init(String name, long size) throws IOException { + String id = UUID.randomUUID().toString(); + String safe = sanitize(name); + Path part = store.uploadsDir().resolve(id + "__" + safe + ".part"); + Files.deleteIfExists(part); + // Pre-create the file. + try (FileChannel ch = FileChannel.open(part, + StandardOpenOption.CREATE, StandardOpenOption.WRITE)) { + // nothing; just create + } + Session s = new Session(id, safe, size, part); + sessions.put(id, s); + LOG.infof("Upload init %s (%s, %,d bytes)", id, safe, size); + return s; + } + + public Session get(String id) { + Session s = sessions.get(id); + if (s == null) throw new IllegalArgumentException("No such upload: " + id); + return s; + } + + /** Write one chunk at {@code offset}; returns the new received high-water mark. */ + public long writeChunk(String id, long offset, InputStream body) throws IOException { + Session s = get(id); + long written = 0; + byte[] buf = new byte[1 << 20]; + try (FileChannel ch = FileChannel.open(s.partPath, StandardOpenOption.WRITE)) { + ch.position(offset); + int r; + while ((r = body.read(buf)) > 0) { + ch.write(ByteBuffer.wrap(buf, 0, r)); + written += r; + } + } + long end = offset + written; + synchronized (s) { + if (end > s.received) s.received = end; + } + return s.received; + } + + /** Finalize: rename the .part to its final name and register it for indexing. */ + public LasFile complete(String id) throws IOException { + Session s = sessions.remove(id); + if (s == null) throw new IllegalArgumentException("No such upload: " + id); + Path finalPath = store.uploadsDir().resolve(s.id + "__" + s.name); + Files.move(s.partPath, finalPath, StandardCopyOption.REPLACE_EXISTING); + return store.registerUploaded(s.name, finalPath); + } + + private static String sanitize(String name) { + if (name == null || name.isBlank()) return "upload.las"; + String base = name.replace('\\', '/'); + int slash = base.lastIndexOf('/'); + if (slash >= 0) base = base.substring(slash + 1); + base = base.replaceAll("[^A-Za-z0-9._ -]", "_").trim(); + return base.isEmpty() ? "upload.las" : base; + } +} diff --git a/src/main/java/com/oiusa/las/web/CurveResource.java b/src/main/java/com/oiusa/las/web/CurveResource.java new file mode 100644 index 0000000..63f1785 --- /dev/null +++ b/src/main/java/com/oiusa/las/web/CurveResource.java @@ -0,0 +1,156 @@ +package com.oiusa.las.web; + +import java.util.ArrayList; +import java.util.List; + +import com.oiusa.las.index.Pyramid; +import com.oiusa.las.model.LasFile; +import com.oiusa.las.model.ResolvedRole; +import com.oiusa.las.service.ChannelRoles; +import com.oiusa.las.service.CurveDataService; +import com.oiusa.las.service.FileStore; + +import io.smallrye.common.annotation.Blocking; +import jakarta.inject.Inject; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; + +/** Roles + decimated curve data that power the drilling log-plot view. */ +@jakarta.ws.rs.Path("/api/files") +@Produces(MediaType.APPLICATION_JSON) +public class CurveResource { + + @Inject + FileStore store; + + @Inject + CurveDataService curveData; + + @GET + @jakarta.ws.rs.Path("/{id}/roles") + public Response roles(@PathParam("id") String id) { + LasFile f = store.get(id); + if (f == null) return Response.status(404).entity(error("No such file: " + id)).build(); + Pyramid pyr = f.pyramid; + boolean ready = pyr != null && pyr.ready(); + + List roles = new ArrayList<>(); + for (ResolvedRole r : f.roles.values()) { + Double dMin = null, dMax = null; + if (ready) { + int slot = pyr.slotOfColumn(r.column()); + if (slot >= 0) { + float gmn = pyr.globalMin(slot), gmx = pyr.globalMax(slot); + if (!Float.isNaN(gmn)) dMin = (double) gmn; + if (!Float.isNaN(gmx)) dMax = (double) gmx; + } + } + double[] sc = ChannelRoles.defaultScale(r.key()); + roles.add(new Dtos.RoleInfo(r.key(), r.label(), r.group(), r.mnemonic(), + r.unit(), r.description(), r.column(), dMin, dMax, sc[0], sc[1])); + } + + // Only advertise an axis once we know its real extent is non-degenerate. A file whose depth + // channel is broken (e.g. stuck/zero) collapses to a single value — don't offer it as an axis. + boolean hasTime = f.hasTimeAxis; + boolean hasDepth = f.hasDepthAxis; + Dtos.AxisExtent timeExt = null, depthExt = null; + if (ready) { + if (f.hasTimeAxis) { + double a = pyr.axisMin(false), b = pyr.axisMax(false); + if (b - a > 0) timeExt = new Dtos.AxisExtent(a, b); else hasTime = false; + } + if (f.hasDepthAxis) { + double a = pyr.axisMin(true), b = pyr.axisMax(true); + if (b - a > 50) depthExt = new Dtos.AxisExtent(a, b); else hasDepth = false; + } + } + + return Response.ok(new Dtos.RolesResponse(ready, hasTime, hasDepth, + timeExt, depthExt, roles, ChannelRoles.defaultTracks())).build(); + } + + @GET + @jakarta.ws.rs.Path("/{id}/curve-data") + @Blocking + public Response curveDataEndpoint(@PathParam("id") String id, + @QueryParam("axis") String axis, + @QueryParam("curves") String curves, + @QueryParam("from") Double from, + @QueryParam("to") Double to, + @QueryParam("width") Integer width) { + LasFile f = store.get(id); + if (f == null) return Response.status(404).entity(error("No such file: " + id)).build(); + boolean depthAxis = "depth".equalsIgnoreCase(axis); + + List req = new ArrayList<>(); + if (curves != null && !curves.isBlank()) { + for (String key : curves.split(",")) { + ResolvedRole r = f.roles.get(key.trim()); + if (r != null) req.add(new CurveDataService.ReqCurve(r.key(), r.mnemonic(), r.unit(), r.column())); + } + } + double fromD = from == null ? Double.NaN : from; + double toD = to == null ? Double.NaN : to; + int w = width == null ? 800 : width; + + CurveDataService.CurveData d = curveData.compute(f, depthAxis, req, fromD, toD, w); + return Response.ok(toDto(d)).build(); + } + + @GET + @jakarta.ws.rs.Path("/{id}/crossplot") + @Blocking + public Response crossplot(@PathParam("id") String id, + @QueryParam("x") String x, + @QueryParam("y") String y, + @QueryParam("color") String color, + @QueryParam("onBottom") @jakarta.ws.rs.DefaultValue("false") boolean onBottom, + @QueryParam("max") Integer max) { + LasFile f = store.get(id); + if (f == null) return Response.status(404).entity(error("No such file: " + id)).build(); + ResolvedRole rx = f.roles.get(x), ry = f.roles.get(y); + if (rx == null || ry == null) return Response.status(400).entity(error("x and y must be resolved roles")).build(); + + boolean colorDepth = "depth".equalsIgnoreCase(color); + boolean colorTime = "time".equalsIgnoreCase(color); + Integer colorCol = null; + if (!colorDepth && !colorTime && color != null) { + ResolvedRole rc = f.roles.get(color); + if (rc != null) colorCol = rc.column(); + } + if (color == null) colorDepth = f.hasDepthAxis; // sensible default + + CurveDataService.CrossData d = curveData.crossplot(f, rx.column(), ry.column(), + colorCol, colorDepth, colorTime, onBottom, max == null ? 5000 : max); + return Response.ok(new Dtos.CrossDataDto(toList(d.x()), toList(d.y()), toList(d.c()), + d.xRange(), d.yRange(), d.cRange(), d.total(), d.returned(), d.onBottomFiltered())).build(); + } + + private static Dtos.CurveDataDto toDto(CurveDataService.CurveData d) { + List curves = new ArrayList<>(); + for (CurveDataService.Series s : d.curves()) { + curves.add(new Dtos.CurveSeriesDto(s.key(), s.mnemonic(), s.unit(), s.column(), + toList(s.min()), toList(s.max()), + nan(s.dataMin()), nan(s.dataMax()))); + } + return new Dtos.CurveDataDto(d.axis(), d.detail(), d.from(), d.to(), d.n(), toList(d.pos()), curves); + } + + /** double[] -> List with NaN mapped to null (so the JSON is valid and gaps are explicit). */ + private static List toList(double[] a) { + List out = new ArrayList<>(a.length); + for (double v : a) out.add(Double.isNaN(v) ? null : v); + return out; + } + + private static Double nan(double v) { return Double.isNaN(v) ? null : v; } + + private static java.util.Map error(String msg) { + return java.util.Map.of("error", msg == null ? "error" : msg); + } +} diff --git a/src/main/java/com/oiusa/las/web/Dtos.java b/src/main/java/com/oiusa/las/web/Dtos.java new file mode 100644 index 0000000..5ff723d --- /dev/null +++ b/src/main/java/com/oiusa/las/web/Dtos.java @@ -0,0 +1,78 @@ +package com.oiusa.las.web; + +import java.util.List; + +import com.oiusa.las.model.Curve; +import com.oiusa.las.model.HeaderSection; +import com.oiusa.las.model.LasFile; + +/** Request/response shapes for the REST API. Records serialize cleanly via Jackson. */ +public final class Dtos { + + private Dtos() {} + + public record FileSummary( + String id, String name, long sizeBytes, String status, String error, + boolean uploaded, boolean headerReady, + long indexedLines, long indexedBytes, long totalLines, long availableLines, + long dataStartLine, int curveCount, String wellName) { + + public static FileSummary of(LasFile f) { + return new FileSummary( + f.id, f.name, f.sizeBytes, f.status.name(), f.error, + f.uploaded, f.headerReady, + f.indexedLines, f.indexedBytes, f.index.totalLines(), f.availableLines(), + f.dataStartLine, f.curves.size(), f.wellName); + } + } + + public record FileMeta( + FileSummary summary, + List sections, + List curves, + List dataColumns, + String wrap, String nullValue) { + + public static FileMeta of(LasFile f) { + return new FileMeta(FileSummary.of(f), f.sections, f.curves, f.dataColumns, f.wrap, f.nullValue); + } + } + + public record LinesResponse(long start, List lines, boolean eof, long availableLines) {} + + public record ConfigResponse(List allowedRoots, String homeDir, + long uploadChunkSize, int indexStride) {} + + public record BrowseEntry(String name, String path, boolean dir, long sizeBytes, boolean looksLikeLas) {} + + public record BrowseResponse(String dir, String parent, List entries) {} + + public record LocalRegisterRequest(String path) {} + + public record UploadInitRequest(String name, long size) {} + + public record UploadInitResponse(String uploadId, long received, long chunkSize) {} + + public record UploadStatusResponse(String uploadId, long received, long size) {} + + // ---- log-plot / curve roles ---- + public record AxisExtent(Double min, Double max) {} + + public record RoleInfo(String key, String label, String group, String mnemonic, + String unit, String description, int column, + Double dataMin, Double dataMax, double defMin, double defMax) {} + + public record RolesResponse(boolean ready, boolean hasTimeAxis, boolean hasDepthAxis, + AxisExtent timeExtent, AxisExtent depthExtent, + List roles, List> defaultTracks) {} + + public record CurveSeriesDto(String key, String mnemonic, String unit, int column, + List min, List max, Double dataMin, Double dataMax) {} + + public record CurveDataDto(String axis, boolean detail, double from, double to, int n, + List pos, List curves) {} + + public record CrossDataDto(List x, List y, List c, + double[] xRange, double[] yRange, double[] cRange, + int total, int returned, boolean onBottomFiltered) {} +} diff --git a/src/main/java/com/oiusa/las/web/FileResource.java b/src/main/java/com/oiusa/las/web/FileResource.java new file mode 100644 index 0000000..158db60 --- /dev/null +++ b/src/main/java/com/oiusa/las/web/FileResource.java @@ -0,0 +1,139 @@ +package com.oiusa.las.web; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.stream.Stream; + +import com.oiusa.las.model.LasFile; +import com.oiusa.las.service.FileStore; + +import io.smallrye.common.annotation.Blocking; +import jakarta.inject.Inject; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; + +// NOTE: jakarta.ws.rs.Path clashes with java.nio.file.Path, so the JAX-RS annotation is used +// fully-qualified as @jakarta.ws.rs.Path and java.nio.file.Path is imported normally. +@jakarta.ws.rs.Path("/api/files") +@Produces(MediaType.APPLICATION_JSON) +public class FileResource { + + @Inject + FileStore store; + + @GET + public List list() { + List out = new ArrayList<>(); + for (LasFile f : store.all()) out.add(Dtos.FileSummary.of(f)); + out.sort(Comparator.comparing(Dtos.FileSummary::name, String.CASE_INSENSITIVE_ORDER)); + return out; + } + + @GET + @jakarta.ws.rs.Path("/config") + public Dtos.ConfigResponse config() { + return new Dtos.ConfigResponse(store.allowedRoots(), System.getProperty("user.home"), + store.uploadChunkSize(), store.stride()); + } + + /** Server-side directory listing constrained to the allowed roots, for the file picker. */ + @GET + @jakarta.ws.rs.Path("/browse") + @Blocking + public Response browse(@QueryParam("dir") String dir) { + try { + Path target = (dir == null || dir.isBlank()) + ? Path.of(System.getProperty("user.home")) + : Path.of(dir); + target = store.requireAllowed(target); + if (!Files.isDirectory(target)) { + return Response.status(400).entity(error("Not a directory: " + target)).build(); + } + List entries = new ArrayList<>(); + try (Stream s = Files.list(target)) { + s.forEach(p -> { + try { + boolean isDir = Files.isDirectory(p); + long size = isDir ? 0 : Files.size(p); + String n = p.getFileName().toString(); + entries.add(new Dtos.BrowseEntry(n, p.toString(), isDir, size, looksLikeLas(n))); + } catch (IOException ignore) { /* skip unreadable entries */ } + }); + } + entries.sort(Comparator + .comparing(Dtos.BrowseEntry::dir).reversed() + .thenComparing(Dtos.BrowseEntry::name, String.CASE_INSENSITIVE_ORDER)); + Path parent = target.getParent(); + String parentStr = (parent != null && isUnderAnyRoot(parent)) ? parent.toString() : null; + return Response.ok(new Dtos.BrowseResponse(target.toString(), parentStr, entries)).build(); + } catch (SecurityException e) { + return Response.status(403).entity(error(e.getMessage())).build(); + } catch (IOException e) { + return Response.status(400).entity(error(e.getMessage())).build(); + } + } + + @GET + @jakarta.ws.rs.Path("/{id}") + public Dtos.FileMeta meta(@PathParam("id") String id) { + return Dtos.FileMeta.of(store.require(id)); + } + + @POST + @jakarta.ws.rs.Path("/local") + @Consumes(MediaType.APPLICATION_JSON) + @Blocking + public Response registerLocal(Dtos.LocalRegisterRequest req) { + if (req == null || req.path() == null || req.path().isBlank()) { + return Response.status(400).entity(error("path is required")).build(); + } + try { + LasFile f = store.registerLocal(req.path()); + return Response.ok(Dtos.FileSummary.of(f)).build(); + } catch (SecurityException e) { + return Response.status(403).entity(error(e.getMessage())).build(); + } catch (IOException e) { + return Response.status(400).entity(error(e.getMessage())).build(); + } + } + + @DELETE + @jakarta.ws.rs.Path("/{id}") + @Blocking + public Response delete(@PathParam("id") String id) { + try { + store.remove(id); + return Response.noContent().build(); + } catch (IOException e) { + return Response.status(500).entity(error(e.getMessage())).build(); + } + } + + private boolean isUnderAnyRoot(Path p) { + Path norm = p.toAbsolutePath().normalize(); + for (String r : store.allowedRoots()) { + if (norm.startsWith(Path.of(r).toAbsolutePath().normalize())) return true; + } + return false; + } + + private static boolean looksLikeLas(String name) { + String n = name.toLowerCase(); + return n.endsWith(".las") || n.endsWith(".asc") || n.endsWith(".txt"); + } + + private static java.util.Map error(String msg) { + return java.util.Map.of("error", msg == null ? "error" : msg); + } +} diff --git a/src/main/java/com/oiusa/las/web/LinesResource.java b/src/main/java/com/oiusa/las/web/LinesResource.java new file mode 100644 index 0000000..33835d3 --- /dev/null +++ b/src/main/java/com/oiusa/las/web/LinesResource.java @@ -0,0 +1,51 @@ +package com.oiusa.las.web; + +import java.io.IOException; + +import com.oiusa.las.index.LineReader; +import com.oiusa.las.model.LasFile; +import com.oiusa.las.service.FileStore; + +import io.smallrye.common.annotation.Blocking; +import jakarta.inject.Inject; +import jakarta.ws.rs.DefaultValue; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; + +@jakarta.ws.rs.Path("/api/files") +@Produces(MediaType.APPLICATION_JSON) +public class LinesResource { + + /** Hard cap on a single range request so the browser is never handed an unbounded payload. */ + private static final int MAX_COUNT = 2000; + + @Inject + FileStore store; + + @GET + @jakarta.ws.rs.Path("/{id}/lines") + @Blocking + public Response lines(@PathParam("id") String id, + @QueryParam("start") @DefaultValue("0") long start, + @QueryParam("count") @DefaultValue("200") int count) { + LasFile f = store.get(id); + if (f == null) return Response.status(404).entity(error("No such file: " + id)).build(); + if (count < 0) count = 0; + if (count > MAX_COUNT) count = MAX_COUNT; + try { + LineReader.Range range = LineReader.read(f, start, count); + return Response.ok(new Dtos.LinesResponse( + range.start(), range.lines(), range.eof(), f.availableLines())).build(); + } catch (IOException e) { + return Response.status(500).entity(error(e.getMessage())).build(); + } + } + + private static java.util.Map error(String msg) { + return java.util.Map.of("error", msg == null ? "error" : msg); + } +} diff --git a/src/main/java/com/oiusa/las/web/SearchResource.java b/src/main/java/com/oiusa/las/web/SearchResource.java new file mode 100644 index 0000000..a4f71c9 --- /dev/null +++ b/src/main/java/com/oiusa/las/web/SearchResource.java @@ -0,0 +1,109 @@ +package com.oiusa.las.web; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.nio.channels.Channels; +import java.nio.channels.FileChannel; +import java.nio.charset.StandardCharsets; +import java.nio.file.StandardOpenOption; +import java.util.Locale; + +import org.eclipse.microprofile.context.ManagedExecutor; +import org.jboss.logging.Logger; + +import com.oiusa.las.model.LasFile; +import com.oiusa.las.service.FileStore; + +import jakarta.inject.Inject; +import jakarta.ws.rs.DefaultValue; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.sse.Sse; +import jakarta.ws.rs.sse.SseEventSink; + +/** + * Case-insensitive substring search streamed over SSE: scans the file once and emits a {@code match} + * event per hit (capped at {@code max}), periodic {@code progress} events, and a final {@code done}. + * Sequential single-pass scan = bounded memory even for 12 GB files. The client closes the stream to + * cancel an in-flight search. + */ +@jakarta.ws.rs.Path("/api/files") +public class SearchResource { + + private static final Logger LOG = Logger.getLogger(SearchResource.class); + private static final int SNIPPET_MAX = 2000; + + @Inject + FileStore store; + + @Inject + ManagedExecutor executor; + + public record Match(long line, String text) {} + public record Progress(long scanned, int matches) {} + public record Done(long scanned, int matches, boolean truncated) {} + + @GET + @jakarta.ws.rs.Path("/{id}/search") + @Produces(MediaType.SERVER_SENT_EVENTS) + public void search(@PathParam("id") String id, + @QueryParam("q") String q, + @QueryParam("max") @DefaultValue("500") int max, + @Context SseEventSink sink, + @Context Sse sse) { + final LasFile f = store.get(id); + if (f == null || q == null || q.isEmpty()) { + sink.send(sse.newEventBuilder().name("done") + .mediaType(MediaType.APPLICATION_JSON_TYPE) + .data(Done.class, new Done(0, 0, false)).build()); + sink.close(); + return; + } + final int cap = Math.max(1, Math.min(10000, max)); + final String needle = q.toLowerCase(Locale.ROOT); + executor.execute(() -> runSearch(f, sink, sse, needle, cap)); + } + + private void runSearch(LasFile f, SseEventSink sink, Sse sse, String needle, int cap) { + long line = 0; + int matches = 0; + boolean truncated = false; + try (FileChannel ch = FileChannel.open(f.path, StandardOpenOption.READ)) { + BufferedReader r = new BufferedReader( + new InputStreamReader(Channels.newInputStream(ch), StandardCharsets.ISO_8859_1), 1 << 20); + String ln; + while ((ln = r.readLine()) != null) { + if (sink.isClosed()) return; + if (ln.toLowerCase(Locale.ROOT).contains(needle)) { + String snippet = ln.length() > SNIPPET_MAX ? ln.substring(0, SNIPPET_MAX) + "…" : ln; + sink.send(sse.newEventBuilder().name("match") + .mediaType(MediaType.APPLICATION_JSON_TYPE) + .data(Match.class, new Match(line, snippet)).build()) + .toCompletableFuture().get(); + if (++matches >= cap) { truncated = true; break; } + } + line++; + if ((line & 0x3FFFF) == 0) { // ~every 262k lines + sink.send(sse.newEventBuilder().name("progress") + .mediaType(MediaType.APPLICATION_JSON_TYPE) + .data(Progress.class, new Progress(line, matches)).build()); + } + } + if (!sink.isClosed()) { + sink.send(sse.newEventBuilder().name("done") + .mediaType(MediaType.APPLICATION_JSON_TYPE) + .data(Done.class, new Done(line, matches, truncated)).build()); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } catch (Exception e) { + LOG.debugf(e, "search ended for %s", f.id); + } finally { + try { sink.close(); } catch (Exception ignore) { } + } + } +} diff --git a/src/main/java/com/oiusa/las/web/StreamResource.java b/src/main/java/com/oiusa/las/web/StreamResource.java new file mode 100644 index 0000000..3d6c9c1 --- /dev/null +++ b/src/main/java/com/oiusa/las/web/StreamResource.java @@ -0,0 +1,124 @@ +package com.oiusa.las.web; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.nio.channels.Channels; +import java.nio.channels.FileChannel; +import java.nio.charset.StandardCharsets; +import java.nio.file.StandardOpenOption; +import java.util.ArrayList; +import java.util.List; + +import org.eclipse.microprofile.context.ManagedExecutor; +import org.jboss.logging.Logger; + +import com.oiusa.las.model.LasFile; +import com.oiusa.las.service.FileStore; + +import jakarta.inject.Inject; +import jakarta.ws.rs.DefaultValue; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.sse.Sse; +import jakarta.ws.rs.sse.SseEventSink; + +/** + * Streams a file's lines to the browser over Server-Sent Events, starting at {@code start} and + * pacing {@code batch} lines every {@code intervalMs}. One {@link BufferedReader} is opened at the + * start offset (located via the sparse index) and advanced sequentially, so even a 12 GB file + * streams with a constant, tiny memory footprint. The loop stops on EOF or when the client + * disconnects (detected because the awaited {@code send} fails). + */ +@jakarta.ws.rs.Path("/api/files") +public class StreamResource { + + private static final Logger LOG = Logger.getLogger(StreamResource.class); + + @Inject + FileStore store; + + @Inject + ManagedExecutor executor; + + public record StreamBatch(long start, List lines, boolean eof, long availableLines, long seq) {} + + @GET + @jakarta.ws.rs.Path("/{id}/stream") + @Produces(MediaType.SERVER_SENT_EVENTS) + public void stream(@PathParam("id") String id, + @QueryParam("start") @DefaultValue("0") long start, + @QueryParam("batch") @DefaultValue("40") int batch, + @QueryParam("intervalMs") @DefaultValue("80") long intervalMs, + @Context SseEventSink sink, + @Context Sse sse) { + final LasFile f = store.get(id); + if (f == null) { + // "fail" (not "error") so it doesn't collide with EventSource's native error event. + sink.send(sse.newEventBuilder().name("fail").data(String.class, "No such file: " + id).build()); + sink.close(); + return; + } + final int b = Math.max(1, Math.min(1000, batch)); + final long delay = Math.max(0, Math.min(5000, intervalMs)); + final long startLine = Math.max(0, start); + + executor.execute(() -> runStream(f, sink, sse, startLine, b, delay)); + } + + private void runStream(LasFile f, SseEventSink sink, Sse sse, long startLine, int batch, long delay) { + long line = startLine; + long seq = 0; + try (FileChannel ch = FileChannel.open(f.path, StandardOpenOption.READ)) { + ch.position(f.index.offsetForLine(startLine)); + BufferedReader r = new BufferedReader( + new InputStreamReader(Channels.newInputStream(ch), StandardCharsets.ISO_8859_1), 1 << 20); + + long skip = startLine - f.index.checkpointLine(startLine); + for (long i = 0; i < skip; i++) { + if (r.readLine() == null) break; + } + + while (!sink.isClosed()) { + List lines = new ArrayList<>(batch); + boolean eof = false; + for (int i = 0; i < batch; i++) { + String ln = r.readLine(); + if (ln == null) { eof = true; break; } + lines.add(ln); + } + if (!lines.isEmpty()) { + StreamBatch payload = new StreamBatch(line, lines, eof, f.availableLines(), seq++); + // Awaiting the send applies backpressure and surfaces client disconnects. + sink.send(sse.newEventBuilder() + .name("lines") + .mediaType(MediaType.APPLICATION_JSON_TYPE) + .data(StreamBatch.class, payload) + .build()) + .toCompletableFuture().get(); + line += lines.size(); + } + if (eof) { + if (!sink.isClosed()) { + sink.send(sse.newEventBuilder().name("eof") + .mediaType(MediaType.APPLICATION_JSON_TYPE) + .data(StreamBatch.class, new StreamBatch(line, List.of(), true, f.availableLines(), seq)) + .build()); + } + break; + } + if (delay > 0) Thread.sleep(delay); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } catch (Exception e) { + // Client disconnect or IO error: just stop. (Common and expected on pause/close.) + LOG.debugf(e, "stream ended for %s", f.id); + } finally { + try { sink.close(); } catch (Exception ignore) { } + } + } +} diff --git a/src/main/java/com/oiusa/las/web/UploadResource.java b/src/main/java/com/oiusa/las/web/UploadResource.java new file mode 100644 index 0000000..4096cc6 --- /dev/null +++ b/src/main/java/com/oiusa/las/web/UploadResource.java @@ -0,0 +1,99 @@ +package com.oiusa.las.web; + +import java.io.IOException; +import java.io.InputStream; + +import com.oiusa.las.model.LasFile; +import com.oiusa.las.service.UploadService; + +import io.smallrye.common.annotation.Blocking; +import jakarta.inject.Inject; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.DefaultValue; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.PUT; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; + +/** + * Resumable chunked upload API: + *
    + *
  1. {@code POST /api/uploads/init} {name,size} → uploadId
  2. + *
  3. {@code PUT /api/uploads/{id}/chunk?offset=N} (octet-stream body) → received high-water mark
  4. + *
  5. {@code GET /api/uploads/{id}} → {received,size} (to resume after an interruption)
  6. + *
  7. {@code POST /api/uploads/{id}/complete} → registers the file and starts indexing
  8. + *
+ */ +@jakarta.ws.rs.Path("/api/uploads") +@Produces(MediaType.APPLICATION_JSON) +public class UploadResource { + + @Inject + UploadService uploads; + + @POST + @jakarta.ws.rs.Path("/init") + @Consumes(MediaType.APPLICATION_JSON) + @Blocking + public Response init(Dtos.UploadInitRequest req) { + if (req == null || req.name() == null || req.size() < 0) { + return Response.status(400).entity(error("name and size are required")).build(); + } + try { + UploadService.Session s = uploads.init(req.name(), req.size()); + return Response.ok(new Dtos.UploadInitResponse(s.id, s.received, 0)).build(); + } catch (IOException e) { + return Response.status(500).entity(error(e.getMessage())).build(); + } + } + + @PUT + @jakarta.ws.rs.Path("/{id}/chunk") + @Consumes(MediaType.APPLICATION_OCTET_STREAM) + @Blocking + public Response chunk(@PathParam("id") String id, + @QueryParam("offset") @DefaultValue("0") long offset, + InputStream body) { + try { + long received = uploads.writeChunk(id, offset, body); + return Response.ok(new Dtos.UploadStatusResponse(id, received, -1)).build(); + } catch (IllegalArgumentException e) { + return Response.status(404).entity(error(e.getMessage())).build(); + } catch (IOException e) { + return Response.status(500).entity(error(e.getMessage())).build(); + } + } + + @GET + @jakarta.ws.rs.Path("/{id}") + public Response status(@PathParam("id") String id) { + try { + UploadService.Session s = uploads.get(id); + return Response.ok(new Dtos.UploadStatusResponse(s.id, s.received, s.size)).build(); + } catch (IllegalArgumentException e) { + return Response.status(404).entity(error(e.getMessage())).build(); + } + } + + @POST + @jakarta.ws.rs.Path("/{id}/complete") + @Blocking + public Response complete(@PathParam("id") String id) { + try { + LasFile f = uploads.complete(id); + return Response.ok(Dtos.FileSummary.of(f)).build(); + } catch (IllegalArgumentException e) { + return Response.status(404).entity(error(e.getMessage())).build(); + } catch (IOException e) { + return Response.status(500).entity(error(e.getMessage())).build(); + } + } + + private static java.util.Map error(String msg) { + return java.util.Map.of("error", msg == null ? "error" : msg); + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties new file mode 100644 index 0000000..1a91b9a --- /dev/null +++ b/src/main/resources/application.properties @@ -0,0 +1,28 @@ +# ---- HTTP ---- +# :8080 is used by the witsml-las-quarkus app; use :8090 here to avoid clashes. +quarkus.http.port=8090 +quarkus.http.host=0.0.0.0 +# Allow large upload chunks (we slice client-side at 16 MiB; 128M gives headroom). +quarkus.http.limits.max-body-size=128M +# Long-lived SSE streams + slow uploads of huge files. +quarkus.http.read-timeout=600s +quarkus.http.idle-timeout=600s + +# ---- CORS (so the Vite dev server on :5173 can call the API directly if not proxied) ---- +quarkus.http.cors.enabled=true +quarkus.http.cors.origins=/.*/ +quarkus.http.cors.methods=GET,POST,PUT,DELETE,OPTIONS + +# ---- App config ---- +# Where uploaded files + working state live. +las.data-dir=${user.home}/.las-stream-viewer +# Local files may only be opened in-place if they live under one of these roots (comma-separated). +# Defaults to the user's home so Desktop\LAS files is reachable. Widen/narrow as needed. +las.allowed-roots=${user.home} +# Sparse line-index checkpoint stride (one byte-offset stored every N lines). +las.index-stride=256 +# Suggested upload chunk size handed to the browser (bytes). 16 MiB. +las.upload-chunk-size=16777216 + +quarkus.log.level=INFO +quarkus.log.category."com.oiusa.las".level=INFO