630 lines
26 KiB
TypeScript
630 lines
26 KiB
TypeScript
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()
|
||
}
|