Files
LAS-stream-viewer/frontend/src/components/LogPlot.tsx

630 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()
}