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(null) const roleMap = useMemo(() => { const m = new Map() roles?.roles.forEach(r => m.set(r.key, r)) return m }, [roles]) const [axis, setAxis] = useState<'depth' | 'time'>('depth') const [tracks, setTracks] = useState([]) 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(null) const canvasRef = useRef(null) const viewRef = useRef<{ from: number; to: number }>({ from: 0, to: 1 }) const dataRef = useRef(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(null) const thumbRef = useRef(null) const windowRangeRef = useRef>(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() 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
Building curve overview…
parsing channels for the log plot
} if (roles && roles.roles.length === 0) { return
No drilling channels recognized in this file.
Use the Raw / QC tab to inspect it.
} return (
{roles?.hasDepthAxis && roles?.hasTimeAxis && (
)}
🐌 setSpeed(parseInt(e.target.value, 10))} /> 🚀 {speedLabel}
{detail ? 'detail' : 'overview'} · {axis}
{/* track headers aligned with canvas tracks */}
{axis === 'depth' ? 'DEPTH ft' : 'TIME'}
{tracks.map((t) => (
{t.curves.map(cv => { const r = roleMap.get(cv.key) const [mn, mx] = scaleFor(cv.key) return (
{r?.mnemonic ?? cv.key} {fmtNum(Math.round(mn))}–{fmtNum(Math.round(mx))}{r?.unit ? ' ' + r.unit : ''} removeCurve(cv.key)}>×
) })}
))}
{showStats && stats.length > 0 && (
Window stats setShowStats(false)}>×
{stats.map(s => ( ))}
chanminavgmax
{s.mnemonic} {s.min == null ? '—' : trim(s.min)} {s.avg == null ? '—' : trim(s.avg)} {s.max == null ? '—' : trim(s.max)}
)} {readout && (
{fmtIndex(readout.index, axis)}{axis === 'depth' ? ' ft' : ''}
{readout.rows.map((r, i) => (
{r.label} {r.v == null ? '—' : trim(r.v)}
))}
)}
{pickerOpen && roles && ( t.curves.map(c => c.key)))} onToggle={toggleRole} onClose={() => setPickerOpen(false)} /> )}
) } /* ---------- curve picker popover ---------- */ function CurvePicker({ roles, active, onToggle, onClose }: { roles: RolesResponse; active: Set; onToggle: (k: string) => void; onClose: () => void }) { const groups = ['mechanics', 'hydraulics', 'gas', 'directional', 'index'] const labels: Record = { mechanics: 'Drilling mechanics', hydraulics: 'Hydraulics & well control', gas: 'Mud gas / formation', directional: 'Directional & dynamics', index: 'Index / state', } return (
Curves ×
{groups.map(g => { const rs = roles.roles.filter(r => r.group === g) if (!rs.length) return null return (
{labels[g]}
{rs.map(r => ( ))}
) })}
) } /* ---------- 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() }