Initial commit: LAS Stream Viewer (Quarkus backend + React log-plot UI)

This commit is contained in:
2026-06-02 15:49:29 +05:30
commit acdbb8b340
47 changed files with 6870 additions and 0 deletions

21
.gitignore vendored Normal file
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

23
frontend/package.json Normal file
View 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
View 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&nbsp;GB+ logs.
</div>
</div>
</div>}
</div>
{error && <div className="toast"> {error}</div>}
</div>
)
}

86
frontend/src/api.ts Normal file
View 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')
}

View 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>
)
}

View 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})`
}

View 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>
)
})}
</>
)
}

View 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>
)
}

View 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&nbsp;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>
)
}

View 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()
}

View 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>
)
}

View 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'
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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

View 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;
}
}

View 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);
}
}

View 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;
}
}

View 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);
}
}

View 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) {
}

View 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) {
}

View 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;
}
}

View 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) {
}

View 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;
}
}

View 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 (1st99th 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; }
}
}

View 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);
}
}
}

View 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;
}
}

View 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);
}
}

View 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;
}
}

View 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);
}
}

View 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) {}
}

View 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);
}
}

View 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);
}
}

View 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) { }
}
}
}

View 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) { }
}
}
}

View 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);
}
}

View 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