Initial commit: LAS Stream Viewer (Quarkus backend + React log-plot UI)
This commit is contained in:
21
.gitignore
vendored
Normal file
21
.gitignore
vendored
Normal file
@@ -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
|
||||||
86
README.md
Normal file
86
README.md
Normal file
@@ -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) |
|
||||||
|
```
|
||||||
20
build.ps1
Normal file
20
build.ps1
Normal file
@@ -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
|
||||||
12
frontend/index.html
Normal file
12
frontend/index.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>LAS Stream Viewer</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1799
frontend/package-lock.json
generated
Normal file
1799
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
frontend/package.json
Normal file
23
frontend/package.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
139
frontend/src/App.tsx
Normal file
139
frontend/src/App.tsx
Normal file
@@ -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<AppConfig | null>(null)
|
||||||
|
const [files, setFiles] = useState<FileSummary[]>([])
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||||
|
const [meta, setMeta] = useState<FileMeta | null>(null)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [mainTab, setMainTab] = useState<'plot' | 'cross' | 'raw'>('plot')
|
||||||
|
const [sidebarOpen, setSidebarOpen] = useState(true)
|
||||||
|
const errTimer = useRef<number | undefined>(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 (
|
||||||
|
<div className={`app${sidebarOpen ? '' : ' nosidebar'}`}>
|
||||||
|
<div className="topbar">
|
||||||
|
<button className="sidebar-toggle" onClick={() => setSidebarOpen(o => !o)}
|
||||||
|
title={sidebarOpen ? 'Hide side panel' : 'Show side panel'}>
|
||||||
|
{sidebarOpen ? '⮜' : '☰'}
|
||||||
|
</button>
|
||||||
|
<div className="logo">LS</div>
|
||||||
|
<div>
|
||||||
|
<h1>LAS Stream Viewer</h1>
|
||||||
|
</div>
|
||||||
|
<span className="sub">large-file well-log line streaming</span>
|
||||||
|
<div className="spacer" />
|
||||||
|
<span className="stat">{files.length} file{files.length === 1 ? '' : 's'} · {fmtNum(totalIndexed)} lines indexed</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sidebar scroll">
|
||||||
|
<IngestPanel config={config} onOpened={onOpened} onError={showError} collapsedDefault={files.length > 0} />
|
||||||
|
<Section title="Files" count={files.length} defaultOpen>
|
||||||
|
<FileList files={files} selectedId={selectedId} onSelect={setSelectedId} onRemove={onRemove} />
|
||||||
|
</Section>
|
||||||
|
{meta && selected && (
|
||||||
|
<>
|
||||||
|
<Section title="Well" defaultOpen>
|
||||||
|
<WellInfo meta={meta} file={selected} />
|
||||||
|
</Section>
|
||||||
|
<Section title="Channels" count={meta.curves.length} defaultOpen={false}>
|
||||||
|
<ChannelList fileId={selected.id} curves={meta.curves} />
|
||||||
|
</Section>
|
||||||
|
<Section title="Header (raw)" defaultOpen={false}>
|
||||||
|
<HeaderPanel meta={meta} />
|
||||||
|
</Section>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="main">
|
||||||
|
{selected
|
||||||
|
? <>
|
||||||
|
<div className="viewer-bar" style={{ padding: '0 8px', minHeight: 38 }}>
|
||||||
|
<div className="main-tabs">
|
||||||
|
<button className={mainTab === 'plot' ? 'on' : ''} onClick={() => setMainTab('plot')}>📈 Log Plot</button>
|
||||||
|
<button className={mainTab === 'cross' ? 'on' : ''} onClick={() => setMainTab('cross')}>⊕ Crossplot</button>
|
||||||
|
<button className={mainTab === 'raw' ? 'on' : ''} onClick={() => setMainTab('raw')}>𝍌 Raw / QC</button>
|
||||||
|
</div>
|
||||||
|
<div className="grow" />
|
||||||
|
<span className="stat" style={{ fontSize: 12, color: 'var(--txt-faint)' }}>{selected.name}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
|
||||||
|
{mainTab === 'plot' && <LogPlot file={selected} onError={showError} />}
|
||||||
|
{mainTab === 'cross' && <Crossplot file={selected} onError={showError} />}
|
||||||
|
{mainTab === 'raw' && <Viewer file={selected} meta={meta} onError={showError} />}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
: <div className="empty">
|
||||||
|
<div>
|
||||||
|
<div className="e-ic">𝍌</div>
|
||||||
|
<div>Open a LAS file to begin.</div>
|
||||||
|
<div className="muted" style={{ marginTop: 6, fontSize: 12 }}>
|
||||||
|
Upload a file or open one already on disk — handles 10 GB+ logs.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="toast">⚠ {error}</div>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
86
frontend/src/api.ts
Normal file
86
frontend/src/api.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import type {
|
||||||
|
AppConfig, BrowseResponse, CrossData, CurveData, FileMeta, FileSummary, LinesResponse, RolesResponse,
|
||||||
|
} from './types'
|
||||||
|
|
||||||
|
async function j<T>(res: Response): Promise<T> {
|
||||||
|
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<T>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
config: () => fetch('/api/files/config').then(r => j<AppConfig>(r)),
|
||||||
|
|
||||||
|
listFiles: () => fetch('/api/files').then(r => j<FileSummary[]>(r)),
|
||||||
|
|
||||||
|
meta: (id: string) => fetch(`/api/files/${id}`).then(r => j<FileMeta>(r)),
|
||||||
|
|
||||||
|
browse: (dir?: string) =>
|
||||||
|
fetch(`/api/files/browse${dir ? `?dir=${encodeURIComponent(dir)}` : ''}`).then(r => j<BrowseResponse>(r)),
|
||||||
|
|
||||||
|
openLocal: (path: string) =>
|
||||||
|
fetch('/api/files/local', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ path }),
|
||||||
|
}).then(r => j<FileSummary>(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<LinesResponse>(r)),
|
||||||
|
|
||||||
|
roles: (id: string) => fetch(`/api/files/${id}/roles`).then(r => j<RolesResponse>(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<CurveData>(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<CrossData>(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<FileSummary>(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')
|
||||||
|
}
|
||||||
67
frontend/src/components/ChannelList.tsx
Normal file
67
frontend/src/components/ChannelList.tsx
Normal file
@@ -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<Map<string, RoleInfo>>(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<string, RoleInfo>()
|
||||||
|
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 (
|
||||||
|
<div>
|
||||||
|
<div className="ch-tools">
|
||||||
|
<input className="field" placeholder="search channels…" value={q} onChange={e => setQ(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<label className="ch-filter">
|
||||||
|
<input type="checkbox" checked={drillingOnly} onChange={e => setDrillingOnly(e.target.checked)} />
|
||||||
|
drilling channels only ({drillingCount})
|
||||||
|
</label>
|
||||||
|
<div className="ch-list scroll">
|
||||||
|
{filtered.map(c => {
|
||||||
|
const role = roleByMnem.get(c.mnemonic.toUpperCase())
|
||||||
|
const color = role ? GROUP_COLOR[role.group] : undefined
|
||||||
|
return (
|
||||||
|
<div className="ch-row" key={c.column} title={c.description}>
|
||||||
|
<span className="mn" style={{ color }}>{c.mnemonic}</span>
|
||||||
|
<span className="u">{c.unit}</span>
|
||||||
|
<span className="d">{c.description}</span>
|
||||||
|
{role && (
|
||||||
|
<span className="badge2" style={{ background: (color || '#888') + '22', color }}>
|
||||||
|
{GROUP_LABEL[role.group]}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{filtered.length === 0 && <div className="muted" style={{ padding: '8px', fontSize: 12 }}>no matches</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
293
frontend/src/components/Crossplot.tsx
Normal file
293
frontend/src/components/Crossplot.tsx
Normal file
@@ -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<RolesResponse | null>(null)
|
||||||
|
const roleMap = useMemo(() => {
|
||||||
|
const m = new Map<string, RoleInfo>()
|
||||||
|
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<CrossData | null>(null)
|
||||||
|
const [hover, setHover] = useState<{ px: number; py: number; i: number } | null>(null)
|
||||||
|
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(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 <div className="empty"><div><div className="spin" style={{ width: 18, height: 18 }} /><div style={{ marginTop: 10 }}>Building curve overview…</div></div></div>
|
||||||
|
}
|
||||||
|
if (roles && dataRoles.length < 2) {
|
||||||
|
return <div className="empty"><div>Not enough numeric channels for a crossplot.</div></div>
|
||||||
|
}
|
||||||
|
|
||||||
|
const rx = roleMap.get(x), ry = roleMap.get(y)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="lp">
|
||||||
|
<div className="viewer-bar">
|
||||||
|
<Sel label="X" value={x} onChange={setX} options={dataRoles} />
|
||||||
|
<Sel label="Y" value={y} onChange={setY} options={dataRoles} />
|
||||||
|
<Sel label="Color" value={color} onChange={setColor} options={dataRoles}
|
||||||
|
extra={[...(roles?.hasDepthAxis ? [['depth', 'Depth']] : []), ...(roles?.hasTimeAxis ? [['time', 'Time']] : [])] as [string, string][]} />
|
||||||
|
<label className="speed" style={{ gap: 5, opacity: hasOnBottom ? 1 : 0.4 }}
|
||||||
|
title={hasOnBottom ? 'restrict to on-bottom drilling buckets' : 'no on-bottom channel in this file'}>
|
||||||
|
<input type="checkbox" checked={onBottom} disabled={!hasOnBottom} onChange={e => setOnBottom(e.target.checked)} /> on-bottom only
|
||||||
|
</label>
|
||||||
|
<label className="speed" style={{ gap: 5 }}>
|
||||||
|
<input type="checkbox" checked={physical} onChange={e => setPhysical(e.target.checked)} /> physical scale
|
||||||
|
</label>
|
||||||
|
<div className="grow" />
|
||||||
|
<span className="stat" style={{ fontFamily: 'var(--mono)', fontSize: 12, color: 'var(--txt-dim)' }}>
|
||||||
|
{data ? `${fmtNum(data.returned)} pts${data.total > data.returned ? ` / ${fmtNum(data.total)}` : ''}${data.onBottomFiltered ? ' · on-btm' : ''}` : '…'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="lp-body" ref={containerRef}>
|
||||||
|
<canvas ref={canvasRef} onMouseMove={onMove} onMouseLeave={() => setHover(null)} style={{ cursor: 'crosshair' }} />
|
||||||
|
{hover && data && (
|
||||||
|
<div className="lp-readout" style={{ left: Math.min(hover.px + 12, sizeRef.current.w - 150), top: Math.max(8, hover.py - 50) }}>
|
||||||
|
<div className="ro"><span className="l">{rx?.mnemonic ?? x}</span><span className="vv">{trim(data.x[hover.i])} {rx?.unit}</span></div>
|
||||||
|
<div className="ro"><span className="l">{ry?.mnemonic ?? y}</span><span className="vv">{trim(data.y[hover.i])} {ry?.unit}</span></div>
|
||||||
|
{data.c[hover.i] != null && <div className="ro"><span className="l">{color === 'time' ? 'time' : color === 'depth' ? 'depth' : (roleMap.get(color)?.mnemonic ?? color)}</span><span className="vv">{fmtColor(data.c[hover.i]!, color)}</span></div>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Sel({ label, value, onChange, options, extra = [] }:
|
||||||
|
{ label: string; value: string; onChange: (v: string) => void; options: RoleInfo[]; extra?: [string, string][] }) {
|
||||||
|
return (
|
||||||
|
<label className="speed" style={{ gap: 5 }}>
|
||||||
|
<span style={{ color: 'var(--txt-faint)', fontSize: 12 }}>{label}</span>
|
||||||
|
<select className="field" style={{ padding: '5px 6px' }} value={value} onChange={e => onChange(e.target.value)}>
|
||||||
|
{extra.map(([k, l]) => <option key={k} value={k}>{l}</option>)}
|
||||||
|
{options.map(r => <option key={r.key} value={r.key}>{r.mnemonic} — {r.label}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 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})`
|
||||||
|
}
|
||||||
45
frontend/src/components/FileList.tsx
Normal file
45
frontend/src/components/FileList.tsx
Normal file
@@ -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 && <div className="muted" style={{ fontSize: 12.5 }}>No files yet.</div>}
|
||||||
|
{files.map(f => {
|
||||||
|
const pct = f.sizeBytes > 0 ? Math.min(100, (f.indexedBytes / f.sizeBytes) * 100) : 0
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={f.id}
|
||||||
|
className={`fcard${f.id === selectedId ? ' sel' : ''}`}
|
||||||
|
onClick={() => onSelect(f.id)}
|
||||||
|
>
|
||||||
|
<div className="x" title="Remove" onClick={(e) => { e.stopPropagation(); onRemove(f.id) }}>×</div>
|
||||||
|
<div className="nm">{f.name}</div>
|
||||||
|
<div className="meta">
|
||||||
|
<span className={`badge b-${f.status}`}>{f.status}</span>
|
||||||
|
<span>{fmtBytes(f.sizeBytes)}</span>
|
||||||
|
{f.availableLines > 0 && <span>{fmtNum(f.availableLines)} lines</span>}
|
||||||
|
{f.curveCount > 0 && <span>{f.curveCount} curves</span>}
|
||||||
|
{!f.uploaded && <span title="opened in place, not copied">in-place</span>}
|
||||||
|
</div>
|
||||||
|
{f.status === 'INDEXING' && (
|
||||||
|
<div className="prog" title={`${pct.toFixed(1)}% scanned`}>
|
||||||
|
<span style={{ width: `${pct}%` }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{f.status === 'ERROR' && f.error && (
|
||||||
|
<div className="meta" style={{ color: 'var(--err)' }}>{f.error}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
59
frontend/src/components/HeaderPanel.tsx
Normal file
59
frontend/src/components/HeaderPanel.tsx
Normal file
@@ -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<string | null>(null)
|
||||||
|
const [raw, setRaw] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="hp">
|
||||||
|
<div className="hp-toggle">
|
||||||
|
<div className="seg">
|
||||||
|
<button className={raw ? '' : 'on'} onClick={() => setRaw(false)}>Structured</button>
|
||||||
|
<button className={raw ? 'on' : ''} onClick={() => setRaw(true)}>Raw text</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{meta.sections.map(s => {
|
||||||
|
const open = openSec === s.name
|
||||||
|
return (
|
||||||
|
<details className="grp" key={s.name} open={open}
|
||||||
|
onToggle={(e) => { if ((e.target as HTMLDetailsElement).open) setOpenSec(s.name) }}>
|
||||||
|
<summary>{s.name} <span className="cnt">({s.lines.filter(l => l.trim()).length})</span></summary>
|
||||||
|
{open && (raw ? <pre>{s.lines.join('\n')}</pre> : <StructuredSection section={s} />)}
|
||||||
|
</details>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function StructuredSection({ section }: { section: HeaderSection }) {
|
||||||
|
const rows = section.lines.filter(l => l.trim().length > 0)
|
||||||
|
return (
|
||||||
|
<div className="kv-list">
|
||||||
|
{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 <div className="kv-free" key={i}>{line.trim()}</div>
|
||||||
|
return (
|
||||||
|
<div className="kv" key={i}>
|
||||||
|
<div className="top">
|
||||||
|
<span className="m">{p.mnem}</span>
|
||||||
|
{p.unit && <span className="u">·{p.unit}</span>}
|
||||||
|
<span className={`v${p.value ? '' : ' empty'}`}>{p.value || '—'}</span>
|
||||||
|
</div>
|
||||||
|
{p.desc && <div className="d">{p.desc}</div>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
166
frontend/src/components/IngestPanel.tsx
Normal file
166
frontend/src/components/IngestPanel.tsx
Normal file
@@ -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<Mode>('disk')
|
||||||
|
const [open, setOpen] = useState(!collapsedDefault)
|
||||||
|
const handleOpened = (f: FileSummary) => { onOpened(f); setOpen(false) }
|
||||||
|
return (
|
||||||
|
<div className="sec">
|
||||||
|
<div className={`sec-h${open ? '' : ' closed'}`} onClick={() => setOpen(o => !o)}>
|
||||||
|
<span className="chev">▼</span>
|
||||||
|
<span>{collapsedDefault ? 'Open another file' : 'Open a LAS file'}</span>
|
||||||
|
</div>
|
||||||
|
{open && (
|
||||||
|
<div className="sec-b">
|
||||||
|
<div className="seg">
|
||||||
|
<button className={mode === 'disk' ? 'on' : ''} onClick={() => setMode('disk')}>Open on disk</button>
|
||||||
|
<button className={mode === 'upload' ? 'on' : ''} onClick={() => setMode('upload')}>Upload</button>
|
||||||
|
</div>
|
||||||
|
{mode === 'upload'
|
||||||
|
? <Uploader config={config} onOpened={handleOpened} onError={onError} />
|
||||||
|
: <DiskBrowser config={config} onOpened={handleOpened} onError={onError} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- 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<HTMLInputElement>(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 (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className={`drop${over ? ' over' : ''}`}
|
||||||
|
onClick={() => !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 ? (
|
||||||
|
<>
|
||||||
|
<div className="big">{busy.name}</div>
|
||||||
|
<div className="small">{fmtBytes(busy.sent)} / {fmtBytes(busy.total)} · {pct.toFixed(1)}%</div>
|
||||||
|
<div className="prog" style={{ marginTop: 10 }}><span style={{ width: `${pct}%` }} /></div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="big">⤓ Drop a LAS file here</div>
|
||||||
|
<div className="small">or click to choose · streamed in 16 MB chunks (any size)</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
ref={inputRef} type="file" hidden
|
||||||
|
accept=".las,.asc,.txt,text/plain"
|
||||||
|
onChange={(e) => { const f = e.target.files?.[0]; if (f) upload(f) }}
|
||||||
|
/>
|
||||||
|
<div className="muted" style={{ fontSize: 11, marginTop: 8 }}>
|
||||||
|
Tip: for files already on this machine, use <b>Open on disk</b> — no copy, opens instantly.
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- server-side disk browser ---------------- */
|
||||||
|
function DiskBrowser({ config, onOpened, onError }: Props) {
|
||||||
|
const [data, setData] = useState<BrowseResponse | null>(null)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [opening, setOpening] = useState<string | null>(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 (
|
||||||
|
<div>
|
||||||
|
<div className="crumbs">{data?.dir ?? '…'}</div>
|
||||||
|
<div style={{ maxHeight: 320, overflow: 'auto' }} className="scroll">
|
||||||
|
{loading && <div className="muted" style={{ padding: '6px 9px' }}><span className="spin" /> reading…</div>}
|
||||||
|
{!loading && data?.parent && (
|
||||||
|
<div className="fbrow" onClick={() => go(data.parent!)}>
|
||||||
|
<span className="ic">↑</span><span className="nm">..</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!loading && data?.entries.map(e => (
|
||||||
|
<div
|
||||||
|
key={e.path}
|
||||||
|
className={`fbrow${e.looksLikeLas ? ' las' : ''}`}
|
||||||
|
onClick={() => e.dir ? go(e.path) : open(e.path)}
|
||||||
|
title={e.path}
|
||||||
|
>
|
||||||
|
<span className="ic">{e.dir ? '📁' : (e.looksLikeLas ? '📈' : '📄')}</span>
|
||||||
|
<span className="nm">{e.name}</span>
|
||||||
|
{!e.dir && <span className="sz">{opening === e.path ? '…' : fmtBytes(e.sizeBytes)}</span>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{!loading && data && data.entries.length === 0 && (
|
||||||
|
<div className="muted" style={{ padding: '6px 9px', fontSize: 12 }}>(empty)</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
629
frontend/src/components/LogPlot.tsx
Normal file
629
frontend/src/components/LogPlot.tsx
Normal file
@@ -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<RolesResponse | null>(null)
|
||||||
|
const roleMap = useMemo(() => {
|
||||||
|
const m = new Map<string, RoleInfo>()
|
||||||
|
roles?.roles.forEach(r => m.set(r.key, r))
|
||||||
|
return m
|
||||||
|
}, [roles])
|
||||||
|
|
||||||
|
const [axis, setAxis] = useState<'depth' | 'time'>('depth')
|
||||||
|
const [tracks, setTracks] = useState<Track[]>([])
|
||||||
|
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<HTMLDivElement>(null)
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||||
|
const viewRef = useRef<{ from: number; to: number }>({ from: 0, to: 1 })
|
||||||
|
const dataRef = useRef<CurveData | null>(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<HTMLDivElement>(null)
|
||||||
|
const thumbRef = useRef<HTMLDivElement>(null)
|
||||||
|
const windowRangeRef = useRef<Map<string, [number, number]>>(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<string, [number, number]>()
|
||||||
|
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 <div className="empty"><div><div className="spin" style={{ width: 18, height: 18 }} />
|
||||||
|
<div style={{ marginTop: 10 }}>Building curve overview…</div>
|
||||||
|
<div className="muted" style={{ fontSize: 12, marginTop: 6 }}>parsing channels for the log plot</div></div></div>
|
||||||
|
}
|
||||||
|
if (roles && roles.roles.length === 0) {
|
||||||
|
return <div className="empty"><div>No drilling channels recognized in this file.<div className="muted" style={{ fontSize: 12, marginTop: 6 }}>Use the Raw / QC tab to inspect it.</div></div></div>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="lp">
|
||||||
|
<div className="viewer-bar">
|
||||||
|
{roles?.hasDepthAxis && roles?.hasTimeAxis && (
|
||||||
|
<div className="seg" style={{ margin: 0, width: 150 }}>
|
||||||
|
<button className={axis === 'depth' ? 'on' : ''} onClick={() => setAxis('depth')}>Depth</button>
|
||||||
|
<button className={axis === 'time' ? 'on' : ''} onClick={() => setAxis('time')}>Time</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button className="btn primary" onClick={() => setPlaying(p => !p)}>{playing ? '⏸ Pause' : '▶ Replay'}</button>
|
||||||
|
<div className="speed" title="replay speed">
|
||||||
|
<span>🐌</span>
|
||||||
|
<input type="range" min={axis === 'time' ? 30 : 2} max={axis === 'time' ? 14400 : 500}
|
||||||
|
value={speed} onChange={e => setSpeed(parseInt(e.target.value, 10))} />
|
||||||
|
<span>🚀</span>
|
||||||
|
<span style={{ fontFamily: 'var(--mono)', width: 78, textAlign: 'right' }}>{speedLabel}</span>
|
||||||
|
</div>
|
||||||
|
<div className="sep" />
|
||||||
|
<button className="btn ghost" onClick={() => zoomBy(0.6)} title="zoom in (or scroll the wheel over the plot)">+</button>
|
||||||
|
<button className="btn ghost" onClick={() => zoomBy(1.7)} title="zoom out">-</button>
|
||||||
|
<button className="btn ghost" onClick={resetView}>⤢ Fit</button>
|
||||||
|
<label className="speed" style={{ gap: 5 }} title="auto-fit: scale each track to the data visible in the current window">
|
||||||
|
<input type="checkbox" checked={autoscale} onChange={e => setAutoscale(e.target.checked)} /> auto-fit
|
||||||
|
</label>
|
||||||
|
<button className="btn ghost" onClick={() => setPickerOpen(o => !o)}>+ Curves</button>
|
||||||
|
<button className={`btn ghost${showStats ? ' primary' : ''}`} onClick={() => setShowStats(s => !s)}>Σ Stats</button>
|
||||||
|
<div className="grow" />
|
||||||
|
<span className="stat" style={{ fontFamily: 'var(--mono)', fontSize: 12, color: 'var(--txt-dim)' }}>
|
||||||
|
{detail ? 'detail' : 'overview'} · {axis}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* track headers aligned with canvas tracks */}
|
||||||
|
<div className="lp-heads">
|
||||||
|
<div className="lp-gutter-sp" style={{ width: GUTTER }}>
|
||||||
|
<span className="muted" style={{ fontSize: 10 }}>{axis === 'depth' ? 'DEPTH ft' : 'TIME'}</span>
|
||||||
|
</div>
|
||||||
|
{tracks.map((t) => (
|
||||||
|
<div className="lp-head" key={t.id} style={{ width: `calc((100% - ${GUTTER}px) / ${nT})` }}>
|
||||||
|
{t.curves.map(cv => {
|
||||||
|
const r = roleMap.get(cv.key)
|
||||||
|
const [mn, mx] = scaleFor(cv.key)
|
||||||
|
return (
|
||||||
|
<div className="lp-curve" key={cv.key} title={r?.description}>
|
||||||
|
<span className="dot" style={{ background: cv.color }} />
|
||||||
|
<span className="mn" style={{ color: cv.color }}>{r?.mnemonic ?? cv.key}</span>
|
||||||
|
<span className="rng">{fmtNum(Math.round(mn))}–{fmtNum(Math.round(mx))}{r?.unit ? ' ' + r.unit : ''}</span>
|
||||||
|
<span className="rm" onClick={() => removeCurve(cv.key)}>×</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="lp-body">
|
||||||
|
<div className="lp-canvas-wrap" ref={containerRef}>
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
onWheel={onWheel}
|
||||||
|
onMouseMove={onMouseMove}
|
||||||
|
onMouseLeave={onLeave}
|
||||||
|
style={{ cursor: 'crosshair' }}
|
||||||
|
/>
|
||||||
|
{showStats && stats.length > 0 && (
|
||||||
|
<div className="lp-stats scroll">
|
||||||
|
<div className="sh">Window stats <span className="x" onClick={() => setShowStats(false)}>×</span></div>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>chan</th><th>min</th><th>avg</th><th>max</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{stats.map(s => (
|
||||||
|
<tr key={s.key}>
|
||||||
|
<td title={s.unit}>{s.mnemonic}</td>
|
||||||
|
<td>{s.min == null ? '—' : trim(s.min)}</td>
|
||||||
|
<td>{s.avg == null ? '—' : trim(s.avg)}</td>
|
||||||
|
<td>{s.max == null ? '—' : trim(s.max)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{readout && (
|
||||||
|
<div className="lp-readout" style={{ left: Math.min(readout.x + GUTTER + 8, sizeRef.current.w - 190), top: Math.min(readout.y + 8, sizeRef.current.h - 10 - readout.rows.length * 15) }}>
|
||||||
|
<div className="ix">{fmtIndex(readout.index, axis)}{axis === 'depth' ? ' ft' : ''}</div>
|
||||||
|
{readout.rows.map((r, i) => (
|
||||||
|
<div className="ro" key={i}>
|
||||||
|
<span className="dot" style={{ background: r.color }} />
|
||||||
|
<span className="l">{r.label}</span>
|
||||||
|
<span className="vv">{r.v == null ? '—' : trim(r.v)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="lp-scrollbar" ref={trackRef} onMouseDown={onTrackDown} title="drag to scroll through the well; wheel over the plot to zoom">
|
||||||
|
<div className="lp-thumb" ref={thumbRef} onMouseDown={onThumbDown} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{pickerOpen && roles && (
|
||||||
|
<CurvePicker roles={roles} active={new Set(tracks.flatMap(t => t.curves.map(c => c.key)))}
|
||||||
|
onToggle={toggleRole} onClose={() => setPickerOpen(false)} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- curve picker popover ---------- */
|
||||||
|
function CurvePicker({ roles, active, onToggle, onClose }:
|
||||||
|
{ roles: RolesResponse; active: Set<string>; onToggle: (k: string) => void; onClose: () => void }) {
|
||||||
|
const groups = ['mechanics', 'hydraulics', 'gas', 'directional', 'index']
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
mechanics: 'Drilling mechanics', hydraulics: 'Hydraulics & well control',
|
||||||
|
gas: 'Mud gas / formation', directional: 'Directional & dynamics', index: 'Index / state',
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="lp-picker">
|
||||||
|
<div className="hd">Curves <span className="x" onClick={onClose}>×</span></div>
|
||||||
|
<div className="bd scroll">
|
||||||
|
{groups.map(g => {
|
||||||
|
const rs = roles.roles.filter(r => r.group === g)
|
||||||
|
if (!rs.length) return null
|
||||||
|
return (
|
||||||
|
<div key={g} className="grp">
|
||||||
|
<div className="gh">{labels[g]}</div>
|
||||||
|
{rs.map(r => (
|
||||||
|
<label key={r.key} className={`chip${active.has(r.key) ? ' on' : ''}`}>
|
||||||
|
<input type="checkbox" checked={active.has(r.key)} onChange={() => onToggle(r.key)} />
|
||||||
|
<span className="mn">{r.mnemonic}</span>
|
||||||
|
<span className="ds">{r.label}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 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()
|
||||||
|
}
|
||||||
23
frontend/src/components/Section.tsx
Normal file
23
frontend/src/components/Section.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="sec">
|
||||||
|
<div className={`sec-h${open ? '' : ' closed'}`} onClick={() => setOpen(o => !o)}>
|
||||||
|
<span className="chev">▼</span>
|
||||||
|
<span>{title}</span>
|
||||||
|
{count != null && <span className="cnt">{count}</span>}
|
||||||
|
</div>
|
||||||
|
{open && <div className="sec-b">{children}</div>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
296
frontend/src/components/Viewer.tsx
Normal file
296
frontend/src/components/Viewer.tsx
Normal file
@@ -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<HTMLDivElement>(null)
|
||||||
|
const cache = useRef<Map<number, string>>(new Map())
|
||||||
|
const requested = useRef<Set<number>>(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<EventSource | null>(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<SearchMatch[]>([])
|
||||||
|
const [searching, setSearching] = useState(false)
|
||||||
|
const [scanned, setScanned] = useState(0)
|
||||||
|
const searchEs = useRef<EventSource | null>(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 (
|
||||||
|
<>
|
||||||
|
<div className="viewer-bar">
|
||||||
|
<button className="btn primary" onClick={togglePlay} disabled={available === 0}>
|
||||||
|
{playing ? '⏸ Pause' : '▶ Stream'}
|
||||||
|
</button>
|
||||||
|
<div className="speed" title={`${fmtNum(linesPerSec)} lines/sec`}>
|
||||||
|
<span>🐌</span>
|
||||||
|
<input type="range" min={0} max={100} value={speedV}
|
||||||
|
onChange={(e) => setSpeedV(parseInt(e.target.value, 10))} />
|
||||||
|
<span>🚀</span>
|
||||||
|
<span style={{ fontFamily: 'var(--mono)', width: 72, textAlign: 'right' }}>{fmtNum(linesPerSec)}/s</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sep" />
|
||||||
|
|
||||||
|
<button className="btn ghost" onClick={() => jumpTo(0)} disabled={available === 0}>⤒ Top</button>
|
||||||
|
{dataStart >= 0 && (
|
||||||
|
<button className="btn ghost" onClick={() => jumpTo(dataStart)} title={`line ${dataStart + 1}`}>≈ Data</button>
|
||||||
|
)}
|
||||||
|
<button className="btn ghost" onClick={() => jumpTo(available - 1, 'center')} disabled={available === 0}>⤓ End</button>
|
||||||
|
|
||||||
|
<input className="field sm" placeholder="go to line…" value={gotoVal}
|
||||||
|
onChange={(e) => setGotoVal(e.target.value)}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') onGoto() }} />
|
||||||
|
<button className="btn" onClick={onGoto}>Go</button>
|
||||||
|
|
||||||
|
<div className="sep" />
|
||||||
|
|
||||||
|
<input className="field" style={{ width: 160 }} placeholder="search text…" value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') runSearch() }} />
|
||||||
|
{searching
|
||||||
|
? <button className="btn" onClick={cancelSearch}><span className="spin" /> Stop</button>
|
||||||
|
: <button className="btn" onClick={runSearch}>Search</button>}
|
||||||
|
|
||||||
|
<div className="grow" />
|
||||||
|
<span className="stat" style={{ color: 'var(--txt-dim)', fontSize: 12, fontFamily: 'var(--mono)' }}>
|
||||||
|
{file.status === 'INDEXING' && <span style={{ color: 'var(--accent-2)' }}>indexing… </span>}
|
||||||
|
line {fmtNum(topLine + 1)} / {fmtNum(available)} · {posPct.toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="viewport" ref={parentRef}>
|
||||||
|
{available === 0 ? (
|
||||||
|
<div className="empty" style={{ height: '100%' }}>
|
||||||
|
<div>
|
||||||
|
<div className="spin" style={{ width: 18, height: 18 }} />
|
||||||
|
<div style={{ marginTop: 10 }}>
|
||||||
|
{file.status === 'ERROR' ? (file.error || 'Failed to index file') : 'Indexing file…'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rows" style={{ height: totalSize }}>
|
||||||
|
{items.map(vi => {
|
||||||
|
const line = vi.index
|
||||||
|
const text = cache.current.get(line)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={line}
|
||||||
|
className={`row${line === currentLine ? ' cur' : ''}${matchSet.has(line) ? ' hit' : ''}`}
|
||||||
|
style={{ transform: `translateY(${vi.start}px)`, height: vi.size }}
|
||||||
|
>
|
||||||
|
<span className="gutter">{fmtNum(line + 1)}</span>
|
||||||
|
<span className={lineClass(text, line, dataStart)}>
|
||||||
|
{text ?? '⋯'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(matches.length > 0 || searching) && (
|
||||||
|
<div className="search-results scroll">
|
||||||
|
<div className="sr-row" style={{ position: 'sticky', top: 0, background: 'var(--bg-2)', cursor: 'default' }}>
|
||||||
|
<span className="ln">{searching ? <><span className="spin" /> </> : null}</span>
|
||||||
|
<span className="tx">
|
||||||
|
{matches.length} match{matches.length === 1 ? '' : 'es'}
|
||||||
|
{searching ? ` · scanned ${fmtNum(scanned)} lines…` : ''}
|
||||||
|
{matches.length >= 500 ? ' (showing first 500)' : ''}
|
||||||
|
</span>
|
||||||
|
<span className="ln" style={{ cursor: 'pointer' }} onClick={() => setMatches([])}>clear</span>
|
||||||
|
</div>
|
||||||
|
{matches.map((m, i) => (
|
||||||
|
<div key={`${m.line}-${i}`} className="sr-row" onClick={() => jumpTo(m.line, 'center')}>
|
||||||
|
<span className="ln">{fmtNum(m.line + 1)}</span>
|
||||||
|
<span className="tx">{m.text}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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'
|
||||||
|
}
|
||||||
61
frontend/src/components/WellInfo.tsx
Normal file
61
frontend/src/components/WellInfo.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="wi">
|
||||||
|
<h2>{meta.summary.wellName || file.name}</h2>
|
||||||
|
{get('API') && <div className="api">API {get('API')}</div>}
|
||||||
|
<dl className="wi-grid">
|
||||||
|
{rows.map(([l, v]) => (<Fragment key={l}><dt>{l}</dt><dd title={v}>{v}</dd></Fragment>))}
|
||||||
|
</dl>
|
||||||
|
<div className="wi-chips">
|
||||||
|
<span className="wi-chip"><b>{fmtNum(meta.curves.length)}</b> curves</span>
|
||||||
|
<span className="wi-chip"><b>{fmtNum(file.availableLines)}</b> rows</span>
|
||||||
|
<span className="wi-chip"><b>{fmtBytes(file.sizeBytes)}</b></span>
|
||||||
|
{meta.nullValue && <span className="wi-chip">NULL <b>{meta.nullValue}</b></span>}
|
||||||
|
{meta.wrap && <span className="wi-chip">WRAP <b>{meta.wrap}</b></span>}
|
||||||
|
</div>
|
||||||
|
{(strt || stop) && (
|
||||||
|
<div className="wi-chips">
|
||||||
|
{strt && <span className="wi-chip">STRT <b>{fmtIdx(strt, idxUnit)}</b></span>}
|
||||||
|
{stop && <span className="wi-chip">STOP <b>{fmtIdx(stop, idxUnit)}</b></span>}
|
||||||
|
{step && step !== '0' && <span className="wi-chip">STEP <b>{step} {idxUnit}</b></span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 : ''}`
|
||||||
|
}
|
||||||
43
frontend/src/las.ts
Normal file
43
frontend/src/las.ts
Normal file
@@ -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<string, string> {
|
||||||
|
const m = new Map<string, string>()
|
||||||
|
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<string, string> = {
|
||||||
|
mechanics: '#4aa3ff',
|
||||||
|
hydraulics: '#36c6a0',
|
||||||
|
gas: '#e0a23c',
|
||||||
|
directional: '#b48ead',
|
||||||
|
index: '#8696a8',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GROUP_LABEL: Record<string, string> = {
|
||||||
|
mechanics: 'MECH', hydraulics: 'HYD', gas: 'GAS', directional: 'DIR', index: 'IDX',
|
||||||
|
}
|
||||||
10
frontend/src/main.tsx
Normal file
10
frontend/src/main.tsx
Normal file
@@ -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(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>,
|
||||||
|
)
|
||||||
394
frontend/src/styles.css
Normal file
394
frontend/src/styles.css
Normal file
@@ -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; }
|
||||||
131
frontend/src/types.ts
Normal file
131
frontend/src/types.ts
Normal file
@@ -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
|
||||||
|
}
|
||||||
20
frontend/tsconfig.json
Normal file
20
frontend/tsconfig.json
Normal file
@@ -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"]
|
||||||
|
}
|
||||||
22
frontend/vite.config.ts
Normal file
22
frontend/vite.config.ts
Normal file
@@ -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,
|
||||||
|
},
|
||||||
|
})
|
||||||
78
pom.xml
Normal file
78
pom.xml
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
|
<groupId>com.oiusa</groupId>
|
||||||
|
<artifactId>las-stream-viewer</artifactId>
|
||||||
|
<version>1.0.0</version>
|
||||||
|
<packaging>jar</packaging>
|
||||||
|
<name>LAS Stream Viewer (Quarkus)</name>
|
||||||
|
<description>Streams very large (10GB+) LAS well-log files line by line, with a React UI.</description>
|
||||||
|
|
||||||
|
<properties>
|
||||||
|
<maven.compiler.release>21</maven.compiler.release>
|
||||||
|
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||||
|
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
|
||||||
|
<quarkus.platform.group-id>io.quarkus.platform</quarkus.platform.group-id>
|
||||||
|
<quarkus.platform.artifact-id>quarkus-bom</quarkus.platform.artifact-id>
|
||||||
|
<quarkus.platform.version>3.34.3</quarkus.platform.version>
|
||||||
|
<skipITs>true</skipITs>
|
||||||
|
</properties>
|
||||||
|
|
||||||
|
<dependencyManagement>
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>${quarkus.platform.group-id}</groupId>
|
||||||
|
<artifactId>${quarkus.platform.artifact-id}</artifactId>
|
||||||
|
<version>${quarkus.platform.version}</version>
|
||||||
|
<type>pom</type>
|
||||||
|
<scope>import</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
</dependencyManagement>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<!-- Quarkus REST (Jakarta REST / RESTEasy Reactive). SSE ships here. -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.quarkus</groupId>
|
||||||
|
<artifactId>quarkus-rest</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.quarkus</groupId>
|
||||||
|
<artifactId>quarkus-rest-jackson</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<!-- ManagedExecutor for background indexing / streaming loops. -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.quarkus</groupId>
|
||||||
|
<artifactId>quarkus-smallrye-context-propagation</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.quarkus</groupId>
|
||||||
|
<artifactId>quarkus-junit5</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
|
||||||
|
<build>
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>${quarkus.platform.group-id}</groupId>
|
||||||
|
<artifactId>quarkus-maven-plugin</artifactId>
|
||||||
|
<version>${quarkus.platform.version}</version>
|
||||||
|
<extensions>true</extensions>
|
||||||
|
<executions>
|
||||||
|
<execution>
|
||||||
|
<goals>
|
||||||
|
<goal>build</goal>
|
||||||
|
<goal>generate-code</goal>
|
||||||
|
<goal>generate-code-tests</goal>
|
||||||
|
</goals>
|
||||||
|
</execution>
|
||||||
|
</executions>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
</project>
|
||||||
18
run.ps1
Normal file
18
run.ps1
Normal file
@@ -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
|
||||||
70
src/main/java/com/oiusa/las/index/LineIndex.java
Normal file
70
src/main/java/com/oiusa/las/index/LineIndex.java
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
package com.oiusa.las.index;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sparse byte-offset index over the lines of a file.
|
||||||
|
*
|
||||||
|
* <p>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).
|
||||||
|
*
|
||||||
|
* <p>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;
|
||||||
|
}
|
||||||
|
}
|
||||||
67
src/main/java/com/oiusa/las/index/LineReader.java
Normal file
67
src/main/java/com/oiusa/las/index/LineReader.java
Normal file
@@ -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}.
|
||||||
|
*
|
||||||
|
* <p>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<String> 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<String> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
188
src/main/java/com/oiusa/las/index/Pyramid.java
Normal file
188
src/main/java/com/oiusa/las/index/Pyramid.java
Normal file
@@ -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 <b>min/max per pixel, never averaging</b>.
|
||||||
|
*
|
||||||
|
* <p>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<float[]> bMin = new ArrayList<>();
|
||||||
|
private final List<float[]> bMax = new ArrayList<>();
|
||||||
|
private final List<float[]> bMean = new ArrayList<>(); // per-bucket mean (for crossplots/stats)
|
||||||
|
private final List<double[]> axis = new ArrayList<>(); // [time, depth] per bucket
|
||||||
|
private final List<long[]> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
103
src/main/java/com/oiusa/las/index/RowParser.java
Normal file
103
src/main/java/com/oiusa/las/index/RowParser.java
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
14
src/main/java/com/oiusa/las/model/Curve.java
Normal file
14
src/main/java/com/oiusa/las/model/Curve.java
Normal file
@@ -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) {
|
||||||
|
}
|
||||||
13
src/main/java/com/oiusa/las/model/HeaderSection.java
Normal file
13
src/main/java/com/oiusa/las/model/HeaderSection.java
Normal file
@@ -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<String> lines) {
|
||||||
|
}
|
||||||
74
src/main/java/com/oiusa/las/model/LasFile.java
Normal file
74
src/main/java/com/oiusa/las/model/LasFile.java
Normal file
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>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<HeaderSection> sections = List.of();
|
||||||
|
public volatile List<Curve> curves = List.of();
|
||||||
|
/** Column names parsed from the {@code ~A} line, in data order. */
|
||||||
|
public volatile List<String> 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<String, ResolvedRole> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
16
src/main/java/com/oiusa/las/model/ResolvedRole.java
Normal file
16
src/main/java/com/oiusa/las/model/ResolvedRole.java
Normal file
@@ -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) {
|
||||||
|
}
|
||||||
153
src/main/java/com/oiusa/las/service/ChannelRoles.java
Normal file
153
src/main/java/com/oiusa/las/service/ChannelRoles.java
Normal file
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>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<String> aliases) {}
|
||||||
|
|
||||||
|
/** group keys, in display order */
|
||||||
|
public static final List<String> 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<RoleDef> 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<String, ResolvedRole> resolve(List<Curve> curves) {
|
||||||
|
// index curves by upper mnemonic
|
||||||
|
Map<String, Curve> byMnem = new LinkedHashMap<>();
|
||||||
|
for (Curve c : curves) byMnem.put(c.mnemonic().toUpperCase(Locale.ROOT), c);
|
||||||
|
|
||||||
|
Map<String, ResolvedRole> 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<Curve> 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<String, ResolvedRole> roles, int... extra) {
|
||||||
|
java.util.TreeSet<Integer> 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<List<String>> defaultTracks() {
|
||||||
|
List<List<String>> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
283
src/main/java/com/oiusa/las/service/CurveDataService.java
Normal file
283
src/main/java/com/oiusa/las/service/CurveDataService.java
Normal file
@@ -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<Series> 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<double[]> 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<double[]> 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<ReqCurve> 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<ReqCurve> 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<ReqCurve> 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<Integer> 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<ReqCurve> req, double[][] mn, double[][] mx, double[] dMin, double[] dMax) {
|
||||||
|
List<Series> 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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
128
src/main/java/com/oiusa/las/service/FileStore.java
Normal file
128
src/main/java/com/oiusa/las/service/FileStore.java
Normal file
@@ -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 <em>in place</em>
|
||||||
|
* (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<String, LasFile> files = new ConcurrentHashMap<>();
|
||||||
|
private Path dataDir;
|
||||||
|
private Path uploadsDir;
|
||||||
|
private List<Path> 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<String> allowedRoots() {
|
||||||
|
List<String> 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<LasFile> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
231
src/main/java/com/oiusa/las/service/IndexService.java
Normal file
231
src/main/java/com/oiusa/las/service/IndexService.java
Normal file
@@ -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<String> 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<String> 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<String> headerLines, long dataStartLine) {
|
||||||
|
List<String> dataColumns = List.of();
|
||||||
|
List<String> 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<String, ResolvedRole> 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<String, ResolvedRole> 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<String> parseDataColumns(String aLine) {
|
||||||
|
String[] tok = aLine.trim().split("\\s+");
|
||||||
|
List<String> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
94
src/main/java/com/oiusa/las/service/LasHeaderParser.java
Normal file
94
src/main/java/com/oiusa/las/service/LasHeaderParser.java
Normal file
@@ -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:
|
||||||
|
* <pre>
|
||||||
|
* MNEM.UNIT DATA / API CODE : DESCRIPTION
|
||||||
|
* WELL. LUSCOMBRE 9H : Well
|
||||||
|
* TIME .seconds : 1 Time Logged
|
||||||
|
* </pre>
|
||||||
|
* Comment lines start with '#'; section headers start with '~'.
|
||||||
|
*/
|
||||||
|
public final class LasHeaderParser {
|
||||||
|
|
||||||
|
public record Result(List<HeaderSection> sections, List<Curve> 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<String> headerLines, List<String> dataColumns) {
|
||||||
|
List<HeaderSection> sections = new ArrayList<>();
|
||||||
|
List<Curve> curves = new ArrayList<>();
|
||||||
|
String wrap = null, nullValue = null, wellName = null;
|
||||||
|
|
||||||
|
String currentName = null;
|
||||||
|
List<String> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
111
src/main/java/com/oiusa/las/service/UploadService.java
Normal file
111
src/main/java/com/oiusa/las/service/UploadService.java
Normal file
@@ -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<String, Session> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
156
src/main/java/com/oiusa/las/web/CurveResource.java
Normal file
156
src/main/java/com/oiusa/las/web/CurveResource.java
Normal file
@@ -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<Dtos.RoleInfo> 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<CurveDataService.ReqCurve> 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<Dtos.CurveSeriesDto> 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<Double> with NaN mapped to null (so the JSON is valid and gaps are explicit). */
|
||||||
|
private static List<Double> toList(double[] a) {
|
||||||
|
List<Double> 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<String, String> error(String msg) {
|
||||||
|
return java.util.Map.of("error", msg == null ? "error" : msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
78
src/main/java/com/oiusa/las/web/Dtos.java
Normal file
78
src/main/java/com/oiusa/las/web/Dtos.java
Normal file
@@ -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<HeaderSection> sections,
|
||||||
|
List<Curve> curves,
|
||||||
|
List<String> 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<String> lines, boolean eof, long availableLines) {}
|
||||||
|
|
||||||
|
public record ConfigResponse(List<String> 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<BrowseEntry> 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<RoleInfo> roles, List<List<String>> defaultTracks) {}
|
||||||
|
|
||||||
|
public record CurveSeriesDto(String key, String mnemonic, String unit, int column,
|
||||||
|
List<Double> min, List<Double> max, Double dataMin, Double dataMax) {}
|
||||||
|
|
||||||
|
public record CurveDataDto(String axis, boolean detail, double from, double to, int n,
|
||||||
|
List<Double> pos, List<CurveSeriesDto> curves) {}
|
||||||
|
|
||||||
|
public record CrossDataDto(List<Double> x, List<Double> y, List<Double> c,
|
||||||
|
double[] xRange, double[] yRange, double[] cRange,
|
||||||
|
int total, int returned, boolean onBottomFiltered) {}
|
||||||
|
}
|
||||||
139
src/main/java/com/oiusa/las/web/FileResource.java
Normal file
139
src/main/java/com/oiusa/las/web/FileResource.java
Normal file
@@ -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<Dtos.FileSummary> list() {
|
||||||
|
List<Dtos.FileSummary> 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<Dtos.BrowseEntry> entries = new ArrayList<>();
|
||||||
|
try (Stream<Path> 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<String, String> error(String msg) {
|
||||||
|
return java.util.Map.of("error", msg == null ? "error" : msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
51
src/main/java/com/oiusa/las/web/LinesResource.java
Normal file
51
src/main/java/com/oiusa/las/web/LinesResource.java
Normal file
@@ -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<String, String> error(String msg) {
|
||||||
|
return java.util.Map.of("error", msg == null ? "error" : msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
109
src/main/java/com/oiusa/las/web/SearchResource.java
Normal file
109
src/main/java/com/oiusa/las/web/SearchResource.java
Normal file
@@ -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) { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
124
src/main/java/com/oiusa/las/web/StreamResource.java
Normal file
124
src/main/java/com/oiusa/las/web/StreamResource.java
Normal file
@@ -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<String> 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<String> 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) { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
99
src/main/java/com/oiusa/las/web/UploadResource.java
Normal file
99
src/main/java/com/oiusa/las/web/UploadResource.java
Normal file
@@ -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:
|
||||||
|
* <ol>
|
||||||
|
* <li>{@code POST /api/uploads/init} {name,size} → uploadId</li>
|
||||||
|
* <li>{@code PUT /api/uploads/{id}/chunk?offset=N} (octet-stream body) → received high-water mark</li>
|
||||||
|
* <li>{@code GET /api/uploads/{id}} → {received,size} (to resume after an interruption)</li>
|
||||||
|
* <li>{@code POST /api/uploads/{id}/complete} → registers the file and starts indexing</li>
|
||||||
|
* </ol>
|
||||||
|
*/
|
||||||
|
@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<String, String> error(String msg) {
|
||||||
|
return java.util.Map.of("error", msg == null ? "error" : msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
28
src/main/resources/application.properties
Normal file
28
src/main/resources/application.properties
Normal file
@@ -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
|
||||||
Reference in New Issue
Block a user