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

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