import React, { useEffect, useRef, useState, useCallback } from 'react'; const ASPECT_RATIO = 3.2 / 2.2; const MARGINS = { top: 18, right: 16, bottom: 34, left: 56 }; function clamp(v, min, max) { return Math.max(min, Math.min(max, v)); } function round3(v) { return parseFloat(v.toFixed(3)); } function trimZeros(text) { return text.replace(/(?:\.0+|(\.\d+?)0+)$/, '$1'); } function formatTick(value) { const abs = Math.abs(value); if (abs === 0) return '0'; if (abs >= 1e4 || abs < 1e-3) { return value.toExponential(1).replace('e+', 'e'); } if (abs >= 100) return trimZeros(value.toFixed(0)); if (abs >= 10) return trimZeros(value.toFixed(1)); if (abs >= 1) return trimZeros(value.toFixed(2)); return trimZeros(value.toFixed(3)); } function makeTicks(min, max, count = 5) { if (!Number.isFinite(min) || !Number.isFinite(max)) return []; if (min === max) return [min]; const ticks = []; for (let i = 0; i < count; i += 1) { ticks.push(min + ((max - min) * i) / (count - 1)); } return ticks; } function getExtent(values, fallbackMin = 0, fallbackMax = 1) { if (!Array.isArray(values) || values.length === 0) { return [fallbackMin, fallbackMax]; } let min = Infinity; let max = -Infinity; for (const value of values) { if (!Number.isFinite(value)) continue; if (value < min) min = value; if (value > max) max = value; } if (!Number.isFinite(min) || !Number.isFinite(max)) { return [fallbackMin, fallbackMax]; } return [min, max]; } export default function LinePlotOverlay({ overlay, x1, x2, aLocked, bLocked, nodeId, onWidgetChange, interactive = true, }) { const containerRef = useRef(null); const [dragging, setDragging] = useState(null); const [size, setSize] = useState({ width: 0, height: 0 }); useEffect(() => { if (!containerRef.current) return undefined; const updateSize = () => { if (!containerRef.current) return; setSize({ width: Math.max(1, Math.round(containerRef.current.clientWidth || 320)), height: Math.max(1, Math.round(containerRef.current.clientHeight || (containerRef.current.clientWidth / ASPECT_RATIO) || 220)), }); }; updateSize(); if (typeof ResizeObserver === 'function') { const observer = new ResizeObserver((entries) => { const entry = entries[0]; if (!entry) return; const { width, height } = entry.contentRect; setSize({ width: Math.max(1, Math.round(width)), height: Math.max(1, Math.round(height)), }); }); observer.observe(containerRef.current); return () => observer.disconnect(); } window.addEventListener('resize', updateSize); return () => window.removeEventListener('resize', updateSize); }, []); const xValues = Array.isArray(overlay?.x_axis) && overlay.x_axis.length === overlay.line?.length ? overlay.x_axis : overlay?.line?.map((_, i) => i) || []; const yValues = Array.isArray(overlay?.line) ? overlay.line : []; const width = size.width || 320; const height = size.height || Math.round(width / ASPECT_RATIO); const plotLeft = MARGINS.left; const plotTop = MARGINS.top; const plotWidth = Math.max(1, width - MARGINS.left - MARGINS.right); const plotHeight = Math.max(1, height - MARGINS.top - MARGINS.bottom); const [xMin, xMax] = getExtent(xValues, 0, 1); const [yMinRaw, yMaxRaw] = getExtent(yValues, 0, 1); const yPad = yMinRaw === yMaxRaw ? 1 : (yMaxRaw - yMinRaw) * 0.08; const yMin = yMinRaw - yPad; const yMax = yMaxRaw + yPad; const scaleX = useCallback((value) => { if (xMax === xMin) return plotLeft + plotWidth / 2; return plotLeft + ((value - xMin) / (xMax - xMin)) * plotWidth; }, [plotLeft, plotWidth, xMin, xMax]); const scaleY = useCallback((value) => { if (yMax === yMin) return plotTop + plotHeight / 2; return plotTop + (1 - ((value - yMin) / (yMax - yMin))) * plotHeight; }, [plotTop, plotHeight, yMin, yMax]); const pickCursorPoint = useCallback((fraction) => { if (!xValues.length || !yValues.length) { return { x: plotLeft, y: plotTop + plotHeight / 2, yFraction: 0.5, }; } const frac = clamp(fraction ?? 0.5, 0, 1); const targetX = xMin + frac * (xMax - xMin || 1); let idx = 0; let best = Infinity; for (let i = 0; i < xValues.length; i += 1) { const dist = Math.abs(xValues[i] - targetX); if (dist < best) { best = dist; idx = i; } } const x = xValues[idx]; const y = yValues[idx]; const yFraction = yMax === yMin ? 0.5 : clamp((y - yMin) / (yMax - yMin), 0, 1); return { x: scaleX(x), y: scaleY(y), yFraction, }; }, [plotLeft, plotTop, plotHeight, scaleX, scaleY, xValues, yValues, xMin, xMax, yMin, yMax]); const cursorA = pickCursorPoint(x1 ?? overlay?.x1 ?? 0.25); const cursorB = pickCursorPoint(x2 ?? overlay?.x2 ?? 0.75); const path = yValues.map((y, i) => `${i === 0 ? 'M' : 'L'} ${scaleX(xValues[i])} ${scaleY(y)}`).join(' '); const xTicks = makeTicks(xMin, xMax); const yTicks = makeTicks(yMin, yMax); const plotStroke = clamp(plotWidth / 240, 1.4, 2.6); const gridStroke = clamp(plotWidth / 900, 0.6, 1.1); const cursorStroke = clamp(plotWidth / 220, 1.4, 2.2); const measureStroke = clamp(plotWidth / 180, 1.6, 2.8); const markerRadius = clamp(plotWidth / 42, 5.5, 9); const updateCursor = useCallback((point, event) => { if (!interactive || !onWidgetChange || !nodeId) return; if (!containerRef.current) return; const rect = containerRef.current.getBoundingClientRect(); const xFrac = clamp((event.clientX - rect.left - plotLeft) / plotWidth, 0, 1); const sample = pickCursorPoint(xFrac); if (point === 'p1') { onWidgetChange(nodeId, 'x1', round3(xFrac)); onWidgetChange(nodeId, 'y1', round3(sample.yFraction)); } else { onWidgetChange(nodeId, 'x2', round3(xFrac)); onWidgetChange(nodeId, 'y2', round3(sample.yFraction)); } }, [interactive, nodeId, onWidgetChange, pickCursorPoint, plotLeft, plotWidth]); const onPointerDown = useCallback((point) => (event) => { if (!interactive) return; if ((point === 'p1' && aLocked) || (point === 'p2' && bLocked)) return; event.preventDefault(); event.stopPropagation(); event.currentTarget.setPointerCapture(event.pointerId); setDragging(point); }, [interactive, aLocked, bLocked]); const onPointerMove = useCallback((event) => { if (!dragging) return; updateCursor(dragging, event); }, [dragging, updateCursor]); const onPointerUp = useCallback(() => { setDragging(null); }, []); return (