Initial commit: LAS Stream Viewer (Quarkus backend + React log-plot UI)
This commit is contained in:
139
frontend/src/App.tsx
Normal file
139
frontend/src/App.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { api, fmtBytes, fmtNum } from './api'
|
||||
import type { AppConfig, FileMeta, FileSummary } from './types'
|
||||
import IngestPanel from './components/IngestPanel'
|
||||
import FileList from './components/FileList'
|
||||
import HeaderPanel from './components/HeaderPanel'
|
||||
import Viewer from './components/Viewer'
|
||||
import LogPlot from './components/LogPlot'
|
||||
import Crossplot from './components/Crossplot'
|
||||
import Section from './components/Section'
|
||||
import WellInfo from './components/WellInfo'
|
||||
import ChannelList from './components/ChannelList'
|
||||
|
||||
export default function App() {
|
||||
const [config, setConfig] = useState<AppConfig | null>(null)
|
||||
const [files, setFiles] = useState<FileSummary[]>([])
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||
const [meta, setMeta] = useState<FileMeta | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [mainTab, setMainTab] = useState<'plot' | 'cross' | 'raw'>('plot')
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true)
|
||||
const errTimer = useRef<number | undefined>(undefined)
|
||||
|
||||
const showError = useCallback((e: unknown) => {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
setError(msg)
|
||||
window.clearTimeout(errTimer.current)
|
||||
errTimer.current = window.setTimeout(() => setError(null), 5000)
|
||||
}, [])
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
setFiles(await api.listFiles())
|
||||
} catch (e) { /* keep last good list; transient */ }
|
||||
}, [])
|
||||
|
||||
// initial config + poll the file list so indexing progress / status stay live
|
||||
useEffect(() => {
|
||||
api.config().then(setConfig).catch(showError)
|
||||
refresh()
|
||||
const t = window.setInterval(refresh, 1500)
|
||||
return () => window.clearInterval(t)
|
||||
}, [refresh, showError])
|
||||
|
||||
const selected = files.find(f => f.id === selectedId) ?? null
|
||||
|
||||
// load metadata once the header is parsed (and refresh if it flips ready)
|
||||
const headerReady = selected?.headerReady
|
||||
useEffect(() => {
|
||||
if (!selectedId || !headerReady) { setMeta(null); return }
|
||||
let alive = true
|
||||
api.meta(selectedId).then(m => { if (alive) setMeta(m) }).catch(showError)
|
||||
return () => { alive = false }
|
||||
}, [selectedId, headerReady, showError])
|
||||
|
||||
const onOpened = useCallback((f: FileSummary) => {
|
||||
setSelectedId(f.id)
|
||||
refresh()
|
||||
}, [refresh])
|
||||
|
||||
const onRemove = useCallback(async (id: string) => {
|
||||
try {
|
||||
await api.remove(id)
|
||||
if (selectedId === id) { setSelectedId(null); setMeta(null) }
|
||||
refresh()
|
||||
} catch (e) { showError(e) }
|
||||
}, [selectedId, refresh, showError])
|
||||
|
||||
const totalIndexed = files.reduce((a, f) => a + (f.availableLines || 0), 0)
|
||||
|
||||
return (
|
||||
<div className={`app${sidebarOpen ? '' : ' nosidebar'}`}>
|
||||
<div className="topbar">
|
||||
<button className="sidebar-toggle" onClick={() => setSidebarOpen(o => !o)}
|
||||
title={sidebarOpen ? 'Hide side panel' : 'Show side panel'}>
|
||||
{sidebarOpen ? '⮜' : '☰'}
|
||||
</button>
|
||||
<div className="logo">LS</div>
|
||||
<div>
|
||||
<h1>LAS Stream Viewer</h1>
|
||||
</div>
|
||||
<span className="sub">large-file well-log line streaming</span>
|
||||
<div className="spacer" />
|
||||
<span className="stat">{files.length} file{files.length === 1 ? '' : 's'} · {fmtNum(totalIndexed)} lines indexed</span>
|
||||
</div>
|
||||
|
||||
<div className="sidebar scroll">
|
||||
<IngestPanel config={config} onOpened={onOpened} onError={showError} collapsedDefault={files.length > 0} />
|
||||
<Section title="Files" count={files.length} defaultOpen>
|
||||
<FileList files={files} selectedId={selectedId} onSelect={setSelectedId} onRemove={onRemove} />
|
||||
</Section>
|
||||
{meta && selected && (
|
||||
<>
|
||||
<Section title="Well" defaultOpen>
|
||||
<WellInfo meta={meta} file={selected} />
|
||||
</Section>
|
||||
<Section title="Channels" count={meta.curves.length} defaultOpen={false}>
|
||||
<ChannelList fileId={selected.id} curves={meta.curves} />
|
||||
</Section>
|
||||
<Section title="Header (raw)" defaultOpen={false}>
|
||||
<HeaderPanel meta={meta} />
|
||||
</Section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="main">
|
||||
{selected
|
||||
? <>
|
||||
<div className="viewer-bar" style={{ padding: '0 8px', minHeight: 38 }}>
|
||||
<div className="main-tabs">
|
||||
<button className={mainTab === 'plot' ? 'on' : ''} onClick={() => setMainTab('plot')}>📈 Log Plot</button>
|
||||
<button className={mainTab === 'cross' ? 'on' : ''} onClick={() => setMainTab('cross')}>⊕ Crossplot</button>
|
||||
<button className={mainTab === 'raw' ? 'on' : ''} onClick={() => setMainTab('raw')}>𝍌 Raw / QC</button>
|
||||
</div>
|
||||
<div className="grow" />
|
||||
<span className="stat" style={{ fontSize: 12, color: 'var(--txt-faint)' }}>{selected.name}</span>
|
||||
</div>
|
||||
<div style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
|
||||
{mainTab === 'plot' && <LogPlot file={selected} onError={showError} />}
|
||||
{mainTab === 'cross' && <Crossplot file={selected} onError={showError} />}
|
||||
{mainTab === 'raw' && <Viewer file={selected} meta={meta} onError={showError} />}
|
||||
</div>
|
||||
</>
|
||||
: <div className="empty">
|
||||
<div>
|
||||
<div className="e-ic">𝍌</div>
|
||||
<div>Open a LAS file to begin.</div>
|
||||
<div className="muted" style={{ marginTop: 6, fontSize: 12 }}>
|
||||
Upload a file or open one already on disk — handles 10 GB+ logs.
|
||||
</div>
|
||||
</div>
|
||||
</div>}
|
||||
</div>
|
||||
|
||||
{error && <div className="toast">⚠ {error}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
86
frontend/src/api.ts
Normal file
86
frontend/src/api.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import type {
|
||||
AppConfig, BrowseResponse, CrossData, CurveData, FileMeta, FileSummary, LinesResponse, RolesResponse,
|
||||
} from './types'
|
||||
|
||||
async function j<T>(res: Response): Promise<T> {
|
||||
if (!res.ok) {
|
||||
let msg = `HTTP ${res.status}`
|
||||
try {
|
||||
const body = await res.json()
|
||||
if (body?.error) msg = body.error
|
||||
} catch { /* ignore */ }
|
||||
throw new Error(msg)
|
||||
}
|
||||
return res.json() as Promise<T>
|
||||
}
|
||||
|
||||
export const api = {
|
||||
config: () => fetch('/api/files/config').then(r => j<AppConfig>(r)),
|
||||
|
||||
listFiles: () => fetch('/api/files').then(r => j<FileSummary[]>(r)),
|
||||
|
||||
meta: (id: string) => fetch(`/api/files/${id}`).then(r => j<FileMeta>(r)),
|
||||
|
||||
browse: (dir?: string) =>
|
||||
fetch(`/api/files/browse${dir ? `?dir=${encodeURIComponent(dir)}` : ''}`).then(r => j<BrowseResponse>(r)),
|
||||
|
||||
openLocal: (path: string) =>
|
||||
fetch('/api/files/local', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path }),
|
||||
}).then(r => j<FileSummary>(r)),
|
||||
|
||||
remove: (id: string) => fetch(`/api/files/${id}`, { method: 'DELETE' }),
|
||||
|
||||
lines: (id: string, start: number, count: number) =>
|
||||
fetch(`/api/files/${id}/lines?start=${start}&count=${count}`).then(r => j<LinesResponse>(r)),
|
||||
|
||||
roles: (id: string) => fetch(`/api/files/${id}/roles`).then(r => j<RolesResponse>(r)),
|
||||
|
||||
curveData: (id: string, axis: 'time' | 'depth', curves: string[], from: number, to: number, width: number) => {
|
||||
const p = new URLSearchParams({ axis, curves: curves.join(','), width: String(Math.round(width)) })
|
||||
if (Number.isFinite(from)) p.set('from', String(from))
|
||||
if (Number.isFinite(to)) p.set('to', String(to))
|
||||
return fetch(`/api/files/${id}/curve-data?${p}`).then(r => j<CurveData>(r))
|
||||
},
|
||||
|
||||
crossplot: (id: string, x: string, y: string, color: string, onBottom: boolean, max = 5000) => {
|
||||
const p = new URLSearchParams({ x, y, color, onBottom: String(onBottom), max: String(max) })
|
||||
return fetch(`/api/files/${id}/crossplot?${p}`).then(r => j<CrossData>(r))
|
||||
},
|
||||
|
||||
// --- chunked upload ---
|
||||
uploadInit: (name: string, size: number) =>
|
||||
fetch('/api/uploads/init', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, size }),
|
||||
}).then(r => j<{ uploadId: string; received: number }>(r)),
|
||||
|
||||
uploadStatus: (id: string) =>
|
||||
fetch(`/api/uploads/${id}`).then(r => j<{ uploadId: string; received: number; size: number }>(r)),
|
||||
|
||||
uploadChunk: (id: string, offset: number, blob: Blob) =>
|
||||
fetch(`/api/uploads/${id}/chunk?offset=${offset}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/octet-stream' },
|
||||
body: blob,
|
||||
}).then(r => j<{ received: number }>(r)),
|
||||
|
||||
uploadComplete: (id: string) =>
|
||||
fetch(`/api/uploads/${id}/complete`, { method: 'POST' }).then(r => j<FileSummary>(r)),
|
||||
}
|
||||
|
||||
export function fmtBytes(n: number): string {
|
||||
if (n < 1024) return `${n} B`
|
||||
const u = ['KB', 'MB', 'GB', 'TB']
|
||||
let v = n / 1024
|
||||
let i = 0
|
||||
while (v >= 1024 && i < u.length - 1) { v /= 1024; i++ }
|
||||
return `${v.toFixed(v < 10 ? 2 : 1)} ${u[i]}`
|
||||
}
|
||||
|
||||
export function fmtNum(n: number): string {
|
||||
return n.toLocaleString('en-US')
|
||||
}
|
||||
67
frontend/src/components/ChannelList.tsx
Normal file
67
frontend/src/components/ChannelList.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { api } from '../api'
|
||||
import { GROUP_COLOR, GROUP_LABEL } from '../las'
|
||||
import type { Curve, RoleInfo } from '../types'
|
||||
|
||||
interface Props { fileId: string; curves: Curve[] }
|
||||
|
||||
/** Searchable channel browser with drilling-role badges; replaces the raw 426-row curve dump. */
|
||||
export default function ChannelList({ fileId, curves }: Props) {
|
||||
const [roleByMnem, setRoleByMnem] = useState<Map<string, RoleInfo>>(new Map())
|
||||
const [q, setQ] = useState('')
|
||||
const [drillingOnly, setDrillingOnly] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
setRoleByMnem(new Map())
|
||||
api.roles(fileId).then(r => {
|
||||
if (!alive) return
|
||||
const m = new Map<string, RoleInfo>()
|
||||
r.roles.forEach(role => m.set(role.mnemonic.toUpperCase(), role))
|
||||
setRoleByMnem(m)
|
||||
}).catch(() => {})
|
||||
return () => { alive = false }
|
||||
}, [fileId])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const needle = q.trim().toLowerCase()
|
||||
return curves.filter(c => {
|
||||
if (drillingOnly && !roleByMnem.has(c.mnemonic.toUpperCase())) return false
|
||||
if (!needle) return true
|
||||
return c.mnemonic.toLowerCase().includes(needle) || (c.description || '').toLowerCase().includes(needle)
|
||||
})
|
||||
}, [curves, q, drillingOnly, roleByMnem])
|
||||
|
||||
const drillingCount = roleByMnem.size
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="ch-tools">
|
||||
<input className="field" placeholder="search channels…" value={q} onChange={e => setQ(e.target.value)} />
|
||||
</div>
|
||||
<label className="ch-filter">
|
||||
<input type="checkbox" checked={drillingOnly} onChange={e => setDrillingOnly(e.target.checked)} />
|
||||
drilling channels only ({drillingCount})
|
||||
</label>
|
||||
<div className="ch-list scroll">
|
||||
{filtered.map(c => {
|
||||
const role = roleByMnem.get(c.mnemonic.toUpperCase())
|
||||
const color = role ? GROUP_COLOR[role.group] : undefined
|
||||
return (
|
||||
<div className="ch-row" key={c.column} title={c.description}>
|
||||
<span className="mn" style={{ color }}>{c.mnemonic}</span>
|
||||
<span className="u">{c.unit}</span>
|
||||
<span className="d">{c.description}</span>
|
||||
{role && (
|
||||
<span className="badge2" style={{ background: (color || '#888') + '22', color }}>
|
||||
{GROUP_LABEL[role.group]}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{filtered.length === 0 && <div className="muted" style={{ padding: '8px', fontSize: 12 }}>no matches</div>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
293
frontend/src/components/Crossplot.tsx
Normal file
293
frontend/src/components/Crossplot.tsx
Normal file
@@ -0,0 +1,293 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { api, fmtNum } from '../api'
|
||||
import type { CrossData, FileSummary, RoleInfo, RolesResponse } from '../types'
|
||||
|
||||
interface Props { file: FileSummary; onError: (e: unknown) => void }
|
||||
|
||||
const M = { l: 60, r: 84, t: 14, b: 44 } // canvas margins
|
||||
|
||||
export default function Crossplot({ file, onError }: Props) {
|
||||
const [roles, setRoles] = useState<RolesResponse | null>(null)
|
||||
const roleMap = useMemo(() => {
|
||||
const m = new Map<string, RoleInfo>()
|
||||
roles?.roles.forEach(r => m.set(r.key, r))
|
||||
return m
|
||||
}, [roles])
|
||||
const dataRoles = useMemo(() => (roles?.roles ?? []).filter(r => r.group !== 'index'), [roles])
|
||||
const hasOnBottom = useMemo(() => (roles?.roles ?? []).some(r => r.key === 'onBottom'), [roles])
|
||||
|
||||
const [x, setX] = useState('wob')
|
||||
const [y, setY] = useState('rop')
|
||||
const [color, setColor] = useState('depth')
|
||||
const [onBottom, setOnBottom] = useState(false)
|
||||
const [physical, setPhysical] = useState(false)
|
||||
const [data, setData] = useState<CrossData | null>(null)
|
||||
const [hover, setHover] = useState<{ px: number; py: number; i: number } | null>(null)
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const sizeRef = useRef({ w: 800, h: 600 })
|
||||
|
||||
/* roles + sensible defaults */
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
setRoles(null); setData(null); setHover(null)
|
||||
api.roles(file.id).then(r => {
|
||||
if (!alive) return
|
||||
setRoles(r)
|
||||
const keys = new Set(r.roles.map(k => k.key))
|
||||
setX(keys.has('wob') ? 'wob' : (r.roles.find(k => k.group === 'mechanics')?.key ?? r.roles[0]?.key ?? 'wob'))
|
||||
setY(keys.has('rop') ? 'rop' : (r.roles.find(k => k.group === 'mechanics' && k.key !== 'wob')?.key ?? 'rop'))
|
||||
setColor(r.hasDepthAxis ? 'depth' : r.hasTimeAxis ? 'time' : (r.roles[0]?.key ?? 'depth'))
|
||||
setOnBottom(r.roles.some(k => k.key === 'onBottom'))
|
||||
}).catch(onError)
|
||||
return () => { alive = false }
|
||||
}, [file.id, onError])
|
||||
|
||||
useEffect(() => {
|
||||
if (!roles || roles.ready) return
|
||||
const t = window.setInterval(() => { api.roles(file.id).then(r => { if (r.ready) setRoles(r) }).catch(() => {}) }, 1500)
|
||||
return () => window.clearInterval(t)
|
||||
}, [roles, file.id])
|
||||
|
||||
/* fetch crossplot */
|
||||
useEffect(() => {
|
||||
if (!roles?.ready) return
|
||||
let alive = true
|
||||
const t = window.setTimeout(() => {
|
||||
api.crossplot(file.id, x, y, color, onBottom, 6000)
|
||||
.then(d => { if (alive) { setData(d); setHover(null) } })
|
||||
.catch(onError)
|
||||
}, 120)
|
||||
return () => { alive = false; window.clearTimeout(t) }
|
||||
}, [roles, file.id, x, y, color, onBottom, onError])
|
||||
|
||||
/* sizing */
|
||||
useEffect(() => {
|
||||
const el = containerRef.current
|
||||
if (!el) return
|
||||
const ro = new ResizeObserver(() => {
|
||||
sizeRef.current = { w: el.clientWidth, h: el.clientHeight }
|
||||
const c = canvasRef.current
|
||||
if (c) {
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
c.width = Math.round(el.clientWidth * dpr); c.height = Math.round(el.clientHeight * dpr)
|
||||
c.style.width = el.clientWidth + 'px'; c.style.height = el.clientHeight + 'px'
|
||||
}
|
||||
draw()
|
||||
})
|
||||
ro.observe(el)
|
||||
return () => ro.disconnect()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const scaleX = useMemo(() => pickScale(physical, roleMap.get(x), data?.xRange), [physical, roleMap, x, data])
|
||||
const scaleY = useMemo(() => pickScale(physical, roleMap.get(y), data?.yRange), [physical, roleMap, y, data])
|
||||
|
||||
const draw = useCallback(() => {
|
||||
const c = canvasRef.current
|
||||
if (!c) return
|
||||
const ctx = c.getContext('2d')!
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
||||
const { w, h } = sizeRef.current
|
||||
ctx.clearRect(0, 0, w, h); ctx.fillStyle = '#0a0e13'; ctx.fillRect(0, 0, w, h)
|
||||
const pw = w - M.l - M.r, ph = h - M.t - M.b
|
||||
if (pw <= 0 || ph <= 0) return
|
||||
|
||||
const [x0, x1] = scaleX, [y0, y1] = scaleY
|
||||
const px = (v: number) => M.l + ((v - x0) / (x1 - x0)) * pw
|
||||
const py = (v: number) => M.t + ph - ((v - y0) / (y1 - y0)) * ph
|
||||
|
||||
// grid + ticks
|
||||
ctx.font = '10px ui-monospace, monospace'; ctx.fillStyle = '#5c6b7c'
|
||||
ctx.strokeStyle = '#162028'
|
||||
for (const tk of niceTicks(x0, x1, 8)) {
|
||||
const xx = px(tk); if (xx < M.l || xx > w - M.r) continue
|
||||
ctx.beginPath(); ctx.moveTo(xx, M.t); ctx.lineTo(xx, M.t + ph); ctx.stroke()
|
||||
ctx.textAlign = 'center'; ctx.fillText(fmtTick(tk), xx, M.t + ph + 14)
|
||||
}
|
||||
for (const tk of niceTicks(y0, y1, 6)) {
|
||||
const yy = py(tk); if (yy < M.t || yy > M.t + ph) continue
|
||||
ctx.beginPath(); ctx.moveTo(M.l, yy); ctx.lineTo(M.l + pw, yy); ctx.stroke()
|
||||
ctx.textAlign = 'right'; ctx.fillText(fmtTick(tk), M.l - 6, yy + 3)
|
||||
}
|
||||
|
||||
// points
|
||||
const cr = data?.cRange ?? [0, 1]
|
||||
if (data) {
|
||||
for (let i = 0; i < data.x.length; i++) {
|
||||
const vx = data.x[i], vy = data.y[i]
|
||||
if (vx < x0 || vx > x1 || vy < y0 || vy > y1) continue
|
||||
const cv = data.c[i]
|
||||
ctx.fillStyle = cv == null ? '#6b7785' : turbo((cv - cr[0]) / (cr[1] - cr[0]))
|
||||
ctx.globalAlpha = 0.6
|
||||
ctx.beginPath(); ctx.arc(px(vx), py(vy), 2.3, 0, 6.2832); ctx.fill()
|
||||
}
|
||||
ctx.globalAlpha = 1
|
||||
}
|
||||
|
||||
// hovered point
|
||||
if (hover && data) {
|
||||
ctx.strokeStyle = '#fff'; ctx.lineWidth = 1.5
|
||||
ctx.beginPath(); ctx.arc(hover.px, hover.py, 4.5, 0, 6.2832); ctx.stroke()
|
||||
}
|
||||
|
||||
// axis frame + labels
|
||||
ctx.strokeStyle = '#25323f'; ctx.lineWidth = 1
|
||||
ctx.strokeRect(M.l, M.t, pw, ph)
|
||||
ctx.fillStyle = '#d7e0ea'; ctx.font = '12px Inter, sans-serif'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.fillText(axisLabel(roleMap.get(x), x), M.l + pw / 2, h - 6)
|
||||
ctx.save(); ctx.translate(14, M.t + ph / 2); ctx.rotate(-Math.PI / 2)
|
||||
ctx.fillText(axisLabel(roleMap.get(y), y), 0, 0); ctx.restore()
|
||||
|
||||
// colorbar
|
||||
const cbx = w - M.r + 22, cbw = 12, cbh = ph
|
||||
const grad = ctx.createLinearGradient(0, M.t, 0, M.t + cbh)
|
||||
for (let s = 0; s <= 10; s++) grad.addColorStop(s / 10, turbo(1 - s / 10))
|
||||
ctx.fillStyle = grad; ctx.fillRect(cbx, M.t, cbw, cbh)
|
||||
ctx.strokeStyle = '#25323f'; ctx.strokeRect(cbx, M.t, cbw, cbh)
|
||||
ctx.fillStyle = '#8696a8'; ctx.font = '10px ui-monospace, monospace'; ctx.textAlign = 'left'
|
||||
ctx.fillText(fmtColor(cr[1], color), cbx + cbw + 4, M.t + 8)
|
||||
ctx.fillText(fmtColor(cr[0], color), cbx + cbw + 4, M.t + cbh)
|
||||
ctx.save(); ctx.translate(w - 8, M.t + cbh / 2); ctx.rotate(-Math.PI / 2)
|
||||
ctx.fillStyle = '#5c6b7c'; ctx.textAlign = 'center'
|
||||
ctx.fillText(colorLabel(roleMap.get(color), color), 0, 0); ctx.restore()
|
||||
}, [scaleX, scaleY, data, hover, roleMap, x, y, color])
|
||||
|
||||
useEffect(() => { draw() }, [draw])
|
||||
|
||||
/* hover: nearest point in screen space */
|
||||
const onMove = (e: React.MouseEvent) => {
|
||||
if (!data) return
|
||||
const rect = canvasRef.current!.getBoundingClientRect()
|
||||
const mx = e.clientX - rect.left, my = e.clientY - rect.top
|
||||
const { w, h } = sizeRef.current
|
||||
const pw = w - M.l - M.r, ph = h - M.t - M.b
|
||||
const [x0, x1] = scaleX, [y0, y1] = scaleY
|
||||
const px = (v: number) => M.l + ((v - x0) / (x1 - x0)) * pw
|
||||
const py = (v: number) => M.t + ph - ((v - y0) / (y1 - y0)) * ph
|
||||
let best = -1, bd = 100
|
||||
for (let i = 0; i < data.x.length; i++) {
|
||||
const sx = px(data.x[i]), sy = py(data.y[i])
|
||||
const d = (sx - mx) ** 2 + (sy - my) ** 2
|
||||
if (d < bd) { bd = d; best = i }
|
||||
}
|
||||
if (best >= 0) setHover({ px: px(data.x[best]), py: py(data.y[best]), i: best })
|
||||
else setHover(null)
|
||||
}
|
||||
|
||||
if (roles && !roles.ready) {
|
||||
return <div className="empty"><div><div className="spin" style={{ width: 18, height: 18 }} /><div style={{ marginTop: 10 }}>Building curve overview…</div></div></div>
|
||||
}
|
||||
if (roles && dataRoles.length < 2) {
|
||||
return <div className="empty"><div>Not enough numeric channels for a crossplot.</div></div>
|
||||
}
|
||||
|
||||
const rx = roleMap.get(x), ry = roleMap.get(y)
|
||||
|
||||
return (
|
||||
<div className="lp">
|
||||
<div className="viewer-bar">
|
||||
<Sel label="X" value={x} onChange={setX} options={dataRoles} />
|
||||
<Sel label="Y" value={y} onChange={setY} options={dataRoles} />
|
||||
<Sel label="Color" value={color} onChange={setColor} options={dataRoles}
|
||||
extra={[...(roles?.hasDepthAxis ? [['depth', 'Depth']] : []), ...(roles?.hasTimeAxis ? [['time', 'Time']] : [])] as [string, string][]} />
|
||||
<label className="speed" style={{ gap: 5, opacity: hasOnBottom ? 1 : 0.4 }}
|
||||
title={hasOnBottom ? 'restrict to on-bottom drilling buckets' : 'no on-bottom channel in this file'}>
|
||||
<input type="checkbox" checked={onBottom} disabled={!hasOnBottom} onChange={e => setOnBottom(e.target.checked)} /> on-bottom only
|
||||
</label>
|
||||
<label className="speed" style={{ gap: 5 }}>
|
||||
<input type="checkbox" checked={physical} onChange={e => setPhysical(e.target.checked)} /> physical scale
|
||||
</label>
|
||||
<div className="grow" />
|
||||
<span className="stat" style={{ fontFamily: 'var(--mono)', fontSize: 12, color: 'var(--txt-dim)' }}>
|
||||
{data ? `${fmtNum(data.returned)} pts${data.total > data.returned ? ` / ${fmtNum(data.total)}` : ''}${data.onBottomFiltered ? ' · on-btm' : ''}` : '…'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="lp-body" ref={containerRef}>
|
||||
<canvas ref={canvasRef} onMouseMove={onMove} onMouseLeave={() => setHover(null)} style={{ cursor: 'crosshair' }} />
|
||||
{hover && data && (
|
||||
<div className="lp-readout" style={{ left: Math.min(hover.px + 12, sizeRef.current.w - 150), top: Math.max(8, hover.py - 50) }}>
|
||||
<div className="ro"><span className="l">{rx?.mnemonic ?? x}</span><span className="vv">{trim(data.x[hover.i])} {rx?.unit}</span></div>
|
||||
<div className="ro"><span className="l">{ry?.mnemonic ?? y}</span><span className="vv">{trim(data.y[hover.i])} {ry?.unit}</span></div>
|
||||
{data.c[hover.i] != null && <div className="ro"><span className="l">{color === 'time' ? 'time' : color === 'depth' ? 'depth' : (roleMap.get(color)?.mnemonic ?? color)}</span><span className="vv">{fmtColor(data.c[hover.i]!, color)}</span></div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Sel({ label, value, onChange, options, extra = [] }:
|
||||
{ label: string; value: string; onChange: (v: string) => void; options: RoleInfo[]; extra?: [string, string][] }) {
|
||||
return (
|
||||
<label className="speed" style={{ gap: 5 }}>
|
||||
<span style={{ color: 'var(--txt-faint)', fontSize: 12 }}>{label}</span>
|
||||
<select className="field" style={{ padding: '5px 6px' }} value={value} onChange={e => onChange(e.target.value)}>
|
||||
{extra.map(([k, l]) => <option key={k} value={k}>{l}</option>)}
|
||||
{options.map(r => <option key={r.key} value={r.key}>{r.mnemonic} — {r.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
/* ---------- helpers ---------- */
|
||||
function pickScale(physical: boolean, role: RoleInfo | undefined, dataRange?: [number, number]): [number, number] {
|
||||
if (physical && role && role.defMax > role.defMin) return pad(role.defMin, role.defMax)
|
||||
if (dataRange && dataRange[1] > dataRange[0]) return pad(dataRange[0], dataRange[1])
|
||||
if (role && role.defMax > role.defMin) return pad(role.defMin, role.defMax)
|
||||
return [0, 1]
|
||||
}
|
||||
function pad(a: number, b: number): [number, number] { const p = (b - a) * 0.04; return [a - p, b + p] }
|
||||
|
||||
function axisLabel(role: RoleInfo | undefined, key: string): string {
|
||||
if (!role) return key
|
||||
return `${role.label}${role.unit ? ` (${role.unit})` : ''}`
|
||||
}
|
||||
function colorLabel(role: RoleInfo | undefined, key: string): string {
|
||||
if (key === 'depth') return 'Depth (ft)'
|
||||
if (key === 'time') return 'Time'
|
||||
return role ? `${role.label}${role.unit ? ` (${role.unit})` : ''}` : key
|
||||
}
|
||||
function fmtColor(v: number, key: string): string {
|
||||
if (key === 'time') { const d = new Date(v * 1000); return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}` }
|
||||
return fmtTick(v)
|
||||
}
|
||||
function fmtTick(v: number): string {
|
||||
const a = Math.abs(v)
|
||||
if (a >= 1000) return fmtNum(Math.round(v))
|
||||
if (a >= 10) return (Math.round(v * 10) / 10).toString()
|
||||
return (Math.round(v * 100) / 100).toString()
|
||||
}
|
||||
function trim(v: number): string { return Math.abs(v) >= 1000 ? fmtNum(Math.round(v)) : (Math.round(v * 100) / 100).toString() }
|
||||
|
||||
function niceTicks(from: number, to: number, count: number): number[] {
|
||||
const span = to - from
|
||||
if (span <= 0) return []
|
||||
const raw = span / count
|
||||
const mag = Math.pow(10, Math.floor(Math.log10(raw)))
|
||||
const norm = raw / mag
|
||||
const step = (norm < 1.5 ? 1 : norm < 3 ? 2 : norm < 7 ? 5 : 10) * mag
|
||||
const start = Math.ceil(from / step) * step
|
||||
const out: number[] = []
|
||||
for (let v = start; v <= to; v += step) out.push(v)
|
||||
return out
|
||||
}
|
||||
|
||||
/** Turbo-ish colormap, t in [0,1]. */
|
||||
function turbo(t: number): string {
|
||||
t = Math.max(0, Math.min(1, t))
|
||||
const stops: [number, number, number][] = [
|
||||
[48, 18, 59], [62, 73, 213], [38, 188, 224], [62, 214, 130],
|
||||
[180, 222, 44], [252, 184, 41], [233, 90, 35], [122, 4, 3],
|
||||
]
|
||||
const s = t * (stops.length - 1)
|
||||
const i = Math.floor(s), f = s - i
|
||||
const a = stops[i], b = stops[Math.min(stops.length - 1, i + 1)]
|
||||
const r = Math.round(a[0] + (b[0] - a[0]) * f)
|
||||
const g = Math.round(a[1] + (b[1] - a[1]) * f)
|
||||
const bl = Math.round(a[2] + (b[2] - a[2]) * f)
|
||||
return `rgb(${r},${g},${bl})`
|
||||
}
|
||||
45
frontend/src/components/FileList.tsx
Normal file
45
frontend/src/components/FileList.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { fmtBytes, fmtNum } from '../api'
|
||||
import type { FileSummary } from '../types'
|
||||
|
||||
interface Props {
|
||||
files: FileSummary[]
|
||||
selectedId: string | null
|
||||
onSelect: (id: string) => void
|
||||
onRemove: (id: string) => void
|
||||
}
|
||||
|
||||
export default function FileList({ files, selectedId, onSelect, onRemove }: Props) {
|
||||
return (
|
||||
<>
|
||||
{files.length === 0 && <div className="muted" style={{ fontSize: 12.5 }}>No files yet.</div>}
|
||||
{files.map(f => {
|
||||
const pct = f.sizeBytes > 0 ? Math.min(100, (f.indexedBytes / f.sizeBytes) * 100) : 0
|
||||
return (
|
||||
<div
|
||||
key={f.id}
|
||||
className={`fcard${f.id === selectedId ? ' sel' : ''}`}
|
||||
onClick={() => onSelect(f.id)}
|
||||
>
|
||||
<div className="x" title="Remove" onClick={(e) => { e.stopPropagation(); onRemove(f.id) }}>×</div>
|
||||
<div className="nm">{f.name}</div>
|
||||
<div className="meta">
|
||||
<span className={`badge b-${f.status}`}>{f.status}</span>
|
||||
<span>{fmtBytes(f.sizeBytes)}</span>
|
||||
{f.availableLines > 0 && <span>{fmtNum(f.availableLines)} lines</span>}
|
||||
{f.curveCount > 0 && <span>{f.curveCount} curves</span>}
|
||||
{!f.uploaded && <span title="opened in place, not copied">in-place</span>}
|
||||
</div>
|
||||
{f.status === 'INDEXING' && (
|
||||
<div className="prog" title={`${pct.toFixed(1)}% scanned`}>
|
||||
<span style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
)}
|
||||
{f.status === 'ERROR' && f.error && (
|
||||
<div className="meta" style={{ color: 'var(--err)' }}>{f.error}</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
59
frontend/src/components/HeaderPanel.tsx
Normal file
59
frontend/src/components/HeaderPanel.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { useState } from 'react'
|
||||
import { parseHeaderLine } from '../las'
|
||||
import type { FileMeta, HeaderSection } from '../types'
|
||||
|
||||
/**
|
||||
* LAS header sections rendered for readability: each metadata line is parsed into
|
||||
* mnemonic / unit / value / description and laid out as key-value rows (with a toggle back to the
|
||||
* exact raw text for QC).
|
||||
*/
|
||||
export default function HeaderPanel({ meta }: { meta: FileMeta }) {
|
||||
const [openSec, setOpenSec] = useState<string | null>(null)
|
||||
const [raw, setRaw] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="hp">
|
||||
<div className="hp-toggle">
|
||||
<div className="seg">
|
||||
<button className={raw ? '' : 'on'} onClick={() => setRaw(false)}>Structured</button>
|
||||
<button className={raw ? 'on' : ''} onClick={() => setRaw(true)}>Raw text</button>
|
||||
</div>
|
||||
</div>
|
||||
{meta.sections.map(s => {
|
||||
const open = openSec === s.name
|
||||
return (
|
||||
<details className="grp" key={s.name} open={open}
|
||||
onToggle={(e) => { if ((e.target as HTMLDetailsElement).open) setOpenSec(s.name) }}>
|
||||
<summary>{s.name} <span className="cnt">({s.lines.filter(l => l.trim()).length})</span></summary>
|
||||
{open && (raw ? <pre>{s.lines.join('\n')}</pre> : <StructuredSection section={s} />)}
|
||||
</details>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StructuredSection({ section }: { section: HeaderSection }) {
|
||||
const rows = section.lines.filter(l => l.trim().length > 0)
|
||||
return (
|
||||
<div className="kv-list">
|
||||
{rows.map((line, i) => {
|
||||
const t = line.trimStart()
|
||||
// skip the comment column-header / separator banners (#MNEM.UNIT … / #----)
|
||||
if (t.startsWith('#')) return null
|
||||
const p = parseHeaderLine(line)
|
||||
if (!p) return <div className="kv-free" key={i}>{line.trim()}</div>
|
||||
return (
|
||||
<div className="kv" key={i}>
|
||||
<div className="top">
|
||||
<span className="m">{p.mnem}</span>
|
||||
{p.unit && <span className="u">·{p.unit}</span>}
|
||||
<span className={`v${p.value ? '' : ' empty'}`}>{p.value || '—'}</span>
|
||||
</div>
|
||||
{p.desc && <div className="d">{p.desc}</div>}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
166
frontend/src/components/IngestPanel.tsx
Normal file
166
frontend/src/components/IngestPanel.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { api, fmtBytes } from '../api'
|
||||
import type { AppConfig, BrowseResponse, FileSummary } from '../types'
|
||||
|
||||
interface Props {
|
||||
config: AppConfig | null
|
||||
onOpened: (f: FileSummary) => void
|
||||
onError: (e: unknown) => void
|
||||
collapsedDefault?: boolean
|
||||
}
|
||||
|
||||
type Mode = 'upload' | 'disk'
|
||||
|
||||
export default function IngestPanel({ config, onOpened, onError, collapsedDefault = false }: Props) {
|
||||
const [mode, setMode] = useState<Mode>('disk')
|
||||
const [open, setOpen] = useState(!collapsedDefault)
|
||||
const handleOpened = (f: FileSummary) => { onOpened(f); setOpen(false) }
|
||||
return (
|
||||
<div className="sec">
|
||||
<div className={`sec-h${open ? '' : ' closed'}`} onClick={() => setOpen(o => !o)}>
|
||||
<span className="chev">▼</span>
|
||||
<span>{collapsedDefault ? 'Open another file' : 'Open a LAS file'}</span>
|
||||
</div>
|
||||
{open && (
|
||||
<div className="sec-b">
|
||||
<div className="seg">
|
||||
<button className={mode === 'disk' ? 'on' : ''} onClick={() => setMode('disk')}>Open on disk</button>
|
||||
<button className={mode === 'upload' ? 'on' : ''} onClick={() => setMode('upload')}>Upload</button>
|
||||
</div>
|
||||
{mode === 'upload'
|
||||
? <Uploader config={config} onOpened={handleOpened} onError={onError} />
|
||||
: <DiskBrowser config={config} onOpened={handleOpened} onError={onError} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ---------------- chunked uploader ---------------- */
|
||||
function Uploader({ config, onOpened, onError }: Props) {
|
||||
const [over, setOver] = useState(false)
|
||||
const [busy, setBusy] = useState<{ name: string; sent: number; total: number } | null>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const upload = useCallback(async (file: File) => {
|
||||
const chunkSize = config?.uploadChunkSize ?? 16 * 1024 * 1024
|
||||
setBusy({ name: file.name, sent: 0, total: file.size })
|
||||
try {
|
||||
const { uploadId } = await api.uploadInit(file.name, file.size)
|
||||
let offset = 0
|
||||
while (offset < file.size) {
|
||||
const end = Math.min(offset + chunkSize, file.size)
|
||||
await api.uploadChunk(uploadId, offset, file.slice(offset, end))
|
||||
offset = end
|
||||
setBusy({ name: file.name, sent: offset, total: file.size })
|
||||
}
|
||||
const summary = await api.uploadComplete(uploadId)
|
||||
onOpened(summary)
|
||||
} catch (e) {
|
||||
onError(e)
|
||||
} finally {
|
||||
setBusy(null)
|
||||
if (inputRef.current) inputRef.current.value = ''
|
||||
}
|
||||
}, [config, onOpened, onError])
|
||||
|
||||
const pct = busy ? (busy.total > 0 ? (busy.sent / busy.total) * 100 : 0) : 0
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={`drop${over ? ' over' : ''}`}
|
||||
onClick={() => !busy && inputRef.current?.click()}
|
||||
onDragOver={(e) => { e.preventDefault(); setOver(true) }}
|
||||
onDragLeave={() => setOver(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault(); setOver(false)
|
||||
if (busy) return
|
||||
const f = e.dataTransfer.files?.[0]
|
||||
if (f) upload(f)
|
||||
}}
|
||||
>
|
||||
{busy ? (
|
||||
<>
|
||||
<div className="big">{busy.name}</div>
|
||||
<div className="small">{fmtBytes(busy.sent)} / {fmtBytes(busy.total)} · {pct.toFixed(1)}%</div>
|
||||
<div className="prog" style={{ marginTop: 10 }}><span style={{ width: `${pct}%` }} /></div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="big">⤓ Drop a LAS file here</div>
|
||||
<div className="small">or click to choose · streamed in 16 MB chunks (any size)</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
ref={inputRef} type="file" hidden
|
||||
accept=".las,.asc,.txt,text/plain"
|
||||
onChange={(e) => { const f = e.target.files?.[0]; if (f) upload(f) }}
|
||||
/>
|
||||
<div className="muted" style={{ fontSize: 11, marginTop: 8 }}>
|
||||
Tip: for files already on this machine, use <b>Open on disk</b> — no copy, opens instantly.
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/* ---------------- server-side disk browser ---------------- */
|
||||
function DiskBrowser({ config, onOpened, onError }: Props) {
|
||||
const [data, setData] = useState<BrowseResponse | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [opening, setOpening] = useState<string | null>(null)
|
||||
|
||||
const go = useCallback(async (dir?: string) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
setData(await api.browse(dir))
|
||||
} catch (e) {
|
||||
onError(e)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [onError])
|
||||
|
||||
useEffect(() => { go(config?.homeDir) }, [config, go])
|
||||
|
||||
const open = useCallback(async (path: string) => {
|
||||
setOpening(path)
|
||||
try {
|
||||
onOpened(await api.openLocal(path))
|
||||
} catch (e) {
|
||||
onError(e)
|
||||
} finally {
|
||||
setOpening(null)
|
||||
}
|
||||
}, [onOpened, onError])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="crumbs">{data?.dir ?? '…'}</div>
|
||||
<div style={{ maxHeight: 320, overflow: 'auto' }} className="scroll">
|
||||
{loading && <div className="muted" style={{ padding: '6px 9px' }}><span className="spin" /> reading…</div>}
|
||||
{!loading && data?.parent && (
|
||||
<div className="fbrow" onClick={() => go(data.parent!)}>
|
||||
<span className="ic">↑</span><span className="nm">..</span>
|
||||
</div>
|
||||
)}
|
||||
{!loading && data?.entries.map(e => (
|
||||
<div
|
||||
key={e.path}
|
||||
className={`fbrow${e.looksLikeLas ? ' las' : ''}`}
|
||||
onClick={() => e.dir ? go(e.path) : open(e.path)}
|
||||
title={e.path}
|
||||
>
|
||||
<span className="ic">{e.dir ? '📁' : (e.looksLikeLas ? '📈' : '📄')}</span>
|
||||
<span className="nm">{e.name}</span>
|
||||
{!e.dir && <span className="sz">{opening === e.path ? '…' : fmtBytes(e.sizeBytes)}</span>}
|
||||
</div>
|
||||
))}
|
||||
{!loading && data && data.entries.length === 0 && (
|
||||
<div className="muted" style={{ padding: '6px 9px', fontSize: 12 }}>(empty)</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
629
frontend/src/components/LogPlot.tsx
Normal file
629
frontend/src/components/LogPlot.tsx
Normal file
@@ -0,0 +1,629 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { api, fmtNum } from '../api'
|
||||
import type { CurveData, FileSummary, RoleInfo, RolesResponse } from '../types'
|
||||
|
||||
const PALETTE = ['#4aa3ff', '#36c6a0', '#e0a23c', '#e9645f', '#b48ead', '#8fbcbb', '#a3be8c', '#d08770']
|
||||
const GUTTER = 72
|
||||
|
||||
interface Curve { key: string; color: string }
|
||||
interface Track { id: string; curves: Curve[] }
|
||||
|
||||
interface Props { file: FileSummary; onError: (e: unknown) => void }
|
||||
|
||||
export default function LogPlot({ file, onError }: Props) {
|
||||
const [roles, setRoles] = useState<RolesResponse | null>(null)
|
||||
const roleMap = useMemo(() => {
|
||||
const m = new Map<string, RoleInfo>()
|
||||
roles?.roles.forEach(r => m.set(r.key, r))
|
||||
return m
|
||||
}, [roles])
|
||||
|
||||
const [axis, setAxis] = useState<'depth' | 'time'>('depth')
|
||||
const [tracks, setTracks] = useState<Track[]>([])
|
||||
const [autoscale, setAutoscale] = useState(false)
|
||||
const [playing, setPlaying] = useState(false)
|
||||
const [speed, setSpeed] = useState(600) // index units / sec
|
||||
const [pickerOpen, setPickerOpen] = useState(false)
|
||||
const [readout, setReadout] = useState<{ x: number; y: number; index: number; rows: { label: string; color: string; v: number | null; unit: string }[] } | null>(null)
|
||||
const [detail, setDetail] = useState(false)
|
||||
const [showStats, setShowStats] = useState(false)
|
||||
const [stats, setStats] = useState<{ key: string; mnemonic: string; unit: string; min: number | null; avg: number | null; max: number | null }[]>([])
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const viewRef = useRef<{ from: number; to: number }>({ from: 0, to: 1 })
|
||||
const dataRef = useRef<CurveData | null>(null)
|
||||
const sizeRef = useRef<{ w: number; h: number }>({ w: 800, h: 600 })
|
||||
const drawPending = useRef(false)
|
||||
const fetchState = useRef<{ last: number; timer: number; seq: number }>({ last: 0, timer: 0, seq: 0 })
|
||||
const trackRef = useRef<HTMLDivElement>(null)
|
||||
const thumbRef = useRef<HTMLDivElement>(null)
|
||||
const windowRangeRef = useRef<Map<string, [number, number]>>(new Map())
|
||||
|
||||
const extent = useMemo(() => {
|
||||
const e = axis === 'depth' ? roles?.depthExtent : roles?.timeExtent
|
||||
return e && e.max > e.min ? { min: e.min, max: e.max } : null
|
||||
}, [roles, axis])
|
||||
|
||||
/* ---------- load roles for this file ---------- */
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
setRoles(null); setTracks([]); setReadout(null); setPlaying(false)
|
||||
api.roles(file.id).then(r => {
|
||||
if (!alive) return
|
||||
setRoles(r)
|
||||
setAxis(r.hasDepthAxis ? 'depth' : 'time')
|
||||
// build default tracks from resolved roles
|
||||
const m = new Map(r.roles.map(x => [x.key, x]))
|
||||
let ci = 0
|
||||
const ts: Track[] = []
|
||||
for (const group of r.defaultTracks) {
|
||||
const curves = group.filter(k => m.has(k)).map(k => ({ key: k, color: PALETTE[ci++ % PALETTE.length] }))
|
||||
if (curves.length) ts.push({ id: 't' + ts.length, curves })
|
||||
}
|
||||
setTracks(ts)
|
||||
}).catch(onError)
|
||||
return () => { alive = false }
|
||||
}, [file.id, onError])
|
||||
|
||||
// poll roles until ready (pyramid finishes building) while indexing
|
||||
useEffect(() => {
|
||||
if (!roles || roles.ready) return
|
||||
const t = window.setInterval(() => {
|
||||
api.roles(file.id).then(r => { if (r.ready) setRoles(r) }).catch(() => {})
|
||||
}, 1500)
|
||||
return () => window.clearInterval(t)
|
||||
}, [roles, file.id])
|
||||
|
||||
/* ---------- initialize view when extent becomes available ---------- */
|
||||
useEffect(() => {
|
||||
if (extent) { viewRef.current = { from: extent.min, to: extent.max }; scheduleFetch(true); requestDraw() }
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [extent])
|
||||
|
||||
/* ---------- size / resize ---------- */
|
||||
useEffect(() => {
|
||||
const el = containerRef.current
|
||||
if (!el) return
|
||||
const ro = new ResizeObserver(() => {
|
||||
sizeRef.current = { w: el.clientWidth, h: el.clientHeight }
|
||||
const c = canvasRef.current
|
||||
if (c) {
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
c.width = Math.round(sizeRef.current.w * dpr)
|
||||
c.height = Math.round(sizeRef.current.h * dpr)
|
||||
c.style.width = sizeRef.current.w + 'px'
|
||||
c.style.height = sizeRef.current.h + 'px'
|
||||
}
|
||||
scheduleFetch(true); requestDraw()
|
||||
})
|
||||
ro.observe(el)
|
||||
return () => ro.disconnect()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
/* ---------- data fetch (throttled so replay doesn't spam) ---------- */
|
||||
const keysSig = useMemo(() => tracks.flatMap(t => t.curves.map(c => c.key)).join(','), [tracks])
|
||||
|
||||
const doFetch = useCallback(async () => {
|
||||
if (!extent) return
|
||||
fetchState.current.last = performance.now()
|
||||
const keys = Array.from(new Set(tracks.flatMap(t => t.curves.map(c => c.key))))
|
||||
if (keys.length === 0) { dataRef.current = null; requestDraw(); return }
|
||||
const v = viewRef.current
|
||||
const span = v.to - v.from
|
||||
const pad = span * 0.3
|
||||
const from = Math.max(extent.min, v.from - pad)
|
||||
const to = Math.min(extent.max, v.to + pad)
|
||||
const h = sizeRef.current.h
|
||||
const width = Math.min(4000, Math.max(64, Math.round(h * 1.6)))
|
||||
const seq = ++fetchState.current.seq
|
||||
try {
|
||||
const cd = await api.curveData(file.id, axis, keys, from, to, width)
|
||||
if (seq !== fetchState.current.seq) return // stale
|
||||
dataRef.current = cd
|
||||
setDetail(cd.detail)
|
||||
// window statistics over the *visible* range (min/avg/max per channel) + per-curve fit range
|
||||
const vw = viewRef.current
|
||||
const wr = new Map<string, [number, number]>()
|
||||
setStats(cd.curves.map(s => {
|
||||
let mn = Infinity, mx = -Infinity, sum = 0, cnt = 0
|
||||
for (let j = 0; j < cd.n; j++) {
|
||||
const p = cd.pos[j]; if (p == null || p < vw.from || p > vw.to) continue
|
||||
const a = s.min[j], b = s.max[j]; if (a == null || b == null) continue
|
||||
if (a < mn) mn = a; if (b > mx) mx = b; sum += (a + b) / 2; cnt++
|
||||
}
|
||||
if (cnt) wr.set(s.key, [mn, mx])
|
||||
return { key: s.key, mnemonic: s.mnemonic, unit: s.unit, min: cnt ? mn : null, avg: cnt ? sum / cnt : null, max: cnt ? mx : null }
|
||||
}))
|
||||
windowRangeRef.current = wr
|
||||
requestDraw()
|
||||
} catch (e) { onError(e) }
|
||||
}, [extent, tracks, axis, file.id, onError])
|
||||
|
||||
const scheduleFetch = useCallback((force = false) => {
|
||||
const interval = 150
|
||||
const now = performance.now()
|
||||
const since = now - fetchState.current.last
|
||||
window.clearTimeout(fetchState.current.timer)
|
||||
if (force || since >= interval) doFetch()
|
||||
else fetchState.current.timer = window.setTimeout(doFetch, interval - since)
|
||||
}, [doFetch])
|
||||
|
||||
useEffect(() => { scheduleFetch(true) }, [keysSig, axis, scheduleFetch])
|
||||
|
||||
/* ---------- drawing ---------- */
|
||||
const v2y = (v: number, h: number) => {
|
||||
const { from, to } = viewRef.current
|
||||
return ((v - from) / (to - from)) * h
|
||||
}
|
||||
const y2v = (y: number, h: number) => {
|
||||
const { from, to } = viewRef.current
|
||||
return from + (y / h) * (to - from)
|
||||
}
|
||||
|
||||
const scaleFor = useCallback((key: string): [number, number] => {
|
||||
// "Auto-fit": scale each track to the data actually visible in the current window.
|
||||
if (autoscale) {
|
||||
const wr = windowRangeRef.current.get(key)
|
||||
if (wr) {
|
||||
if (wr[1] > wr[0]) { const p = (wr[1] - wr[0]) * 0.06; return [wr[0] - p, wr[1] + p] }
|
||||
return [wr[0] - 1, wr[1] + 1]
|
||||
}
|
||||
}
|
||||
const r = roleMap.get(key)
|
||||
if (!r) return [0, 1]
|
||||
if (r.defMax > r.defMin) return [r.defMin, r.defMax] // fixed physical scale
|
||||
if (r.dataMin != null && r.dataMax != null && r.dataMax > r.dataMin) return [r.dataMin, r.dataMax]
|
||||
return [0, 1]
|
||||
}, [roleMap, autoscale])
|
||||
|
||||
const draw = useCallback(() => {
|
||||
drawPending.current = false
|
||||
const c = canvasRef.current
|
||||
if (!c || !extent) return
|
||||
const ctx = c.getContext('2d')!
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
||||
const { w, h } = sizeRef.current
|
||||
ctx.clearRect(0, 0, w, h)
|
||||
ctx.fillStyle = '#0a0e13'; ctx.fillRect(0, 0, w, h)
|
||||
|
||||
const nT = tracks.length
|
||||
const plotW = w - GUTTER
|
||||
const trackW = nT > 0 ? plotW / nT : plotW
|
||||
const data = dataRef.current
|
||||
const { from, to } = viewRef.current
|
||||
|
||||
// index gridlines + gutter labels
|
||||
const ticks = niceTicks(from, to, 8)
|
||||
ctx.font = '10px ui-monospace, monospace'
|
||||
ctx.textBaseline = 'middle'
|
||||
for (const tk of ticks) {
|
||||
const y = v2y(tk, h)
|
||||
if (y < 0 || y > h) continue
|
||||
ctx.strokeStyle = '#162028'; ctx.beginPath(); ctx.moveTo(GUTTER, y); ctx.lineTo(w, y); ctx.stroke()
|
||||
ctx.fillStyle = '#5c6b7c'; ctx.textAlign = 'right'
|
||||
ctx.fillText(fmtIndex(tk, axis), GUTTER - 6, y)
|
||||
}
|
||||
// gutter divider
|
||||
ctx.strokeStyle = '#25323f'; ctx.beginPath(); ctx.moveTo(GUTTER, 0); ctx.lineTo(GUTTER, h); ctx.stroke()
|
||||
|
||||
// tracks
|
||||
for (let ti = 0; ti < nT; ti++) {
|
||||
const x0 = GUTTER + ti * trackW
|
||||
ctx.strokeStyle = '#1b2531'; ctx.beginPath(); ctx.moveTo(x0, 0); ctx.lineTo(x0, h); ctx.stroke()
|
||||
const t = tracks[ti]
|
||||
for (const cv of t.curves) {
|
||||
const s = data?.curves.find(x => x.key === cv.key)
|
||||
if (!s || !data) continue
|
||||
const [cmin, cmax] = scaleFor(cv.key)
|
||||
const pad = 4
|
||||
const sx = (val: number) => {
|
||||
let f = (val - cmin) / (cmax - cmin)
|
||||
if (f < 0) f = 0; else if (f > 1) f = 1
|
||||
return x0 + pad + f * (trackW - 2 * pad)
|
||||
}
|
||||
// min/max envelope (preserves spikes)
|
||||
ctx.strokeStyle = cv.color; ctx.globalAlpha = 0.45; ctx.lineWidth = 1; ctx.beginPath()
|
||||
for (let j = 0; j < data.n; j++) {
|
||||
const pv = data.pos[j]; if (pv == null) continue
|
||||
const mn = s.min[j], mx = s.max[j]
|
||||
if (mn == null || mx == null) continue
|
||||
const y = v2y(pv, h); if (y < -2 || y > h + 2) continue
|
||||
ctx.moveTo(sx(mn), y); ctx.lineTo(sx(mx), y)
|
||||
}
|
||||
ctx.stroke()
|
||||
// mid trace
|
||||
ctx.globalAlpha = 1; ctx.beginPath(); let started = false
|
||||
for (let j = 0; j < data.n; j++) {
|
||||
const pv = data.pos[j]; if (pv == null) { started = false; continue }
|
||||
const mn = s.min[j], mx = s.max[j]
|
||||
if (mn == null || mx == null) { started = false; continue }
|
||||
const y = v2y(pv, h)
|
||||
const x = sx((mn + mx) / 2)
|
||||
if (!started) { ctx.moveTo(x, y); started = true } else ctx.lineTo(x, y)
|
||||
}
|
||||
ctx.stroke()
|
||||
}
|
||||
}
|
||||
ctx.globalAlpha = 1
|
||||
|
||||
// crosshair
|
||||
if (readout) {
|
||||
ctx.strokeStyle = '#36c6a0'; ctx.globalAlpha = 0.6; ctx.setLineDash([4, 3])
|
||||
ctx.beginPath(); ctx.moveTo(GUTTER, readout.y); ctx.lineTo(w, readout.y); ctx.stroke()
|
||||
ctx.setLineDash([]); ctx.globalAlpha = 1
|
||||
}
|
||||
// playhead at bottom while replaying
|
||||
if (playing) {
|
||||
ctx.strokeStyle = '#e0a23c'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(GUTTER, h - 1); ctx.lineTo(w, h - 1); ctx.stroke()
|
||||
}
|
||||
|
||||
// position the scrollbar thumb to mirror the visible window
|
||||
const track = trackRef.current, thumb = thumbRef.current
|
||||
if (track && thumb) {
|
||||
const trackH = track.clientHeight
|
||||
const full = extent.max - extent.min
|
||||
const span = to - from
|
||||
const th = Math.max(28, full > 0 ? (trackH * span) / full : trackH)
|
||||
const maxTop = trackH - th
|
||||
const top = full > span ? ((from - extent.min) / (full - span)) * maxTop : 0
|
||||
thumb.style.height = th + 'px'
|
||||
thumb.style.top = top + 'px'
|
||||
}
|
||||
}, [extent, tracks, axis, scaleFor, readout, playing])
|
||||
|
||||
const requestDraw = useCallback(() => {
|
||||
if (drawPending.current) return
|
||||
drawPending.current = true
|
||||
requestAnimationFrame(() => draw())
|
||||
}, [draw])
|
||||
|
||||
useEffect(() => { requestDraw() }, [requestDraw])
|
||||
|
||||
/* ---------- interactions ---------- */
|
||||
const clampView = useCallback((from: number, to: number) => {
|
||||
if (!extent) return { from, to }
|
||||
const full = extent.max - extent.min
|
||||
let span = to - from
|
||||
if (span >= full) return { from: extent.min, to: extent.max }
|
||||
if (span < 1e-9) span = 1e-9
|
||||
if (from < extent.min) { from = extent.min; to = from + span }
|
||||
if (to > extent.max) { to = extent.max; from = to - span }
|
||||
return { from, to }
|
||||
}, [extent])
|
||||
|
||||
const setView = useCallback((from: number, to: number) => {
|
||||
viewRef.current = clampView(from, to)
|
||||
scheduleFetch(); requestDraw()
|
||||
}, [clampView, scheduleFetch, requestDraw])
|
||||
|
||||
const onWheel = useCallback((e: React.WheelEvent) => {
|
||||
if (!extent) return
|
||||
e.preventDefault()
|
||||
const rect = canvasRef.current!.getBoundingClientRect()
|
||||
const y = e.clientY - rect.top
|
||||
const h = sizeRef.current.h
|
||||
const v = y2v(y, h)
|
||||
const { from, to } = viewRef.current
|
||||
const factor = e.deltaY > 0 ? 1.2 : 0.83
|
||||
let span = (to - from) * factor
|
||||
const full = extent.max - extent.min
|
||||
const minSpan = axis === 'time' ? 5 : 1
|
||||
span = Math.max(minSpan, Math.min(full, span))
|
||||
const frac = y / h
|
||||
setView(v - frac * span, v - frac * span + span)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [extent, axis, setView])
|
||||
|
||||
const onMouseMove = (e: React.MouseEvent) => {
|
||||
const rect = canvasRef.current!.getBoundingClientRect()
|
||||
const h = sizeRef.current.h
|
||||
const y = e.clientY - rect.top
|
||||
// hover readout
|
||||
const data = dataRef.current
|
||||
const idx = y2v(y, h)
|
||||
const rows: { label: string; color: string; v: number | null; unit: string }[] = []
|
||||
if (data) {
|
||||
const j = nearest(data.pos, idx)
|
||||
for (const t of tracks) for (const cv of t.curves) {
|
||||
const s = data.curves.find(x => x.key === cv.key)
|
||||
const r = roleMap.get(cv.key)
|
||||
let val: number | null = null
|
||||
if (s && j >= 0) { const mn = s.min[j], mx = s.max[j]; if (mn != null && mx != null) val = (mn + mx) / 2 }
|
||||
rows.push({ label: r?.mnemonic ?? cv.key, color: cv.color, v: val, unit: r?.unit ?? '' })
|
||||
}
|
||||
}
|
||||
setReadout({ x: e.clientX - rect.left, y, index: idx, rows })
|
||||
}
|
||||
const onLeave = () => { setReadout(null) }
|
||||
|
||||
const zoomBy = useCallback((factor: number) => {
|
||||
if (!extent) return
|
||||
const { from, to } = viewRef.current
|
||||
const c = (from + to) / 2
|
||||
const full = extent.max - extent.min
|
||||
const minSpan = axis === 'time' ? 5 : 1
|
||||
const span = Math.max(minSpan, Math.min(full, (to - from) * factor))
|
||||
setView(c - span / 2, c + span / 2)
|
||||
}, [extent, axis, setView])
|
||||
|
||||
/* ---------- scrollbar (pan through depth/time) ---------- */
|
||||
const thumbGeom = useCallback(() => {
|
||||
const trackH = trackRef.current?.clientHeight ?? 1
|
||||
const full = extent ? extent.max - extent.min : 1
|
||||
const span = viewRef.current.to - viewRef.current.from
|
||||
const th = Math.max(28, full > 0 ? (trackH * span) / full : trackH)
|
||||
return { trackH, full, span, th, maxTop: trackH - th }
|
||||
}, [extent])
|
||||
|
||||
const sbDrag = useRef<{ y: number; from: number } | null>(null)
|
||||
const onThumbDown = (e: React.MouseEvent) => {
|
||||
e.preventDefault(); e.stopPropagation()
|
||||
if (!extent) return
|
||||
sbDrag.current = { y: e.clientY, from: viewRef.current.from }
|
||||
const move = (ev: MouseEvent) => {
|
||||
if (!sbDrag.current) return
|
||||
const g = thumbGeom()
|
||||
const dy = ev.clientY - sbDrag.current.y
|
||||
const dFrac = g.maxTop > 0 ? dy / g.maxTop : 0
|
||||
const nf = sbDrag.current.from + dFrac * (g.full - g.span)
|
||||
setView(nf, nf + g.span)
|
||||
}
|
||||
const up = () => { sbDrag.current = null; document.removeEventListener('mousemove', move); document.removeEventListener('mouseup', up) }
|
||||
document.addEventListener('mousemove', move)
|
||||
document.addEventListener('mouseup', up)
|
||||
}
|
||||
const onTrackDown = (e: React.MouseEvent) => {
|
||||
if (!extent) return
|
||||
const rect = trackRef.current!.getBoundingClientRect()
|
||||
const clickY = e.clientY - rect.top
|
||||
const g = thumbGeom()
|
||||
const top = g.full > g.span ? ((viewRef.current.from - extent.min) / (g.full - g.span)) * g.maxTop : 0
|
||||
const page = g.span * 0.9
|
||||
if (clickY < top) setView(viewRef.current.from - page, viewRef.current.to - page)
|
||||
else if (clickY > top + g.th) setView(viewRef.current.from + page, viewRef.current.to + page)
|
||||
}
|
||||
|
||||
/* ---------- replay ---------- */
|
||||
useEffect(() => {
|
||||
if (!playing || !extent) return
|
||||
let raf = 0
|
||||
let last = performance.now()
|
||||
const tick = (t: number) => {
|
||||
const dt = (t - last) / 1000; last = t
|
||||
const { from, to } = viewRef.current
|
||||
const span = to - from
|
||||
let nt = to + speed * dt
|
||||
if (nt >= extent.max) {
|
||||
viewRef.current = { from: extent.max - span, to: extent.max }
|
||||
scheduleFetch(); requestDraw(); setPlaying(false); return
|
||||
}
|
||||
viewRef.current = clampView(nt - span, nt)
|
||||
scheduleFetch(); requestDraw()
|
||||
raf = requestAnimationFrame(tick)
|
||||
}
|
||||
raf = requestAnimationFrame(tick)
|
||||
return () => cancelAnimationFrame(raf)
|
||||
}, [playing, speed, extent, clampView, scheduleFetch, requestDraw])
|
||||
|
||||
const resetView = () => { if (extent) setView(extent.min, extent.max) }
|
||||
|
||||
/* ---------- track editing ---------- */
|
||||
const toggleRole = (key: string) => {
|
||||
setTracks(prev => {
|
||||
const has = prev.some(t => t.curves.some(c => c.key === key))
|
||||
if (has) {
|
||||
return prev.map(t => ({ ...t, curves: t.curves.filter(c => c.key !== key) })).filter(t => t.curves.length)
|
||||
}
|
||||
const used = new Set(prev.flatMap(t => t.curves.map(c => c.key)))
|
||||
const color = PALETTE[used.size % PALETTE.length]
|
||||
return [...prev, { id: 't' + Date.now(), curves: [{ key, color }] }]
|
||||
})
|
||||
}
|
||||
const removeCurve = (key: string) =>
|
||||
setTracks(prev => prev.map(t => ({ ...t, curves: t.curves.filter(c => c.key !== key) })).filter(t => t.curves.length))
|
||||
|
||||
/* ---------- render ---------- */
|
||||
const nT = tracks.length
|
||||
const speedLabel = axis === 'time'
|
||||
? `${fmtNum(Math.round(speed))} s/s`
|
||||
: `${fmtNum(Math.round(speed))} ft/s`
|
||||
|
||||
if (roles && !roles.ready) {
|
||||
return <div className="empty"><div><div className="spin" style={{ width: 18, height: 18 }} />
|
||||
<div style={{ marginTop: 10 }}>Building curve overview…</div>
|
||||
<div className="muted" style={{ fontSize: 12, marginTop: 6 }}>parsing channels for the log plot</div></div></div>
|
||||
}
|
||||
if (roles && roles.roles.length === 0) {
|
||||
return <div className="empty"><div>No drilling channels recognized in this file.<div className="muted" style={{ fontSize: 12, marginTop: 6 }}>Use the Raw / QC tab to inspect it.</div></div></div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="lp">
|
||||
<div className="viewer-bar">
|
||||
{roles?.hasDepthAxis && roles?.hasTimeAxis && (
|
||||
<div className="seg" style={{ margin: 0, width: 150 }}>
|
||||
<button className={axis === 'depth' ? 'on' : ''} onClick={() => setAxis('depth')}>Depth</button>
|
||||
<button className={axis === 'time' ? 'on' : ''} onClick={() => setAxis('time')}>Time</button>
|
||||
</div>
|
||||
)}
|
||||
<button className="btn primary" onClick={() => setPlaying(p => !p)}>{playing ? '⏸ Pause' : '▶ Replay'}</button>
|
||||
<div className="speed" title="replay speed">
|
||||
<span>🐌</span>
|
||||
<input type="range" min={axis === 'time' ? 30 : 2} max={axis === 'time' ? 14400 : 500}
|
||||
value={speed} onChange={e => setSpeed(parseInt(e.target.value, 10))} />
|
||||
<span>🚀</span>
|
||||
<span style={{ fontFamily: 'var(--mono)', width: 78, textAlign: 'right' }}>{speedLabel}</span>
|
||||
</div>
|
||||
<div className="sep" />
|
||||
<button className="btn ghost" onClick={() => zoomBy(0.6)} title="zoom in (or scroll the wheel over the plot)">+</button>
|
||||
<button className="btn ghost" onClick={() => zoomBy(1.7)} title="zoom out">-</button>
|
||||
<button className="btn ghost" onClick={resetView}>⤢ Fit</button>
|
||||
<label className="speed" style={{ gap: 5 }} title="auto-fit: scale each track to the data visible in the current window">
|
||||
<input type="checkbox" checked={autoscale} onChange={e => setAutoscale(e.target.checked)} /> auto-fit
|
||||
</label>
|
||||
<button className="btn ghost" onClick={() => setPickerOpen(o => !o)}>+ Curves</button>
|
||||
<button className={`btn ghost${showStats ? ' primary' : ''}`} onClick={() => setShowStats(s => !s)}>Σ Stats</button>
|
||||
<div className="grow" />
|
||||
<span className="stat" style={{ fontFamily: 'var(--mono)', fontSize: 12, color: 'var(--txt-dim)' }}>
|
||||
{detail ? 'detail' : 'overview'} · {axis}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* track headers aligned with canvas tracks */}
|
||||
<div className="lp-heads">
|
||||
<div className="lp-gutter-sp" style={{ width: GUTTER }}>
|
||||
<span className="muted" style={{ fontSize: 10 }}>{axis === 'depth' ? 'DEPTH ft' : 'TIME'}</span>
|
||||
</div>
|
||||
{tracks.map((t) => (
|
||||
<div className="lp-head" key={t.id} style={{ width: `calc((100% - ${GUTTER}px) / ${nT})` }}>
|
||||
{t.curves.map(cv => {
|
||||
const r = roleMap.get(cv.key)
|
||||
const [mn, mx] = scaleFor(cv.key)
|
||||
return (
|
||||
<div className="lp-curve" key={cv.key} title={r?.description}>
|
||||
<span className="dot" style={{ background: cv.color }} />
|
||||
<span className="mn" style={{ color: cv.color }}>{r?.mnemonic ?? cv.key}</span>
|
||||
<span className="rng">{fmtNum(Math.round(mn))}–{fmtNum(Math.round(mx))}{r?.unit ? ' ' + r.unit : ''}</span>
|
||||
<span className="rm" onClick={() => removeCurve(cv.key)}>×</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="lp-body">
|
||||
<div className="lp-canvas-wrap" ref={containerRef}>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
onWheel={onWheel}
|
||||
onMouseMove={onMouseMove}
|
||||
onMouseLeave={onLeave}
|
||||
style={{ cursor: 'crosshair' }}
|
||||
/>
|
||||
{showStats && stats.length > 0 && (
|
||||
<div className="lp-stats scroll">
|
||||
<div className="sh">Window stats <span className="x" onClick={() => setShowStats(false)}>×</span></div>
|
||||
<table>
|
||||
<thead><tr><th>chan</th><th>min</th><th>avg</th><th>max</th></tr></thead>
|
||||
<tbody>
|
||||
{stats.map(s => (
|
||||
<tr key={s.key}>
|
||||
<td title={s.unit}>{s.mnemonic}</td>
|
||||
<td>{s.min == null ? '—' : trim(s.min)}</td>
|
||||
<td>{s.avg == null ? '—' : trim(s.avg)}</td>
|
||||
<td>{s.max == null ? '—' : trim(s.max)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{readout && (
|
||||
<div className="lp-readout" style={{ left: Math.min(readout.x + GUTTER + 8, sizeRef.current.w - 190), top: Math.min(readout.y + 8, sizeRef.current.h - 10 - readout.rows.length * 15) }}>
|
||||
<div className="ix">{fmtIndex(readout.index, axis)}{axis === 'depth' ? ' ft' : ''}</div>
|
||||
{readout.rows.map((r, i) => (
|
||||
<div className="ro" key={i}>
|
||||
<span className="dot" style={{ background: r.color }} />
|
||||
<span className="l">{r.label}</span>
|
||||
<span className="vv">{r.v == null ? '—' : trim(r.v)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="lp-scrollbar" ref={trackRef} onMouseDown={onTrackDown} title="drag to scroll through the well; wheel over the plot to zoom">
|
||||
<div className="lp-thumb" ref={thumbRef} onMouseDown={onThumbDown} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{pickerOpen && roles && (
|
||||
<CurvePicker roles={roles} active={new Set(tracks.flatMap(t => t.curves.map(c => c.key)))}
|
||||
onToggle={toggleRole} onClose={() => setPickerOpen(false)} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ---------- curve picker popover ---------- */
|
||||
function CurvePicker({ roles, active, onToggle, onClose }:
|
||||
{ roles: RolesResponse; active: Set<string>; onToggle: (k: string) => void; onClose: () => void }) {
|
||||
const groups = ['mechanics', 'hydraulics', 'gas', 'directional', 'index']
|
||||
const labels: Record<string, string> = {
|
||||
mechanics: 'Drilling mechanics', hydraulics: 'Hydraulics & well control',
|
||||
gas: 'Mud gas / formation', directional: 'Directional & dynamics', index: 'Index / state',
|
||||
}
|
||||
return (
|
||||
<div className="lp-picker">
|
||||
<div className="hd">Curves <span className="x" onClick={onClose}>×</span></div>
|
||||
<div className="bd scroll">
|
||||
{groups.map(g => {
|
||||
const rs = roles.roles.filter(r => r.group === g)
|
||||
if (!rs.length) return null
|
||||
return (
|
||||
<div key={g} className="grp">
|
||||
<div className="gh">{labels[g]}</div>
|
||||
{rs.map(r => (
|
||||
<label key={r.key} className={`chip${active.has(r.key) ? ' on' : ''}`}>
|
||||
<input type="checkbox" checked={active.has(r.key)} onChange={() => onToggle(r.key)} />
|
||||
<span className="mn">{r.mnemonic}</span>
|
||||
<span className="ds">{r.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ---------- helpers ---------- */
|
||||
function nearest(pos: (number | null)[], v: number): number {
|
||||
let lo = 0, hi = pos.length - 1, best = -1, bestD = Infinity
|
||||
// pos is ascending but may contain nulls; linear-ish guarded binary
|
||||
while (lo <= hi) {
|
||||
const mid = (lo + hi) >> 1
|
||||
const pv = pos[mid]
|
||||
if (pv == null) { // probe outward
|
||||
let k = mid + 1; while (k <= hi && pos[k] == null) k++
|
||||
if (k > hi) { hi = mid - 1; continue }
|
||||
const d = Math.abs((pos[k] as number) - v); if (d < bestD) { bestD = d; best = k }
|
||||
if ((pos[k] as number) < v) lo = k + 1; else hi = mid - 1
|
||||
continue
|
||||
}
|
||||
const d = Math.abs(pv - v); if (d < bestD) { bestD = d; best = mid }
|
||||
if (pv < v) lo = mid + 1; else hi = mid - 1
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
function niceTicks(from: number, to: number, count: number): number[] {
|
||||
const span = to - from
|
||||
if (span <= 0) return []
|
||||
const raw = span / count
|
||||
const mag = Math.pow(10, Math.floor(Math.log10(raw)))
|
||||
const norm = raw / mag
|
||||
const step = (norm < 1.5 ? 1 : norm < 3 ? 2 : norm < 7 ? 5 : 10) * mag
|
||||
const start = Math.ceil(from / step) * step
|
||||
const out: number[] = []
|
||||
for (let v = start; v <= to; v += step) out.push(v)
|
||||
return out
|
||||
}
|
||||
|
||||
function fmtIndex(v: number, axis: 'depth' | 'time'): string {
|
||||
if (axis === 'depth') return fmtNum(Math.round(v))
|
||||
const d = new Date(v * 1000)
|
||||
const hh = String(d.getHours()).padStart(2, '0')
|
||||
const mm = String(d.getMinutes()).padStart(2, '0')
|
||||
const ss = String(d.getSeconds()).padStart(2, '0')
|
||||
return `${hh}:${mm}:${ss}`
|
||||
}
|
||||
|
||||
function trim(v: number): string {
|
||||
if (Math.abs(v) >= 1000) return fmtNum(Math.round(v))
|
||||
return (Math.round(v * 100) / 100).toString()
|
||||
}
|
||||
23
frontend/src/components/Section.tsx
Normal file
23
frontend/src/components/Section.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { ReactNode, useState } from 'react'
|
||||
|
||||
interface Props {
|
||||
title: string
|
||||
count?: ReactNode
|
||||
defaultOpen?: boolean
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
/** A collapsible sidebar section with a chevron header. */
|
||||
export default function Section({ title, count, defaultOpen = true, children }: Props) {
|
||||
const [open, setOpen] = useState(defaultOpen)
|
||||
return (
|
||||
<div className="sec">
|
||||
<div className={`sec-h${open ? '' : ' closed'}`} onClick={() => setOpen(o => !o)}>
|
||||
<span className="chev">▼</span>
|
||||
<span>{title}</span>
|
||||
{count != null && <span className="cnt">{count}</span>}
|
||||
</div>
|
||||
{open && <div className="sec-b">{children}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
296
frontend/src/components/Viewer.tsx
Normal file
296
frontend/src/components/Viewer.tsx
Normal file
@@ -0,0 +1,296 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||
import { api, fmtNum } from '../api'
|
||||
import type { FileMeta, FileSummary, SearchMatch } from '../types'
|
||||
|
||||
const ROW = 20 // px per line
|
||||
const PAGE = 500 // lines fetched per range request
|
||||
const MARGIN_PAGES = 1 // prefetch a page above/below the viewport
|
||||
const CACHE_CAP = 20000 // max cached lines (bounds browser memory on 10GB+ files)
|
||||
const STREAM_INTERVAL = 70
|
||||
|
||||
interface Props {
|
||||
file: FileSummary
|
||||
meta: FileMeta | null
|
||||
onError: (e: unknown) => void
|
||||
}
|
||||
|
||||
export default function Viewer({ file, meta, onError }: Props) {
|
||||
const parentRef = useRef<HTMLDivElement>(null)
|
||||
const cache = useRef<Map<number, string>>(new Map())
|
||||
const requested = useRef<Set<number>>(new Set())
|
||||
const [, setVersion] = useState(0)
|
||||
const bump = useCallback(() => setVersion(v => v + 1), [])
|
||||
|
||||
const available = file.availableLines
|
||||
const dataStart = file.dataStartLine
|
||||
|
||||
// reset everything when the selected file changes
|
||||
useEffect(() => {
|
||||
cache.current.clear()
|
||||
requested.current.clear()
|
||||
setPlaying(false)
|
||||
setCurrentLine(-1)
|
||||
setMatches([])
|
||||
parentRef.current?.scrollTo({ top: 0 })
|
||||
bump()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [file.id])
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: available,
|
||||
getScrollElement: () => parentRef.current,
|
||||
estimateSize: () => ROW,
|
||||
overscan: 24,
|
||||
})
|
||||
|
||||
const items = virtualizer.getVirtualItems()
|
||||
const first = items.length ? items[0].index : 0
|
||||
const last = items.length ? items[items.length - 1].index : 0
|
||||
const topLine = first
|
||||
|
||||
/* ---------- range loading for browse / scroll ---------- */
|
||||
const ensureLoaded = useCallback(async (lo: number, hi: number) => {
|
||||
const startPage = Math.max(0, Math.floor(lo / PAGE) - MARGIN_PAGES)
|
||||
const endPage = Math.floor(hi / PAGE) + MARGIN_PAGES
|
||||
for (let p = startPage; p <= endPage; p++) {
|
||||
if (requested.current.has(p)) continue
|
||||
const start = p * PAGE
|
||||
if (start >= available) continue
|
||||
requested.current.add(p)
|
||||
try {
|
||||
const resp = await api.lines(file.id, start, PAGE)
|
||||
for (let i = 0; i < resp.lines.length; i++) cache.current.set(start + i, resp.lines[i])
|
||||
// A short page fetched before indexing finished is the moving frontier — don't pin it,
|
||||
// so it re-fetches in full once more lines are available.
|
||||
if (resp.lines.length < PAGE && file.status !== 'READY') requested.current.delete(p)
|
||||
trimCache()
|
||||
bump()
|
||||
} catch (e) {
|
||||
requested.current.delete(p) // allow retry
|
||||
onError(e)
|
||||
}
|
||||
}
|
||||
}, [file.id, available, file.status, onError, bump])
|
||||
|
||||
const trimCache = useCallback(() => {
|
||||
const m = cache.current
|
||||
if (m.size <= CACHE_CAP) return
|
||||
const target = Math.floor(CACHE_CAP * 0.8)
|
||||
const it = m.keys()
|
||||
while (m.size > target) {
|
||||
const k = it.next().value
|
||||
if (k === undefined) break
|
||||
m.delete(k)
|
||||
requested.current.delete(Math.floor(k / PAGE))
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (available > 0) ensureLoaded(first, last)
|
||||
}, [first, last, available, ensureLoaded])
|
||||
|
||||
/* ---------- streaming (SSE play) ---------- */
|
||||
const [playing, setPlaying] = useState(false)
|
||||
const [speedV, setSpeedV] = useState(35)
|
||||
const [currentLine, setCurrentLine] = useState(-1)
|
||||
const esRef = useRef<EventSource | null>(null)
|
||||
|
||||
const linesPerSec = useMemo(() => Math.round(5 * Math.pow(4000, speedV / 100)), [speedV])
|
||||
|
||||
const closeStream = useCallback(() => {
|
||||
esRef.current?.close()
|
||||
esRef.current = null
|
||||
}, [])
|
||||
|
||||
const openStream = useCallback((startLine: number) => {
|
||||
closeStream()
|
||||
const batch = Math.max(1, Math.round((linesPerSec * STREAM_INTERVAL) / 1000))
|
||||
const url = `/api/files/${file.id}/stream?start=${startLine}&batch=${batch}&intervalMs=${STREAM_INTERVAL}`
|
||||
const es = new EventSource(url)
|
||||
esRef.current = es
|
||||
es.addEventListener('lines', (ev) => {
|
||||
const d = JSON.parse((ev as MessageEvent).data) as { start: number; lines: string[] }
|
||||
for (let i = 0; i < d.lines.length; i++) cache.current.set(d.start + i, d.lines[i])
|
||||
const next = d.start + d.lines.length
|
||||
trimCache()
|
||||
setCurrentLine(next - 1)
|
||||
virtualizer.scrollToIndex(Math.min(next, available > 0 ? available - 1 : next), { align: 'end' })
|
||||
bump()
|
||||
})
|
||||
es.addEventListener('eof', () => { setPlaying(false); closeStream() })
|
||||
es.onerror = () => { /* connection dropped or closed; stop following */ setPlaying(false); closeStream() }
|
||||
}, [file.id, linesPerSec, available, closeStream, trimCache, virtualizer, bump])
|
||||
|
||||
const togglePlay = useCallback(() => {
|
||||
if (playing) { setPlaying(false); closeStream(); return }
|
||||
const start = currentLine >= 0 ? currentLine + 1 : topLine
|
||||
setPlaying(true)
|
||||
openStream(start)
|
||||
}, [playing, currentLine, topLine, openStream, closeStream])
|
||||
|
||||
// restart stream when speed changes mid-play
|
||||
useEffect(() => {
|
||||
if (playing) openStream(currentLine >= 0 ? currentLine + 1 : topLine)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [linesPerSec])
|
||||
|
||||
useEffect(() => () => closeStream(), [closeStream])
|
||||
|
||||
/* ---------- navigation ---------- */
|
||||
const [gotoVal, setGotoVal] = useState('')
|
||||
const jumpTo = useCallback((line: number, align: 'start' | 'center' = 'start') => {
|
||||
const target = Math.max(0, Math.min(available - 1, line))
|
||||
virtualizer.scrollToIndex(target, { align })
|
||||
ensureLoaded(Math.max(0, target - PAGE), target + PAGE)
|
||||
}, [available, virtualizer, ensureLoaded])
|
||||
|
||||
const onGoto = useCallback(() => {
|
||||
const n = parseInt(gotoVal.replace(/[^0-9]/g, ''), 10)
|
||||
if (!isNaN(n)) jumpTo(n - 1)
|
||||
}, [gotoVal, jumpTo])
|
||||
|
||||
/* ---------- search (SSE) ---------- */
|
||||
const [query, setQuery] = useState('')
|
||||
const [matches, setMatches] = useState<SearchMatch[]>([])
|
||||
const [searching, setSearching] = useState(false)
|
||||
const [scanned, setScanned] = useState(0)
|
||||
const searchEs = useRef<EventSource | null>(null)
|
||||
const matchSet = useMemo(() => new Set(matches.map(m => m.line)), [matches])
|
||||
|
||||
const cancelSearch = useCallback(() => {
|
||||
searchEs.current?.close()
|
||||
searchEs.current = null
|
||||
setSearching(false)
|
||||
}, [])
|
||||
|
||||
const runSearch = useCallback(() => {
|
||||
cancelSearch()
|
||||
if (!query.trim()) { setMatches([]); return }
|
||||
setMatches([]); setScanned(0); setSearching(true)
|
||||
const es = new EventSource(`/api/files/${file.id}/search?q=${encodeURIComponent(query)}&max=500`)
|
||||
searchEs.current = es
|
||||
es.addEventListener('match', (ev) => {
|
||||
const m = JSON.parse((ev as MessageEvent).data) as SearchMatch
|
||||
setMatches(prev => (prev.length < 500 ? [...prev, m] : prev))
|
||||
})
|
||||
es.addEventListener('progress', (ev) => {
|
||||
const p = JSON.parse((ev as MessageEvent).data) as { scanned: number }
|
||||
setScanned(p.scanned)
|
||||
})
|
||||
es.addEventListener('done', () => { setSearching(false); es.close(); searchEs.current = null })
|
||||
es.onerror = () => { setSearching(false); es.close(); searchEs.current = null }
|
||||
}, [query, file.id, cancelSearch])
|
||||
|
||||
useEffect(() => () => cancelSearch(), [cancelSearch])
|
||||
|
||||
/* ---------- render ---------- */
|
||||
const totalSize = virtualizer.getTotalSize()
|
||||
const posPct = available > 1 ? ((topLine / (available - 1)) * 100) : 0
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="viewer-bar">
|
||||
<button className="btn primary" onClick={togglePlay} disabled={available === 0}>
|
||||
{playing ? '⏸ Pause' : '▶ Stream'}
|
||||
</button>
|
||||
<div className="speed" title={`${fmtNum(linesPerSec)} lines/sec`}>
|
||||
<span>🐌</span>
|
||||
<input type="range" min={0} max={100} value={speedV}
|
||||
onChange={(e) => setSpeedV(parseInt(e.target.value, 10))} />
|
||||
<span>🚀</span>
|
||||
<span style={{ fontFamily: 'var(--mono)', width: 72, textAlign: 'right' }}>{fmtNum(linesPerSec)}/s</span>
|
||||
</div>
|
||||
|
||||
<div className="sep" />
|
||||
|
||||
<button className="btn ghost" onClick={() => jumpTo(0)} disabled={available === 0}>⤒ Top</button>
|
||||
{dataStart >= 0 && (
|
||||
<button className="btn ghost" onClick={() => jumpTo(dataStart)} title={`line ${dataStart + 1}`}>≈ Data</button>
|
||||
)}
|
||||
<button className="btn ghost" onClick={() => jumpTo(available - 1, 'center')} disabled={available === 0}>⤓ End</button>
|
||||
|
||||
<input className="field sm" placeholder="go to line…" value={gotoVal}
|
||||
onChange={(e) => setGotoVal(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') onGoto() }} />
|
||||
<button className="btn" onClick={onGoto}>Go</button>
|
||||
|
||||
<div className="sep" />
|
||||
|
||||
<input className="field" style={{ width: 160 }} placeholder="search text…" value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') runSearch() }} />
|
||||
{searching
|
||||
? <button className="btn" onClick={cancelSearch}><span className="spin" /> Stop</button>
|
||||
: <button className="btn" onClick={runSearch}>Search</button>}
|
||||
|
||||
<div className="grow" />
|
||||
<span className="stat" style={{ color: 'var(--txt-dim)', fontSize: 12, fontFamily: 'var(--mono)' }}>
|
||||
{file.status === 'INDEXING' && <span style={{ color: 'var(--accent-2)' }}>indexing… </span>}
|
||||
line {fmtNum(topLine + 1)} / {fmtNum(available)} · {posPct.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="viewport" ref={parentRef}>
|
||||
{available === 0 ? (
|
||||
<div className="empty" style={{ height: '100%' }}>
|
||||
<div>
|
||||
<div className="spin" style={{ width: 18, height: 18 }} />
|
||||
<div style={{ marginTop: 10 }}>
|
||||
{file.status === 'ERROR' ? (file.error || 'Failed to index file') : 'Indexing file…'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rows" style={{ height: totalSize }}>
|
||||
{items.map(vi => {
|
||||
const line = vi.index
|
||||
const text = cache.current.get(line)
|
||||
return (
|
||||
<div
|
||||
key={line}
|
||||
className={`row${line === currentLine ? ' cur' : ''}${matchSet.has(line) ? ' hit' : ''}`}
|
||||
style={{ transform: `translateY(${vi.start}px)`, height: vi.size }}
|
||||
>
|
||||
<span className="gutter">{fmtNum(line + 1)}</span>
|
||||
<span className={lineClass(text, line, dataStart)}>
|
||||
{text ?? '⋯'}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(matches.length > 0 || searching) && (
|
||||
<div className="search-results scroll">
|
||||
<div className="sr-row" style={{ position: 'sticky', top: 0, background: 'var(--bg-2)', cursor: 'default' }}>
|
||||
<span className="ln">{searching ? <><span className="spin" /> </> : null}</span>
|
||||
<span className="tx">
|
||||
{matches.length} match{matches.length === 1 ? '' : 'es'}
|
||||
{searching ? ` · scanned ${fmtNum(scanned)} lines…` : ''}
|
||||
{matches.length >= 500 ? ' (showing first 500)' : ''}
|
||||
</span>
|
||||
<span className="ln" style={{ cursor: 'pointer' }} onClick={() => setMatches([])}>clear</span>
|
||||
</div>
|
||||
{matches.map((m, i) => (
|
||||
<div key={`${m.line}-${i}`} className="sr-row" onClick={() => jumpTo(m.line, 'center')}>
|
||||
<span className="ln">{fmtNum(m.line + 1)}</span>
|
||||
<span className="tx">{m.text}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function lineClass(text: string | undefined, line: number, dataStart: number): string {
|
||||
if (text === undefined) return 'ltext loading'
|
||||
if (dataStart >= 0 && line === dataStart - 1) return 'ltext head' // the ~A column header
|
||||
const t = text.trimStart()
|
||||
if (t.startsWith('~')) return 'ltext sec'
|
||||
if (t.startsWith('#')) return 'ltext comment'
|
||||
return 'ltext'
|
||||
}
|
||||
61
frontend/src/components/WellInfo.tsx
Normal file
61
frontend/src/components/WellInfo.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { Fragment } from 'react'
|
||||
import { fmtBytes, fmtNum } from '../api'
|
||||
import { sectionValues } from '../las'
|
||||
import type { FileMeta, FileSummary } from '../types'
|
||||
|
||||
/** Compact well summary card extracted from the LAS WELL block. */
|
||||
export default function WellInfo({ meta, file }: { meta: FileMeta; file: FileSummary }) {
|
||||
const w = sectionValues(meta, 'WELL')
|
||||
const get = (k: string) => w.get(k) || ''
|
||||
|
||||
const rows: [string, string][] = []
|
||||
const add = (label: string, k: string) => { const v = get(k); if (v) rows.push([label, v]) }
|
||||
add('Field', 'FLD')
|
||||
const cnty = get('CNTY'), stat = get('STAT')
|
||||
if (cnty || stat) rows.push(['County', [cnty, stat].filter(Boolean).join(', ')])
|
||||
add('Country', 'CTRY')
|
||||
add('Operator', 'COMP')
|
||||
add('Service', 'SRVC')
|
||||
add('Rig', 'RIG')
|
||||
|
||||
const strt = get('STRT'), stop = get('STOP'), step = get('STEP')
|
||||
const idxUnit = (() => {
|
||||
const sec = meta.sections.find(s => s.name.toUpperCase().includes('WELL'))
|
||||
const line = sec?.lines.find(l => l.trim().toUpperCase().startsWith('STRT'))
|
||||
return line ? (line.split('.')[1] || '').trim().split(/\s/)[0] : ''
|
||||
})()
|
||||
|
||||
return (
|
||||
<div className="wi">
|
||||
<h2>{meta.summary.wellName || file.name}</h2>
|
||||
{get('API') && <div className="api">API {get('API')}</div>}
|
||||
<dl className="wi-grid">
|
||||
{rows.map(([l, v]) => (<Fragment key={l}><dt>{l}</dt><dd title={v}>{v}</dd></Fragment>))}
|
||||
</dl>
|
||||
<div className="wi-chips">
|
||||
<span className="wi-chip"><b>{fmtNum(meta.curves.length)}</b> curves</span>
|
||||
<span className="wi-chip"><b>{fmtNum(file.availableLines)}</b> rows</span>
|
||||
<span className="wi-chip"><b>{fmtBytes(file.sizeBytes)}</b></span>
|
||||
{meta.nullValue && <span className="wi-chip">NULL <b>{meta.nullValue}</b></span>}
|
||||
{meta.wrap && <span className="wi-chip">WRAP <b>{meta.wrap}</b></span>}
|
||||
</div>
|
||||
{(strt || stop) && (
|
||||
<div className="wi-chips">
|
||||
{strt && <span className="wi-chip">STRT <b>{fmtIdx(strt, idxUnit)}</b></span>}
|
||||
{stop && <span className="wi-chip">STOP <b>{fmtIdx(stop, idxUnit)}</b></span>}
|
||||
{step && step !== '0' && <span className="wi-chip">STEP <b>{step} {idxUnit}</b></span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function fmtIdx(v: string, unit: string): string {
|
||||
const n = Number(v)
|
||||
if (unit.toLowerCase().startsWith('sec') && Number.isFinite(n) && n > 1e9) {
|
||||
// epoch seconds -> readable
|
||||
const d = new Date(n * 1000)
|
||||
return d.toLocaleString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
return `${v}${unit ? ' ' + unit : ''}`
|
||||
}
|
||||
43
frontend/src/las.ts
Normal file
43
frontend/src/las.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import type { FileMeta } from './types'
|
||||
|
||||
/** Parse one LAS metadata line: `MNEM.UNIT VALUE : DESCRIPTION`. */
|
||||
export function parseHeaderLine(line: string): { mnem: string; unit: string; value: string; desc: string } | null {
|
||||
if (!line || line[0] === '~' || line[0] === '#') return null
|
||||
const dot = line.indexOf('.')
|
||||
if (dot < 0) return null
|
||||
const colon = line.indexOf(':')
|
||||
let ue = dot + 1
|
||||
while (ue < line.length && !/\s/.test(line[ue])) ue++
|
||||
const mnem = line.slice(0, dot).trim()
|
||||
const unit = line.slice(dot + 1, ue).trim()
|
||||
let value = '', desc = ''
|
||||
if (colon >= 0 && colon >= ue) { value = line.slice(ue, colon).trim(); desc = line.slice(colon + 1).trim() }
|
||||
else if (colon >= 0) { desc = line.slice(colon + 1).trim() }
|
||||
else { value = line.slice(ue).trim() }
|
||||
if (!mnem) return null
|
||||
return { mnem, unit, value, desc }
|
||||
}
|
||||
|
||||
/** Build a mnemonic -> value map from a named header section (e.g. "WELL"). */
|
||||
export function sectionValues(meta: FileMeta, nameIncludes: string): Map<string, string> {
|
||||
const m = new Map<string, string>()
|
||||
const sec = meta.sections.find(s => s.name.toUpperCase().includes(nameIncludes.toUpperCase()))
|
||||
if (!sec) return m
|
||||
for (const line of sec.lines) {
|
||||
const p = parseHeaderLine(line)
|
||||
if (p && p.value) m.set(p.mnem.toUpperCase(), p.value)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
export const GROUP_COLOR: Record<string, string> = {
|
||||
mechanics: '#4aa3ff',
|
||||
hydraulics: '#36c6a0',
|
||||
gas: '#e0a23c',
|
||||
directional: '#b48ead',
|
||||
index: '#8696a8',
|
||||
}
|
||||
|
||||
export const GROUP_LABEL: Record<string, string> = {
|
||||
mechanics: 'MECH', hydraulics: 'HYD', gas: 'GAS', directional: 'DIR', index: 'IDX',
|
||||
}
|
||||
10
frontend/src/main.tsx
Normal file
10
frontend/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App'
|
||||
import './styles.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
394
frontend/src/styles.css
Normal file
394
frontend/src/styles.css
Normal file
@@ -0,0 +1,394 @@
|
||||
:root {
|
||||
--bg: #0b0f14;
|
||||
--bg-1: #111824;
|
||||
--bg-2: #18212e;
|
||||
--bg-3: #1f2a3a;
|
||||
--line: #25323f;
|
||||
--txt: #d7e0ea;
|
||||
--txt-dim: #8696a8;
|
||||
--txt-faint: #5c6b7c;
|
||||
--accent: #36c6a0;
|
||||
--accent-2: #4aa3ff;
|
||||
--warn: #e0a23c;
|
||||
--err: #e9645f;
|
||||
--mono: 'JetBrains Mono', 'Cascadia Code', 'Consolas', ui-monospace, monospace;
|
||||
--sans: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body, #root {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--txt);
|
||||
font-family: var(--sans);
|
||||
font-size: 14px;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.app {
|
||||
display: grid;
|
||||
grid-template-columns: 360px 1fr;
|
||||
grid-template-rows: 52px 1fr;
|
||||
grid-template-areas: "top top" "side main";
|
||||
height: 100vh;
|
||||
transition: grid-template-columns .16s ease;
|
||||
}
|
||||
.app.nosidebar { grid-template-columns: 0 1fr; }
|
||||
.app.nosidebar .sidebar { display: none; }
|
||||
|
||||
.sidebar-toggle {
|
||||
background: var(--bg-3); color: var(--txt-dim); border: 1px solid var(--line);
|
||||
border-radius: 7px; width: 30px; height: 28px; cursor: pointer; font-size: 13px;
|
||||
display: grid; place-items: center; flex: 0 0 auto;
|
||||
}
|
||||
.sidebar-toggle:hover { background: #27374a; color: var(--txt); }
|
||||
|
||||
/* ---- top bar ---- */
|
||||
.topbar {
|
||||
grid-area: top;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 0 18px;
|
||||
background: linear-gradient(180deg, var(--bg-2), var(--bg-1));
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.topbar .logo {
|
||||
width: 26px; height: 26px;
|
||||
border-radius: 7px;
|
||||
background: linear-gradient(135deg, var(--accent), var(--accent-2));
|
||||
display: grid; place-items: center;
|
||||
font-weight: 800; color: #06231d; font-size: 13px;
|
||||
}
|
||||
.topbar h1 { font-size: 15px; font-weight: 650; margin: 0; letter-spacing: .2px; }
|
||||
.topbar .sub { color: var(--txt-faint); font-size: 12px; }
|
||||
.topbar .spacer { flex: 1; }
|
||||
.topbar .stat { color: var(--txt-dim); font-size: 12px; font-family: var(--mono); }
|
||||
|
||||
/* ---- sidebar ---- */
|
||||
.sidebar {
|
||||
grid-area: side;
|
||||
background: var(--bg-1);
|
||||
border-right: 1px solid var(--line);
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.panel { border-bottom: 1px solid var(--line); }
|
||||
.panel-h {
|
||||
padding: 12px 16px 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: .8px;
|
||||
text-transform: uppercase;
|
||||
color: var(--txt-faint);
|
||||
}
|
||||
.panel-b { padding: 0 14px 14px; }
|
||||
|
||||
/* segmented control */
|
||||
.seg {
|
||||
display: flex;
|
||||
background: var(--bg-2);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 9px;
|
||||
padding: 3px;
|
||||
gap: 3px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.seg button {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: var(--txt-dim);
|
||||
padding: 6px 8px;
|
||||
border-radius: 7px;
|
||||
cursor: pointer;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.seg button.on { background: var(--bg-3); color: var(--txt); box-shadow: 0 1px 2px rgba(0,0,0,.3); }
|
||||
|
||||
/* dropzone */
|
||||
.drop {
|
||||
border: 1.5px dashed var(--line);
|
||||
border-radius: 12px;
|
||||
padding: 22px 14px;
|
||||
text-align: center;
|
||||
color: var(--txt-dim);
|
||||
cursor: pointer;
|
||||
transition: border-color .15s, background .15s;
|
||||
}
|
||||
.drop:hover, .drop.over { border-color: var(--accent); background: rgba(54,198,160,.06); color: var(--txt); }
|
||||
.drop .big { font-size: 13px; font-weight: 600; color: var(--txt); }
|
||||
.drop .small { font-size: 11.5px; margin-top: 4px; }
|
||||
|
||||
/* browser list */
|
||||
.crumbs { font-family: var(--mono); font-size: 11px; color: var(--txt-faint); margin: 0 0 8px; word-break: break-all; }
|
||||
.fbrow {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 7px 9px; border-radius: 8px; cursor: pointer;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
.fbrow:hover { background: var(--bg-2); }
|
||||
.fbrow .ic { width: 16px; text-align: center; opacity: .8; }
|
||||
.fbrow .nm { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.fbrow .sz { color: var(--txt-faint); font-family: var(--mono); font-size: 11px; }
|
||||
.fbrow.las .nm { color: var(--accent); }
|
||||
|
||||
/* file cards */
|
||||
.fcard {
|
||||
background: var(--bg-2);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 11px;
|
||||
padding: 11px 12px;
|
||||
margin-bottom: 9px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
transition: border-color .15s;
|
||||
}
|
||||
.fcard:hover { border-color: #33485e; }
|
||||
.fcard.sel { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent) inset; }
|
||||
.fcard .nm { font-weight: 650; font-size: 13px; word-break: break-all; padding-right: 20px; }
|
||||
.fcard .meta { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 6px; color: var(--txt-dim); font-size: 11.5px; font-family: var(--mono); }
|
||||
.fcard .x { position: absolute; top: 8px; right: 9px; color: var(--txt-faint); cursor: pointer; font-size: 14px; line-height: 1; }
|
||||
.fcard .x:hover { color: var(--err); }
|
||||
|
||||
.badge { padding: 1px 7px; border-radius: 20px; font-size: 10.5px; font-weight: 700; letter-spacing: .3px; }
|
||||
.b-READY { background: rgba(54,198,160,.16); color: var(--accent); }
|
||||
.b-INDEXING { background: rgba(74,163,255,.16); color: var(--accent-2); }
|
||||
.b-REGISTERED { background: rgba(134,150,168,.16); color: var(--txt-dim); }
|
||||
.b-ERROR { background: rgba(233,100,95,.16); color: var(--err); }
|
||||
|
||||
.prog { height: 4px; background: var(--bg-3); border-radius: 4px; overflow: hidden; margin-top: 8px; }
|
||||
.prog > span { display: block; height: 100%; background: linear-gradient(90deg, var(--accent-2), var(--accent)); transition: width .3s; }
|
||||
|
||||
/* ---- main ---- */
|
||||
.main { grid-area: main; display: flex; flex-direction: column; overflow: hidden; background: var(--bg); }
|
||||
|
||||
.viewer-bar {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 9px 14px;
|
||||
background: var(--bg-1);
|
||||
border-bottom: 1px solid var(--line);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.btn {
|
||||
background: var(--bg-3); color: var(--txt); border: 1px solid var(--line);
|
||||
border-radius: 8px; padding: 6px 11px; cursor: pointer; font-size: 12.5px; font-weight: 600;
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
}
|
||||
.btn:hover { background: #27374a; }
|
||||
.btn.primary { background: var(--accent); color: #06231d; border-color: transparent; }
|
||||
.btn.primary:hover { filter: brightness(1.08); }
|
||||
.btn:disabled { opacity: .45; cursor: not-allowed; }
|
||||
.btn.ghost { background: transparent; }
|
||||
|
||||
.field {
|
||||
background: var(--bg-2); border: 1px solid var(--line); color: var(--txt);
|
||||
border-radius: 8px; padding: 6px 9px; font-size: 12.5px; font-family: var(--mono);
|
||||
}
|
||||
.field:focus { outline: none; border-color: var(--accent-2); }
|
||||
.field.sm { width: 110px; }
|
||||
|
||||
.speed { display: flex; align-items: center; gap: 8px; color: var(--txt-dim); font-size: 12px; }
|
||||
.speed input[type=range] { width: 130px; accent-color: var(--accent); }
|
||||
|
||||
.sep { width: 1px; height: 22px; background: var(--line); }
|
||||
.grow { flex: 1; }
|
||||
|
||||
/* ---- the line viewer ---- */
|
||||
.viewport {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
background: #0a0e13;
|
||||
font-family: var(--mono);
|
||||
font-size: 12.5px;
|
||||
line-height: 20px;
|
||||
position: relative;
|
||||
}
|
||||
.rows { position: relative; width: 100%; }
|
||||
.row {
|
||||
position: absolute; left: 0; top: 0;
|
||||
width: max-content; min-width: 100%;
|
||||
display: flex; white-space: pre; align-items: center;
|
||||
}
|
||||
.row:hover { background: rgba(255,255,255,.03); }
|
||||
.row.cur { background: rgba(54,198,160,.10); }
|
||||
.row.hit { background: rgba(224,162,60,.14); }
|
||||
.gutter {
|
||||
position: sticky; left: 0;
|
||||
flex: 0 0 auto; width: 86px;
|
||||
text-align: right; padding-right: 12px;
|
||||
color: var(--txt-faint); user-select: none;
|
||||
background: #0a0e13;
|
||||
border-right: 1px solid var(--line);
|
||||
}
|
||||
.ltext { padding-left: 12px; padding-right: 24px; }
|
||||
.ltext.sec { color: var(--accent); font-weight: 600; }
|
||||
.ltext.comment { color: var(--txt-faint); }
|
||||
.ltext.head { color: var(--accent-2); }
|
||||
.ltext.loading { color: var(--txt-faint); font-style: italic; }
|
||||
|
||||
/* ---- header / curves panel ---- */
|
||||
.hp { padding: 0 0 6px; }
|
||||
.hp .grp { border-bottom: 1px solid var(--line); }
|
||||
.hp .grp > summary {
|
||||
list-style: none; cursor: pointer; padding: 10px 16px;
|
||||
font-size: 12px; font-weight: 700; color: var(--txt);
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
}
|
||||
.hp .grp > summary::-webkit-details-marker { display: none; }
|
||||
.hp .grp > summary .cnt { color: var(--txt-faint); font-weight: 500; font-family: var(--mono); font-size: 11px; }
|
||||
.hp pre {
|
||||
margin: 0; padding: 0 16px 12px; font-family: var(--mono); font-size: 11.5px;
|
||||
color: var(--txt-dim); white-space: pre-wrap; word-break: break-word; max-height: 280px; overflow: auto;
|
||||
}
|
||||
.curve-tbl { width: 100%; border-collapse: collapse; font-family: var(--mono); font-size: 11.5px; }
|
||||
.curve-tbl th { text-align: left; color: var(--txt-faint); font-weight: 600; padding: 4px 16px; position: sticky; top: 0; background: var(--bg-1); }
|
||||
.curve-tbl td { padding: 3px 16px; border-top: 1px solid var(--line); color: var(--txt-dim); }
|
||||
.curve-tbl td.mn { color: var(--accent); font-weight: 600; }
|
||||
.curve-wrap { max-height: 360px; overflow: auto; }
|
||||
|
||||
/* search results */
|
||||
.search-results { max-height: 200px; overflow: auto; border-top: 1px solid var(--line); background: var(--bg-1); }
|
||||
.sr-row { padding: 5px 14px; cursor: pointer; font-family: var(--mono); font-size: 11.5px; display: flex; gap: 10px; border-bottom: 1px solid var(--bg-2); }
|
||||
.sr-row:hover { background: var(--bg-2); }
|
||||
.sr-row .ln { color: var(--accent-2); flex: 0 0 auto; }
|
||||
.sr-row .tx { color: var(--txt-dim); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
/* misc */
|
||||
.empty { display: grid; place-items: center; height: 100%; color: var(--txt-faint); text-align: center; }
|
||||
.empty .e-ic { font-size: 40px; margin-bottom: 10px; opacity: .5; }
|
||||
.spin { display: inline-block; width: 12px; height: 12px; border: 2px solid var(--txt-faint); border-top-color: var(--accent); border-radius: 50%; animation: sp .7s linear infinite; }
|
||||
@keyframes sp { to { transform: rotate(360deg); } }
|
||||
.toast { position: fixed; bottom: 18px; left: 50%; transform: translateX(-50%); background: var(--bg-3); border: 1px solid var(--err); color: var(--txt); padding: 9px 16px; border-radius: 10px; font-size: 12.5px; z-index: 50; box-shadow: 0 8px 24px rgba(0,0,0,.4); }
|
||||
.muted { color: var(--txt-faint); }
|
||||
|
||||
/* ---- log plot ---- */
|
||||
.lp { display: flex; flex-direction: column; height: 100%; min-height: 0; }
|
||||
.lp-heads { display: flex; background: var(--bg-1); border-bottom: 1px solid var(--line); min-height: 30px; }
|
||||
.lp-gutter-sp { flex: 0 0 auto; display: grid; place-items: center; border-right: 1px solid var(--line); }
|
||||
.lp-head { border-right: 1px solid #1b2531; padding: 3px 5px; overflow: hidden; min-width: 0; }
|
||||
.lp-curve { display: flex; align-items: center; gap: 4px; font-size: 10.5px; font-family: var(--mono); line-height: 1.45; }
|
||||
.lp-curve .dot { width: 8px; height: 8px; border-radius: 2px; flex: 0 0 auto; }
|
||||
.lp-curve .mn { font-weight: 700; }
|
||||
.lp-curve .rng { color: var(--txt-faint); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.lp-curve .rm { margin-left: auto; color: var(--txt-faint); cursor: pointer; padding: 0 2px; }
|
||||
.lp-curve .rm:hover { color: var(--err); }
|
||||
.lp-body { flex: 1; display: flex; min-height: 0; overflow: hidden; }
|
||||
.lp-canvas-wrap { flex: 1; position: relative; min-width: 0; overflow: hidden; }
|
||||
.lp-body canvas { display: block; }
|
||||
.lp-scrollbar { flex: 0 0 15px; width: 15px; background: var(--bg-1); border-left: 1px solid var(--line); position: relative; cursor: pointer; }
|
||||
.lp-thumb { position: absolute; left: 2px; right: 2px; min-height: 28px; border-radius: 6px; background: #33485e; cursor: grab; }
|
||||
.lp-thumb:hover { background: #3e566e; }
|
||||
.lp-thumb:active { cursor: grabbing; background: var(--accent-2); }
|
||||
.lp-readout {
|
||||
position: absolute; pointer-events: none; z-index: 5;
|
||||
background: rgba(17,24,36,.95); border: 1px solid var(--line); border-radius: 8px;
|
||||
padding: 6px 8px; font-family: var(--mono); font-size: 11px; min-width: 150px;
|
||||
box-shadow: 0 6px 20px rgba(0,0,0,.5);
|
||||
}
|
||||
.lp-readout .ix { color: var(--accent); font-weight: 700; margin-bottom: 4px; border-bottom: 1px solid var(--line); padding-bottom: 3px; }
|
||||
.lp-readout .ro { display: flex; align-items: center; gap: 5px; }
|
||||
.lp-readout .ro .dot { width: 7px; height: 7px; border-radius: 2px; flex: 0 0 auto; }
|
||||
.lp-readout .ro .l { color: var(--txt-dim); width: 52px; overflow: hidden; text-overflow: ellipsis; }
|
||||
.lp-readout .ro .vv { margin-left: auto; color: var(--txt); }
|
||||
|
||||
.lp-picker {
|
||||
position: absolute; top: 96px; right: 16px; z-index: 20; width: 300px; max-height: 70vh;
|
||||
background: var(--bg-2); border: 1px solid var(--line); border-radius: 12px; overflow: hidden;
|
||||
box-shadow: 0 12px 40px rgba(0,0,0,.5); display: flex; flex-direction: column;
|
||||
}
|
||||
.lp-picker .hd { padding: 9px 12px; font-weight: 700; font-size: 12px; border-bottom: 1px solid var(--line); display: flex; }
|
||||
.lp-picker .hd .x { margin-left: auto; cursor: pointer; color: var(--txt-faint); }
|
||||
.lp-picker .hd .x:hover { color: var(--err); }
|
||||
.lp-picker .bd { padding: 8px; overflow-y: auto; }
|
||||
.lp-picker .grp { margin-bottom: 8px; }
|
||||
.lp-picker .gh { font-size: 10px; text-transform: uppercase; letter-spacing: .6px; color: var(--txt-faint); margin: 4px 4px 5px; }
|
||||
.lp-picker .chip {
|
||||
display: flex; align-items: center; gap: 7px; padding: 4px 7px; border-radius: 7px; cursor: pointer; font-size: 12px;
|
||||
}
|
||||
.lp-picker .chip:hover { background: var(--bg-3); }
|
||||
.lp-picker .chip.on { background: rgba(54,198,160,.1); }
|
||||
.lp-picker .chip .mn { font-family: var(--mono); font-weight: 700; width: 56px; }
|
||||
.lp-picker .chip .ds { color: var(--txt-dim); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
.main-tabs { display: flex; gap: 2px; }
|
||||
.main-tabs button {
|
||||
background: transparent; border: 0; border-bottom: 2px solid transparent; color: var(--txt-dim);
|
||||
padding: 7px 14px; cursor: pointer; font-size: 13px; font-weight: 600;
|
||||
}
|
||||
.main-tabs button.on { color: var(--txt); border-bottom-color: var(--accent); }
|
||||
|
||||
/* ---- collapsible sidebar section ---- */
|
||||
.sec { border-bottom: 1px solid var(--line); }
|
||||
.sec-h {
|
||||
display: flex; align-items: center; gap: 8px; cursor: pointer; user-select: none;
|
||||
padding: 11px 16px; font-size: 11px; font-weight: 700; letter-spacing: .7px;
|
||||
text-transform: uppercase; color: var(--txt-faint);
|
||||
}
|
||||
.sec-h:hover { color: var(--txt-dim); }
|
||||
.sec-h .chev { transition: transform .15s; font-size: 10px; }
|
||||
.sec-h.closed .chev { transform: rotate(-90deg); }
|
||||
.sec-h .cnt { margin-left: auto; font-weight: 600; font-family: var(--mono); text-transform: none; letter-spacing: 0; }
|
||||
.sec-b { padding: 0 14px 14px; }
|
||||
|
||||
/* well info card */
|
||||
.wi { padding: 0; }
|
||||
.wi h2 { font-size: 15px; margin: 0 0 2px; font-weight: 650; }
|
||||
.wi .api { font-family: var(--mono); font-size: 11px; color: var(--txt-faint); margin-bottom: 10px; }
|
||||
.wi-grid { display: grid; grid-template-columns: auto 1fr; gap: 4px 10px; font-size: 12px; }
|
||||
.wi-grid dt { color: var(--txt-faint); }
|
||||
.wi-grid dd { margin: 0; color: var(--txt); overflow: hidden; text-overflow: ellipsis; }
|
||||
.wi-chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 11px; }
|
||||
.wi-chip { background: var(--bg-2); border: 1px solid var(--line); border-radius: 7px; padding: 4px 8px; font-size: 11px; font-family: var(--mono); }
|
||||
.wi-chip b { color: var(--accent); }
|
||||
|
||||
/* channel browser */
|
||||
.ch-tools { display: flex; gap: 6px; margin-bottom: 8px; }
|
||||
.ch-tools .field { flex: 1; }
|
||||
.ch-filter { display: flex; align-items: center; gap: 5px; font-size: 11.5px; color: var(--txt-dim); margin-bottom: 8px; cursor: pointer; }
|
||||
.ch-list { max-height: 320px; overflow-y: auto; margin: 0 -6px; }
|
||||
.ch-row { display: flex; align-items: center; gap: 7px; padding: 5px 8px; border-radius: 7px; font-size: 12px; }
|
||||
.ch-row:hover { background: var(--bg-2); }
|
||||
.ch-row .mn { font-family: var(--mono); font-weight: 700; min-width: 56px; }
|
||||
.ch-row .u { color: var(--txt-faint); font-family: var(--mono); font-size: 10.5px; min-width: 38px; }
|
||||
.ch-row .d { color: var(--txt-dim); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.ch-row .badge2 { font-size: 9.5px; font-weight: 700; padding: 1px 6px; border-radius: 20px; white-space: nowrap; }
|
||||
|
||||
/* stats panel on the plot */
|
||||
.lp-stats {
|
||||
position: absolute; top: 8px; right: 8px; z-index: 6; width: 232px; max-height: calc(100% - 16px); overflow-y: auto;
|
||||
background: rgba(17,24,36,.95); border: 1px solid var(--line); border-radius: 10px; padding: 8px 10px;
|
||||
font-size: 11px; box-shadow: 0 8px 24px rgba(0,0,0,.45);
|
||||
}
|
||||
.lp-stats .sh { display: flex; align-items: center; font-weight: 700; font-size: 11px; color: var(--txt-dim); margin-bottom: 7px; }
|
||||
.lp-stats .sh .x { margin-left: auto; cursor: pointer; color: var(--txt-faint); }
|
||||
.lp-stats table { width: 100%; border-collapse: collapse; font-family: var(--mono); font-size: 10.5px; }
|
||||
.lp-stats th { color: var(--txt-faint); font-weight: 600; text-align: right; padding: 2px 3px; }
|
||||
.lp-stats th:first-child { text-align: left; }
|
||||
.lp-stats td { text-align: right; padding: 2px 3px; color: var(--txt-dim); border-top: 1px solid var(--bg-3); }
|
||||
.lp-stats td:first-child { text-align: left; font-weight: 700; }
|
||||
|
||||
/* ---- structured raw-header view ---- */
|
||||
.hp-toggle { display: flex; padding: 6px 16px 2px; }
|
||||
.hp-toggle .seg { margin: 0; width: 170px; }
|
||||
.kv-list { padding: 4px 16px 12px; }
|
||||
.kv { padding: 5px 0; border-bottom: 1px solid var(--bg-2); }
|
||||
.kv:last-child { border-bottom: 0; }
|
||||
.kv .top { display: flex; align-items: baseline; gap: 7px; }
|
||||
.kv .m { font-family: var(--mono); font-weight: 700; font-size: 11.5px; color: var(--txt); white-space: nowrap; }
|
||||
.kv .u { font-family: var(--mono); font-size: 10px; color: var(--txt-faint); }
|
||||
.kv .v { margin-left: auto; font-family: var(--mono); font-size: 11.5px; color: var(--accent); text-align: right; word-break: break-word; }
|
||||
.kv .v.empty { color: var(--txt-faint); }
|
||||
.kv .d { font-size: 10.5px; color: var(--txt-faint); margin-top: 1px; }
|
||||
.kv-free { font-family: var(--mono); font-size: 11px; color: var(--txt-dim); white-space: pre-wrap; word-break: break-word; padding: 2px 0; }
|
||||
.scroll::-webkit-scrollbar { width: 11px; height: 11px; }
|
||||
.scroll::-webkit-scrollbar-thumb { background: #2a3a4c; border-radius: 6px; border: 2px solid var(--bg); }
|
||||
.viewport::-webkit-scrollbar { width: 12px; height: 12px; }
|
||||
.viewport::-webkit-scrollbar-thumb { background: #2a3a4c; border-radius: 6px; border: 3px solid #0a0e13; }
|
||||
131
frontend/src/types.ts
Normal file
131
frontend/src/types.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
export interface FileSummary {
|
||||
id: string
|
||||
name: string
|
||||
sizeBytes: number
|
||||
status: 'REGISTERED' | 'INDEXING' | 'READY' | 'ERROR'
|
||||
error: string | null
|
||||
uploaded: boolean
|
||||
headerReady: boolean
|
||||
indexedLines: number
|
||||
indexedBytes: number
|
||||
totalLines: number
|
||||
availableLines: number
|
||||
dataStartLine: number
|
||||
curveCount: number
|
||||
wellName: string | null
|
||||
}
|
||||
|
||||
export interface Curve {
|
||||
column: number
|
||||
mnemonic: string
|
||||
unit: string
|
||||
apiCode: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export interface HeaderSection {
|
||||
name: string
|
||||
lines: string[]
|
||||
}
|
||||
|
||||
export interface FileMeta {
|
||||
summary: FileSummary
|
||||
sections: HeaderSection[]
|
||||
curves: Curve[]
|
||||
dataColumns: string[]
|
||||
wrap: string | null
|
||||
nullValue: string | null
|
||||
}
|
||||
|
||||
export interface LinesResponse {
|
||||
start: number
|
||||
lines: string[]
|
||||
eof: boolean
|
||||
availableLines: number
|
||||
}
|
||||
|
||||
export interface AppConfig {
|
||||
allowedRoots: string[]
|
||||
homeDir: string
|
||||
uploadChunkSize: number
|
||||
indexStride: number
|
||||
}
|
||||
|
||||
export interface BrowseEntry {
|
||||
name: string
|
||||
path: string
|
||||
dir: boolean
|
||||
sizeBytes: number
|
||||
looksLikeLas: boolean
|
||||
}
|
||||
|
||||
export interface BrowseResponse {
|
||||
dir: string
|
||||
parent: string | null
|
||||
entries: BrowseEntry[]
|
||||
}
|
||||
|
||||
export interface SearchMatch {
|
||||
line: number
|
||||
text: string
|
||||
}
|
||||
|
||||
// ---- log plot ----
|
||||
export interface RoleInfo {
|
||||
key: string
|
||||
label: string
|
||||
group: string
|
||||
mnemonic: string
|
||||
unit: string
|
||||
description: string
|
||||
column: number
|
||||
dataMin: number | null
|
||||
dataMax: number | null
|
||||
defMin: number
|
||||
defMax: number
|
||||
}
|
||||
|
||||
export interface AxisExtent { min: number; max: number }
|
||||
|
||||
export interface RolesResponse {
|
||||
ready: boolean
|
||||
hasTimeAxis: boolean
|
||||
hasDepthAxis: boolean
|
||||
timeExtent: AxisExtent | null
|
||||
depthExtent: AxisExtent | null
|
||||
roles: RoleInfo[]
|
||||
defaultTracks: string[][]
|
||||
}
|
||||
|
||||
export interface CurveSeries {
|
||||
key: string
|
||||
mnemonic: string
|
||||
unit: string
|
||||
column: number
|
||||
min: (number | null)[]
|
||||
max: (number | null)[]
|
||||
dataMin: number | null
|
||||
dataMax: number | null
|
||||
}
|
||||
|
||||
export interface CurveData {
|
||||
axis: string
|
||||
detail: boolean
|
||||
from: number
|
||||
to: number
|
||||
n: number
|
||||
pos: (number | null)[]
|
||||
curves: CurveSeries[]
|
||||
}
|
||||
|
||||
export interface CrossData {
|
||||
x: number[]
|
||||
y: number[]
|
||||
c: (number | null)[]
|
||||
xRange: [number, number]
|
||||
yRange: [number, number]
|
||||
cRange: [number, number]
|
||||
total: number
|
||||
returned: number
|
||||
onBottomFiltered: boolean
|
||||
}
|
||||
Reference in New Issue
Block a user