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

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

View File

@@ -0,0 +1,629 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { api, fmtNum } from '../api'
import type { CurveData, FileSummary, RoleInfo, RolesResponse } from '../types'
const PALETTE = ['#4aa3ff', '#36c6a0', '#e0a23c', '#e9645f', '#b48ead', '#8fbcbb', '#a3be8c', '#d08770']
const GUTTER = 72
interface Curve { key: string; color: string }
interface Track { id: string; curves: Curve[] }
interface Props { file: FileSummary; onError: (e: unknown) => void }
export default function LogPlot({ file, onError }: Props) {
const [roles, setRoles] = useState<RolesResponse | null>(null)
const roleMap = useMemo(() => {
const m = new Map<string, RoleInfo>()
roles?.roles.forEach(r => m.set(r.key, r))
return m
}, [roles])
const [axis, setAxis] = useState<'depth' | 'time'>('depth')
const [tracks, setTracks] = useState<Track[]>([])
const [autoscale, setAutoscale] = useState(false)
const [playing, setPlaying] = useState(false)
const [speed, setSpeed] = useState(600) // index units / sec
const [pickerOpen, setPickerOpen] = useState(false)
const [readout, setReadout] = useState<{ x: number; y: number; index: number; rows: { label: string; color: string; v: number | null; unit: string }[] } | null>(null)
const [detail, setDetail] = useState(false)
const [showStats, setShowStats] = useState(false)
const [stats, setStats] = useState<{ key: string; mnemonic: string; unit: string; min: number | null; avg: number | null; max: number | null }[]>([])
const containerRef = useRef<HTMLDivElement>(null)
const canvasRef = useRef<HTMLCanvasElement>(null)
const viewRef = useRef<{ from: number; to: number }>({ from: 0, to: 1 })
const dataRef = useRef<CurveData | null>(null)
const sizeRef = useRef<{ w: number; h: number }>({ w: 800, h: 600 })
const drawPending = useRef(false)
const fetchState = useRef<{ last: number; timer: number; seq: number }>({ last: 0, timer: 0, seq: 0 })
const trackRef = useRef<HTMLDivElement>(null)
const thumbRef = useRef<HTMLDivElement>(null)
const windowRangeRef = useRef<Map<string, [number, number]>>(new Map())
const extent = useMemo(() => {
const e = axis === 'depth' ? roles?.depthExtent : roles?.timeExtent
return e && e.max > e.min ? { min: e.min, max: e.max } : null
}, [roles, axis])
/* ---------- load roles for this file ---------- */
useEffect(() => {
let alive = true
setRoles(null); setTracks([]); setReadout(null); setPlaying(false)
api.roles(file.id).then(r => {
if (!alive) return
setRoles(r)
setAxis(r.hasDepthAxis ? 'depth' : 'time')
// build default tracks from resolved roles
const m = new Map(r.roles.map(x => [x.key, x]))
let ci = 0
const ts: Track[] = []
for (const group of r.defaultTracks) {
const curves = group.filter(k => m.has(k)).map(k => ({ key: k, color: PALETTE[ci++ % PALETTE.length] }))
if (curves.length) ts.push({ id: 't' + ts.length, curves })
}
setTracks(ts)
}).catch(onError)
return () => { alive = false }
}, [file.id, onError])
// poll roles until ready (pyramid finishes building) while indexing
useEffect(() => {
if (!roles || roles.ready) return
const t = window.setInterval(() => {
api.roles(file.id).then(r => { if (r.ready) setRoles(r) }).catch(() => {})
}, 1500)
return () => window.clearInterval(t)
}, [roles, file.id])
/* ---------- initialize view when extent becomes available ---------- */
useEffect(() => {
if (extent) { viewRef.current = { from: extent.min, to: extent.max }; scheduleFetch(true); requestDraw() }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [extent])
/* ---------- size / resize ---------- */
useEffect(() => {
const el = containerRef.current
if (!el) return
const ro = new ResizeObserver(() => {
sizeRef.current = { w: el.clientWidth, h: el.clientHeight }
const c = canvasRef.current
if (c) {
const dpr = window.devicePixelRatio || 1
c.width = Math.round(sizeRef.current.w * dpr)
c.height = Math.round(sizeRef.current.h * dpr)
c.style.width = sizeRef.current.w + 'px'
c.style.height = sizeRef.current.h + 'px'
}
scheduleFetch(true); requestDraw()
})
ro.observe(el)
return () => ro.disconnect()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
/* ---------- data fetch (throttled so replay doesn't spam) ---------- */
const keysSig = useMemo(() => tracks.flatMap(t => t.curves.map(c => c.key)).join(','), [tracks])
const doFetch = useCallback(async () => {
if (!extent) return
fetchState.current.last = performance.now()
const keys = Array.from(new Set(tracks.flatMap(t => t.curves.map(c => c.key))))
if (keys.length === 0) { dataRef.current = null; requestDraw(); return }
const v = viewRef.current
const span = v.to - v.from
const pad = span * 0.3
const from = Math.max(extent.min, v.from - pad)
const to = Math.min(extent.max, v.to + pad)
const h = sizeRef.current.h
const width = Math.min(4000, Math.max(64, Math.round(h * 1.6)))
const seq = ++fetchState.current.seq
try {
const cd = await api.curveData(file.id, axis, keys, from, to, width)
if (seq !== fetchState.current.seq) return // stale
dataRef.current = cd
setDetail(cd.detail)
// window statistics over the *visible* range (min/avg/max per channel) + per-curve fit range
const vw = viewRef.current
const wr = new Map<string, [number, number]>()
setStats(cd.curves.map(s => {
let mn = Infinity, mx = -Infinity, sum = 0, cnt = 0
for (let j = 0; j < cd.n; j++) {
const p = cd.pos[j]; if (p == null || p < vw.from || p > vw.to) continue
const a = s.min[j], b = s.max[j]; if (a == null || b == null) continue
if (a < mn) mn = a; if (b > mx) mx = b; sum += (a + b) / 2; cnt++
}
if (cnt) wr.set(s.key, [mn, mx])
return { key: s.key, mnemonic: s.mnemonic, unit: s.unit, min: cnt ? mn : null, avg: cnt ? sum / cnt : null, max: cnt ? mx : null }
}))
windowRangeRef.current = wr
requestDraw()
} catch (e) { onError(e) }
}, [extent, tracks, axis, file.id, onError])
const scheduleFetch = useCallback((force = false) => {
const interval = 150
const now = performance.now()
const since = now - fetchState.current.last
window.clearTimeout(fetchState.current.timer)
if (force || since >= interval) doFetch()
else fetchState.current.timer = window.setTimeout(doFetch, interval - since)
}, [doFetch])
useEffect(() => { scheduleFetch(true) }, [keysSig, axis, scheduleFetch])
/* ---------- drawing ---------- */
const v2y = (v: number, h: number) => {
const { from, to } = viewRef.current
return ((v - from) / (to - from)) * h
}
const y2v = (y: number, h: number) => {
const { from, to } = viewRef.current
return from + (y / h) * (to - from)
}
const scaleFor = useCallback((key: string): [number, number] => {
// "Auto-fit": scale each track to the data actually visible in the current window.
if (autoscale) {
const wr = windowRangeRef.current.get(key)
if (wr) {
if (wr[1] > wr[0]) { const p = (wr[1] - wr[0]) * 0.06; return [wr[0] - p, wr[1] + p] }
return [wr[0] - 1, wr[1] + 1]
}
}
const r = roleMap.get(key)
if (!r) return [0, 1]
if (r.defMax > r.defMin) return [r.defMin, r.defMax] // fixed physical scale
if (r.dataMin != null && r.dataMax != null && r.dataMax > r.dataMin) return [r.dataMin, r.dataMax]
return [0, 1]
}, [roleMap, autoscale])
const draw = useCallback(() => {
drawPending.current = false
const c = canvasRef.current
if (!c || !extent) return
const ctx = c.getContext('2d')!
const dpr = window.devicePixelRatio || 1
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
const { w, h } = sizeRef.current
ctx.clearRect(0, 0, w, h)
ctx.fillStyle = '#0a0e13'; ctx.fillRect(0, 0, w, h)
const nT = tracks.length
const plotW = w - GUTTER
const trackW = nT > 0 ? plotW / nT : plotW
const data = dataRef.current
const { from, to } = viewRef.current
// index gridlines + gutter labels
const ticks = niceTicks(from, to, 8)
ctx.font = '10px ui-monospace, monospace'
ctx.textBaseline = 'middle'
for (const tk of ticks) {
const y = v2y(tk, h)
if (y < 0 || y > h) continue
ctx.strokeStyle = '#162028'; ctx.beginPath(); ctx.moveTo(GUTTER, y); ctx.lineTo(w, y); ctx.stroke()
ctx.fillStyle = '#5c6b7c'; ctx.textAlign = 'right'
ctx.fillText(fmtIndex(tk, axis), GUTTER - 6, y)
}
// gutter divider
ctx.strokeStyle = '#25323f'; ctx.beginPath(); ctx.moveTo(GUTTER, 0); ctx.lineTo(GUTTER, h); ctx.stroke()
// tracks
for (let ti = 0; ti < nT; ti++) {
const x0 = GUTTER + ti * trackW
ctx.strokeStyle = '#1b2531'; ctx.beginPath(); ctx.moveTo(x0, 0); ctx.lineTo(x0, h); ctx.stroke()
const t = tracks[ti]
for (const cv of t.curves) {
const s = data?.curves.find(x => x.key === cv.key)
if (!s || !data) continue
const [cmin, cmax] = scaleFor(cv.key)
const pad = 4
const sx = (val: number) => {
let f = (val - cmin) / (cmax - cmin)
if (f < 0) f = 0; else if (f > 1) f = 1
return x0 + pad + f * (trackW - 2 * pad)
}
// min/max envelope (preserves spikes)
ctx.strokeStyle = cv.color; ctx.globalAlpha = 0.45; ctx.lineWidth = 1; ctx.beginPath()
for (let j = 0; j < data.n; j++) {
const pv = data.pos[j]; if (pv == null) continue
const mn = s.min[j], mx = s.max[j]
if (mn == null || mx == null) continue
const y = v2y(pv, h); if (y < -2 || y > h + 2) continue
ctx.moveTo(sx(mn), y); ctx.lineTo(sx(mx), y)
}
ctx.stroke()
// mid trace
ctx.globalAlpha = 1; ctx.beginPath(); let started = false
for (let j = 0; j < data.n; j++) {
const pv = data.pos[j]; if (pv == null) { started = false; continue }
const mn = s.min[j], mx = s.max[j]
if (mn == null || mx == null) { started = false; continue }
const y = v2y(pv, h)
const x = sx((mn + mx) / 2)
if (!started) { ctx.moveTo(x, y); started = true } else ctx.lineTo(x, y)
}
ctx.stroke()
}
}
ctx.globalAlpha = 1
// crosshair
if (readout) {
ctx.strokeStyle = '#36c6a0'; ctx.globalAlpha = 0.6; ctx.setLineDash([4, 3])
ctx.beginPath(); ctx.moveTo(GUTTER, readout.y); ctx.lineTo(w, readout.y); ctx.stroke()
ctx.setLineDash([]); ctx.globalAlpha = 1
}
// playhead at bottom while replaying
if (playing) {
ctx.strokeStyle = '#e0a23c'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(GUTTER, h - 1); ctx.lineTo(w, h - 1); ctx.stroke()
}
// position the scrollbar thumb to mirror the visible window
const track = trackRef.current, thumb = thumbRef.current
if (track && thumb) {
const trackH = track.clientHeight
const full = extent.max - extent.min
const span = to - from
const th = Math.max(28, full > 0 ? (trackH * span) / full : trackH)
const maxTop = trackH - th
const top = full > span ? ((from - extent.min) / (full - span)) * maxTop : 0
thumb.style.height = th + 'px'
thumb.style.top = top + 'px'
}
}, [extent, tracks, axis, scaleFor, readout, playing])
const requestDraw = useCallback(() => {
if (drawPending.current) return
drawPending.current = true
requestAnimationFrame(() => draw())
}, [draw])
useEffect(() => { requestDraw() }, [requestDraw])
/* ---------- interactions ---------- */
const clampView = useCallback((from: number, to: number) => {
if (!extent) return { from, to }
const full = extent.max - extent.min
let span = to - from
if (span >= full) return { from: extent.min, to: extent.max }
if (span < 1e-9) span = 1e-9
if (from < extent.min) { from = extent.min; to = from + span }
if (to > extent.max) { to = extent.max; from = to - span }
return { from, to }
}, [extent])
const setView = useCallback((from: number, to: number) => {
viewRef.current = clampView(from, to)
scheduleFetch(); requestDraw()
}, [clampView, scheduleFetch, requestDraw])
const onWheel = useCallback((e: React.WheelEvent) => {
if (!extent) return
e.preventDefault()
const rect = canvasRef.current!.getBoundingClientRect()
const y = e.clientY - rect.top
const h = sizeRef.current.h
const v = y2v(y, h)
const { from, to } = viewRef.current
const factor = e.deltaY > 0 ? 1.2 : 0.83
let span = (to - from) * factor
const full = extent.max - extent.min
const minSpan = axis === 'time' ? 5 : 1
span = Math.max(minSpan, Math.min(full, span))
const frac = y / h
setView(v - frac * span, v - frac * span + span)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [extent, axis, setView])
const onMouseMove = (e: React.MouseEvent) => {
const rect = canvasRef.current!.getBoundingClientRect()
const h = sizeRef.current.h
const y = e.clientY - rect.top
// hover readout
const data = dataRef.current
const idx = y2v(y, h)
const rows: { label: string; color: string; v: number | null; unit: string }[] = []
if (data) {
const j = nearest(data.pos, idx)
for (const t of tracks) for (const cv of t.curves) {
const s = data.curves.find(x => x.key === cv.key)
const r = roleMap.get(cv.key)
let val: number | null = null
if (s && j >= 0) { const mn = s.min[j], mx = s.max[j]; if (mn != null && mx != null) val = (mn + mx) / 2 }
rows.push({ label: r?.mnemonic ?? cv.key, color: cv.color, v: val, unit: r?.unit ?? '' })
}
}
setReadout({ x: e.clientX - rect.left, y, index: idx, rows })
}
const onLeave = () => { setReadout(null) }
const zoomBy = useCallback((factor: number) => {
if (!extent) return
const { from, to } = viewRef.current
const c = (from + to) / 2
const full = extent.max - extent.min
const minSpan = axis === 'time' ? 5 : 1
const span = Math.max(minSpan, Math.min(full, (to - from) * factor))
setView(c - span / 2, c + span / 2)
}, [extent, axis, setView])
/* ---------- scrollbar (pan through depth/time) ---------- */
const thumbGeom = useCallback(() => {
const trackH = trackRef.current?.clientHeight ?? 1
const full = extent ? extent.max - extent.min : 1
const span = viewRef.current.to - viewRef.current.from
const th = Math.max(28, full > 0 ? (trackH * span) / full : trackH)
return { trackH, full, span, th, maxTop: trackH - th }
}, [extent])
const sbDrag = useRef<{ y: number; from: number } | null>(null)
const onThumbDown = (e: React.MouseEvent) => {
e.preventDefault(); e.stopPropagation()
if (!extent) return
sbDrag.current = { y: e.clientY, from: viewRef.current.from }
const move = (ev: MouseEvent) => {
if (!sbDrag.current) return
const g = thumbGeom()
const dy = ev.clientY - sbDrag.current.y
const dFrac = g.maxTop > 0 ? dy / g.maxTop : 0
const nf = sbDrag.current.from + dFrac * (g.full - g.span)
setView(nf, nf + g.span)
}
const up = () => { sbDrag.current = null; document.removeEventListener('mousemove', move); document.removeEventListener('mouseup', up) }
document.addEventListener('mousemove', move)
document.addEventListener('mouseup', up)
}
const onTrackDown = (e: React.MouseEvent) => {
if (!extent) return
const rect = trackRef.current!.getBoundingClientRect()
const clickY = e.clientY - rect.top
const g = thumbGeom()
const top = g.full > g.span ? ((viewRef.current.from - extent.min) / (g.full - g.span)) * g.maxTop : 0
const page = g.span * 0.9
if (clickY < top) setView(viewRef.current.from - page, viewRef.current.to - page)
else if (clickY > top + g.th) setView(viewRef.current.from + page, viewRef.current.to + page)
}
/* ---------- replay ---------- */
useEffect(() => {
if (!playing || !extent) return
let raf = 0
let last = performance.now()
const tick = (t: number) => {
const dt = (t - last) / 1000; last = t
const { from, to } = viewRef.current
const span = to - from
let nt = to + speed * dt
if (nt >= extent.max) {
viewRef.current = { from: extent.max - span, to: extent.max }
scheduleFetch(); requestDraw(); setPlaying(false); return
}
viewRef.current = clampView(nt - span, nt)
scheduleFetch(); requestDraw()
raf = requestAnimationFrame(tick)
}
raf = requestAnimationFrame(tick)
return () => cancelAnimationFrame(raf)
}, [playing, speed, extent, clampView, scheduleFetch, requestDraw])
const resetView = () => { if (extent) setView(extent.min, extent.max) }
/* ---------- track editing ---------- */
const toggleRole = (key: string) => {
setTracks(prev => {
const has = prev.some(t => t.curves.some(c => c.key === key))
if (has) {
return prev.map(t => ({ ...t, curves: t.curves.filter(c => c.key !== key) })).filter(t => t.curves.length)
}
const used = new Set(prev.flatMap(t => t.curves.map(c => c.key)))
const color = PALETTE[used.size % PALETTE.length]
return [...prev, { id: 't' + Date.now(), curves: [{ key, color }] }]
})
}
const removeCurve = (key: string) =>
setTracks(prev => prev.map(t => ({ ...t, curves: t.curves.filter(c => c.key !== key) })).filter(t => t.curves.length))
/* ---------- render ---------- */
const nT = tracks.length
const speedLabel = axis === 'time'
? `${fmtNum(Math.round(speed))} s/s`
: `${fmtNum(Math.round(speed))} ft/s`
if (roles && !roles.ready) {
return <div className="empty"><div><div className="spin" style={{ width: 18, height: 18 }} />
<div style={{ marginTop: 10 }}>Building curve overview</div>
<div className="muted" style={{ fontSize: 12, marginTop: 6 }}>parsing channels for the log plot</div></div></div>
}
if (roles && roles.roles.length === 0) {
return <div className="empty"><div>No drilling channels recognized in this file.<div className="muted" style={{ fontSize: 12, marginTop: 6 }}>Use the Raw / QC tab to inspect it.</div></div></div>
}
return (
<div className="lp">
<div className="viewer-bar">
{roles?.hasDepthAxis && roles?.hasTimeAxis && (
<div className="seg" style={{ margin: 0, width: 150 }}>
<button className={axis === 'depth' ? 'on' : ''} onClick={() => setAxis('depth')}>Depth</button>
<button className={axis === 'time' ? 'on' : ''} onClick={() => setAxis('time')}>Time</button>
</div>
)}
<button className="btn primary" onClick={() => setPlaying(p => !p)}>{playing ? '⏸ Pause' : '▶ Replay'}</button>
<div className="speed" title="replay speed">
<span>🐌</span>
<input type="range" min={axis === 'time' ? 30 : 2} max={axis === 'time' ? 14400 : 500}
value={speed} onChange={e => setSpeed(parseInt(e.target.value, 10))} />
<span>🚀</span>
<span style={{ fontFamily: 'var(--mono)', width: 78, textAlign: 'right' }}>{speedLabel}</span>
</div>
<div className="sep" />
<button className="btn ghost" onClick={() => zoomBy(0.6)} title="zoom in (or scroll the wheel over the plot)"></button>
<button className="btn ghost" onClick={() => zoomBy(1.7)} title="zoom out"></button>
<button className="btn ghost" onClick={resetView}> Fit</button>
<label className="speed" style={{ gap: 5 }} title="auto-fit: scale each track to the data visible in the current window">
<input type="checkbox" checked={autoscale} onChange={e => setAutoscale(e.target.checked)} /> auto-fit
</label>
<button className="btn ghost" onClick={() => setPickerOpen(o => !o)}> Curves</button>
<button className={`btn ghost${showStats ? ' primary' : ''}`} onClick={() => setShowStats(s => !s)}>Σ Stats</button>
<div className="grow" />
<span className="stat" style={{ fontFamily: 'var(--mono)', fontSize: 12, color: 'var(--txt-dim)' }}>
{detail ? 'detail' : 'overview'} · {axis}
</span>
</div>
{/* track headers aligned with canvas tracks */}
<div className="lp-heads">
<div className="lp-gutter-sp" style={{ width: GUTTER }}>
<span className="muted" style={{ fontSize: 10 }}>{axis === 'depth' ? 'DEPTH ft' : 'TIME'}</span>
</div>
{tracks.map((t) => (
<div className="lp-head" key={t.id} style={{ width: `calc((100% - ${GUTTER}px) / ${nT})` }}>
{t.curves.map(cv => {
const r = roleMap.get(cv.key)
const [mn, mx] = scaleFor(cv.key)
return (
<div className="lp-curve" key={cv.key} title={r?.description}>
<span className="dot" style={{ background: cv.color }} />
<span className="mn" style={{ color: cv.color }}>{r?.mnemonic ?? cv.key}</span>
<span className="rng">{fmtNum(Math.round(mn))}{fmtNum(Math.round(mx))}{r?.unit ? ' ' + r.unit : ''}</span>
<span className="rm" onClick={() => removeCurve(cv.key)}>×</span>
</div>
)
})}
</div>
))}
</div>
<div className="lp-body">
<div className="lp-canvas-wrap" ref={containerRef}>
<canvas
ref={canvasRef}
onWheel={onWheel}
onMouseMove={onMouseMove}
onMouseLeave={onLeave}
style={{ cursor: 'crosshair' }}
/>
{showStats && stats.length > 0 && (
<div className="lp-stats scroll">
<div className="sh">Window stats <span className="x" onClick={() => setShowStats(false)}>×</span></div>
<table>
<thead><tr><th>chan</th><th>min</th><th>avg</th><th>max</th></tr></thead>
<tbody>
{stats.map(s => (
<tr key={s.key}>
<td title={s.unit}>{s.mnemonic}</td>
<td>{s.min == null ? '—' : trim(s.min)}</td>
<td>{s.avg == null ? '—' : trim(s.avg)}</td>
<td>{s.max == null ? '—' : trim(s.max)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{readout && (
<div className="lp-readout" style={{ left: Math.min(readout.x + GUTTER + 8, sizeRef.current.w - 190), top: Math.min(readout.y + 8, sizeRef.current.h - 10 - readout.rows.length * 15) }}>
<div className="ix">{fmtIndex(readout.index, axis)}{axis === 'depth' ? ' ft' : ''}</div>
{readout.rows.map((r, i) => (
<div className="ro" key={i}>
<span className="dot" style={{ background: r.color }} />
<span className="l">{r.label}</span>
<span className="vv">{r.v == null ? '—' : trim(r.v)}</span>
</div>
))}
</div>
)}
</div>
<div className="lp-scrollbar" ref={trackRef} onMouseDown={onTrackDown} title="drag to scroll through the well; wheel over the plot to zoom">
<div className="lp-thumb" ref={thumbRef} onMouseDown={onThumbDown} />
</div>
</div>
{pickerOpen && roles && (
<CurvePicker roles={roles} active={new Set(tracks.flatMap(t => t.curves.map(c => c.key)))}
onToggle={toggleRole} onClose={() => setPickerOpen(false)} />
)}
</div>
)
}
/* ---------- curve picker popover ---------- */
function CurvePicker({ roles, active, onToggle, onClose }:
{ roles: RolesResponse; active: Set<string>; onToggle: (k: string) => void; onClose: () => void }) {
const groups = ['mechanics', 'hydraulics', 'gas', 'directional', 'index']
const labels: Record<string, string> = {
mechanics: 'Drilling mechanics', hydraulics: 'Hydraulics & well control',
gas: 'Mud gas / formation', directional: 'Directional & dynamics', index: 'Index / state',
}
return (
<div className="lp-picker">
<div className="hd">Curves <span className="x" onClick={onClose}>×</span></div>
<div className="bd scroll">
{groups.map(g => {
const rs = roles.roles.filter(r => r.group === g)
if (!rs.length) return null
return (
<div key={g} className="grp">
<div className="gh">{labels[g]}</div>
{rs.map(r => (
<label key={r.key} className={`chip${active.has(r.key) ? ' on' : ''}`}>
<input type="checkbox" checked={active.has(r.key)} onChange={() => onToggle(r.key)} />
<span className="mn">{r.mnemonic}</span>
<span className="ds">{r.label}</span>
</label>
))}
</div>
)
})}
</div>
</div>
)
}
/* ---------- helpers ---------- */
function nearest(pos: (number | null)[], v: number): number {
let lo = 0, hi = pos.length - 1, best = -1, bestD = Infinity
// pos is ascending but may contain nulls; linear-ish guarded binary
while (lo <= hi) {
const mid = (lo + hi) >> 1
const pv = pos[mid]
if (pv == null) { // probe outward
let k = mid + 1; while (k <= hi && pos[k] == null) k++
if (k > hi) { hi = mid - 1; continue }
const d = Math.abs((pos[k] as number) - v); if (d < bestD) { bestD = d; best = k }
if ((pos[k] as number) < v) lo = k + 1; else hi = mid - 1
continue
}
const d = Math.abs(pv - v); if (d < bestD) { bestD = d; best = mid }
if (pv < v) lo = mid + 1; else hi = mid - 1
}
return best
}
function niceTicks(from: number, to: number, count: number): number[] {
const span = to - from
if (span <= 0) return []
const raw = span / count
const mag = Math.pow(10, Math.floor(Math.log10(raw)))
const norm = raw / mag
const step = (norm < 1.5 ? 1 : norm < 3 ? 2 : norm < 7 ? 5 : 10) * mag
const start = Math.ceil(from / step) * step
const out: number[] = []
for (let v = start; v <= to; v += step) out.push(v)
return out
}
function fmtIndex(v: number, axis: 'depth' | 'time'): string {
if (axis === 'depth') return fmtNum(Math.round(v))
const d = new Date(v * 1000)
const hh = String(d.getHours()).padStart(2, '0')
const mm = String(d.getMinutes()).padStart(2, '0')
const ss = String(d.getSeconds()).padStart(2, '0')
return `${hh}:${mm}:${ss}`
}
function trim(v: number): string {
if (Math.abs(v) >= 1000) return fmtNum(Math.round(v))
return (Math.round(v * 100) / 100).toString()
}