diff options
Diffstat (limited to 'jb-ui/src/components/EventMarquee.tsx')
| -rw-r--r-- | jb-ui/src/components/EventMarquee.tsx | 230 |
1 files changed, 230 insertions, 0 deletions
diff --git a/jb-ui/src/components/EventMarquee.tsx b/jb-ui/src/components/EventMarquee.tsx new file mode 100644 index 0000000..4ce4f15 --- /dev/null +++ b/jb-ui/src/components/EventMarquee.tsx @@ -0,0 +1,230 @@ +import { EventEnvelope, EventType, TaskEnterPayload, TaskFinishPayload } from "@/api_fsb"; +import { formatSecondsVerbose, formatSource, formatStatus, truncate } from "@/lib/utils"; +import { selectAllEvents, selectCurrentSpeed } from "@/models/grlEventsSlice"; +import getUnicodeFlagIcon from 'country-flag-icons/unicode'; +import { useEffect, useRef, useState } from "react"; +import { useSelector } from "react-redux"; + +// ─── Config ─────────────────────────────────────────────────────────────────── + +const SPEED_EASING = 0.025; // how quickly speed transitions (per frame) + +interface EventItemProps { + event: EventEnvelope; +} + +const EventTaskEnter = ({ event }: EventItemProps) => { + const payload = event.payload as TaskEnterPayload; + + return ( + <> + <span className="px-2 text-lg">{getUnicodeFlagIcon(payload.country_iso)}</span> + <p> + {truncate(event.product_user_id!, 6, "*****")} Entered{" "} + {formatSource(payload.source)}{" "} + (#{truncate(payload.survey_id!, 5, "***")}) + </p> + </> + ) +} + +const EventTaskFinish = ({ event }: EventItemProps) => { + const payload = event.payload as TaskFinishPayload; + + return ( + <> + <span className="px-2 text-lg">{getUnicodeFlagIcon(event.payload.country_iso)}</span> + <p> + {truncate(event.product_user_id!, 6, "*****")}{" "} + {formatStatus(payload.status)} {formatSource(payload.source)}{" "} + (#{truncate(payload.survey_id!, 5, "***")}) in{" "} + {formatSecondsVerbose(payload.duration_sec!)} + </p> + </> + ) +} + +const EventUserCreated = ({ event }: EventItemProps) => { + + return ( + <> + <span className="px-2 text-lg">{getUnicodeFlagIcon(event.payload.country_iso)}</span> + <p>{truncate(event.product_user_id!, 6, "*****")} Created</p> + </> + ) +} + + +const EventComponent = ({ event }: EventItemProps) => { + + const renderContent = () => { + switch (event.event_type) { + case EventType.TaskEnter: + return <EventTaskEnter event={event} /> + case EventType.TaskFinish: + return <EventTaskFinish event={event} /> + case EventType.UserCreated: + return <EventUserCreated event={event} /> + default: + return <><p>Unknown event</p></> + } + } + + return ( + <div key={event.event_uuid!} + className="inline-flex items-center text-[10px] + font-mono px-2"> + {renderContent()} + + {/* <span className="text-red-500 pl-5">◆</span> */} + </div > + ) +} + + +// ─── Main Component ─────────────────────────────────────────────────────────── +export default function NewsTicker() { + // --- Redux --- + const reduxItems = useSelector(selectAllEvents); + const targetSpeedRedux = useSelector(selectCurrentSpeed); // px/sec + + // --- Scroll state (all refs — no re-renders from animation) --- + const containerRef = useRef<HTMLDivElement | null>(null); // The ticker band + const stripRef = useRef<HTMLDivElement | null>(null); // Scrolling strip + const translateX = useRef(0); + const currentSpeed = useRef(targetSpeedRedux); + const targetSpeed = useRef(targetSpeedRedux); + + // Only state and refs persist across renders, the rest are re-initialized, + // that is why this isn't just a standard variable. + // Request Animation Frame reference (stores the animation frame ID) + const rafRef = useRef<number | null>(null); + const lastTime = useRef<number | null>(null); + + // --- Item tracking --- + // Track which IDs are already in the scroller to only append + // genuinely new ones. + const seenUUIDs = useRef(new Set<string>()); + const pendingQueue = useRef<EventEnvelope[]>([]); + const [scrollItems, setScrollItems] = useState<EventEnvelope[]>([]); + + // --- Sync Redux → pendingQueue (only new items) --- + useEffect(() => { + const newItems = reduxItems.filter((item) => !seenUUIDs.current.has(item.event_uuid!)); + if (newItems.length === 0) return; + + newItems.forEach(item => seenUUIDs.current.add(item.event_uuid!)); + pendingQueue.current.push(...newItems); + }, [reduxItems]); + + // --- Sync target speed from Redux --- + useEffect(() => { + targetSpeed.current = targetSpeedRedux; + }, [targetSpeedRedux]); + + // --- RAF loop --- + useEffect(() => { + // This timestamp is a "high-resolution timestamp" provided by + // the browser, which is more accurate for measuring frame intervals + // than Date.now() + const loop = (timestamp: number) => { + console.log("RAF loop, timestamp:", timestamp, lastTime.current); + + if (!lastTime.current) lastTime.current = timestamp; + const delta = (timestamp - lastTime.current) / 1000; + lastTime.current = timestamp; + + // Slow easement towards targetSpeed + currentSpeed.current += + (targetSpeed.current - currentSpeed.current) * SPEED_EASING; + + // flush pending queue into React state + if (pendingQueue.current.length > 0) { + const incoming = pendingQueue.current.splice(0); + setScrollItems((prev) => [...prev, ...incoming]); + } + + // Advance strip, slides it over left by X pixels + translateX.current -= currentSpeed.current * delta; + if (stripRef.current) { + stripRef.current.style.transform = `translateX(${translateX.current}px)`; + } + + // prune items that have fully exited the left edge + if (stripRef.current && containerRef.current) { + const children = Array.from(stripRef.current.children); + const containerLeft = containerRef.current.getBoundingClientRect().left; + let pruneCount = 0; + let removedWidth = 0; + + for (const child of children) { + const rect = child.getBoundingClientRect(); + if (rect.right < containerLeft - 20) { + pruneCount++; + removedWidth += rect.width; + } else { + break; + } + } + + if (pruneCount > 0) { + translateX.current += removedWidth; + setScrollItems((prev) => prev.slice(pruneCount)); + } + } + + // Assign the next loop + rafRef.current = requestAnimationFrame(loop); + }; + + rafRef.current = requestAnimationFrame(loop); + return () => cancelAnimationFrame(rafRef.current!); + }, []); + + return ( + <div style={{ + minHeight: "3vh", display: "flex", flexDirection: "column", + justifyContent: "center", alignItems: "center", gap: "0" + }}> + + {/* Ticker wrapper */} + <div style={{ width: "100%", position: "relative" }}> + + {/* The ticker band */} + <div + ref={containerRef} + style={{ + overflow: "hidden", + padding: "0", + position: "relative", + }} + > + + {/* Scrolling strip */} + <div + ref={stripRef} + style={{ + display: "inline-flex", + alignItems: "center", + whiteSpace: "nowrap", + padding: "10px 0", + willChange: "transform", + position: "relative", + zIndex: 2, + }} + > + {scrollItems.map(item => ( + <EventComponent event={item} /> + ))} + + {/* Trailing spacer so new content doesn't immediately snap in */} + <span style={{ paddingRight: "100vw", display: "inline-block" }} /> + + </div> + </div> + + </div> + + </div> + ); +} |
