"use client";
import { motion, useSpring } from "motion/react";
import { memo, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useChart, useChartStable } from "./chart-context";
import { hmsTimeFmt } from "./chart-formatters";
const TICKER_HALF_WIDTH = 50;
const FADE_BUFFER = 20;
const crosshairSpringConfig = { stiffness: 300, damping: 30 };
function labelFadeOpacity(
labelX: number,
crosshairX: number | null,
isHovering: boolean
): number {
if (!isHovering || crosshairX === null) {
return 1;
}
const distance = Math.abs(labelX - crosshairX);
if (distance < TICKER_HALF_WIDTH) {
return 0;
}
if (distance < TICKER_HALF_WIDTH + FADE_BUFFER) {
return (distance - TICKER_HALF_WIDTH) / FADE_BUFFER;
}
return 1;
}
export interface LiveXAxisProps {
/** Number of time labels. Default: 5 */
numTicks?: number;
/** Time formatter. Default: HH:MM:SS */
formatTime?: (t: number) => string;
}
const defaultFormatTime = (t: number) => hmsTimeFmt.format(new Date(t));
export function LiveXAxis(props: LiveXAxisProps) {
const { containerRef } = useChartStable();
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
const container = containerRef.current;
if (!(mounted && container)) {
return null;
}
return ;
}
const LiveXAxisInner = memo(function LiveXAxisInner({
numTicks = 5,
formatTime = defaultFormatTime,
container,
}: LiveXAxisProps & { container: HTMLDivElement }) {
const { xScale, margin, tooltipData } = useChart();
const domain = xScale.domain();
const startMs = domain[0]?.getTime() ?? 0;
const endMs = domain[1]?.getTime() ?? 0;
const labels = useMemo(() => {
const step = (endMs - startMs) / (numTicks - 1);
return Array.from({ length: numTicks }, (_, i) => {
const t = startMs + i * step;
const x = (xScale(new Date(t)) ?? 0) + margin.left;
return { x, label: formatTime(t), stableKey: i };
});
}, [startMs, endMs, numTicks, xScale, margin.left, formatTime]);
const isHovering = tooltipData !== null;
const crosshairX = tooltipData ? tooltipData.x + margin.left : null;
// Time pill label
const pillLabel = useMemo(() => {
if (!tooltipData) {
return null;
}
const timeMs = xScale.invert(tooltipData.x).getTime();
return formatTime(timeMs);
}, [tooltipData, xScale, formatTime]);
// Spring-animated pill position — matches TooltipIndicator's spring config
// so the pill and crosshair line move in lockstep
const pillX = tooltipData ? tooltipData.x + margin.left : 0;
const animatedPillX = useSpring(pillX, crosshairSpringConfig);
const springRef = useRef(animatedPillX);
springRef.current = animatedPillX;
useEffect(() => {
springRef.current.set(pillX);
}, [pillX]);
return createPortal(
{/* Time labels */}
{labels.map((l) => (
{l.label}
))}
{/* Time pill at crosshair — spring-animated to match crosshair line */}
{isHovering && pillLabel && (
{pillLabel}
)}
,
container
);
});
LiveXAxis.displayName = "LiveXAxis";
export default LiveXAxis;