diff options
Diffstat (limited to 'jb-ui/src/components/EventMarquee.tsx')
| -rw-r--r-- | jb-ui/src/components/EventMarquee.tsx | 235 |
1 files changed, 116 insertions, 119 deletions
diff --git a/jb-ui/src/components/EventMarquee.tsx b/jb-ui/src/components/EventMarquee.tsx index 8664e3b..4ce4f15 100644 --- a/jb-ui/src/components/EventMarquee.tsx +++ b/jb-ui/src/components/EventMarquee.tsx @@ -1,33 +1,45 @@ -import { EventEnvelope, EventType } from "@/api_fsb"; -import { selectLatestEvent, selectLatestEvents } from "@/models/grlEventsSlice"; +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 BASE_SPEED = 60; // px/sec at idle -const MAX_SPEED = 380; // px/sec at surge -const RATE_WINDOW = 6_000; // ms window to measure message frequency + const SPEED_EASING = 0.025; // how quickly speed transitions (per frame) -const RATE_TO_SPEED = 55; // px/sec added per msg/sec interface EventItemProps { event: EventEnvelope; } const EventTaskEnter = ({ event }: EventItemProps) => { + const payload = event.payload as TaskEnterPayload; return ( <> - <h2 className="text-lg">{event.product_user_id} Entered</h2> + <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 ( <> - <h2 className="text-lg">{event.product_user_id} Finished</h2> + <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> </> ) } @@ -36,7 +48,8 @@ const EventUserCreated = ({ event }: EventItemProps) => { return ( <> - <h2 className="text-lg">{event.product_user_id} Created</h2> + <span className="px-2 text-lg">{getUnicodeFlagIcon(event.payload.country_iso)}</span> + <p>{truncate(event.product_user_id!, 6, "*****")} Created</p> </> ) } @@ -59,10 +72,11 @@ const EventComponent = ({ event }: EventItemProps) => { return ( <div key={event.event_uuid!} - className="inline-flex items-center text-[10px]"> + className="inline-flex items-center text-[10px] + font-mono px-2"> {renderContent()} - <span className="text-red-500 pl-5">◆</span> + {/* <span className="text-red-500 pl-5">◆</span> */} </div > ) } @@ -70,116 +84,105 @@ const EventComponent = ({ event }: EventItemProps) => { // ─── Main Component ─────────────────────────────────────────────────────────── export default function NewsTicker() { - const latestEvent = useSelector(selectLatestEvent); - const latestEvents = useSelector(selectLatestEvents(10)); - const itemsRef = useRef(latestEvents); - + // --- Redux --- + const reduxItems = useSelector(selectAllEvents); + const targetSpeedRedux = useSelector(selectCurrentSpeed); // px/sec - const [speed, setSpeed] = useState(BASE_SPEED); - - const stripRef = useRef(null); - const containerRef = useRef(null); + // --- 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(BASE_SPEED); - const targetSpeed = useRef(BASE_SPEED); - const rafRef = useRef(null); - const lastTime = useRef(null); - + 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; - const nextId = useRef(1); - const pendingQueue = useRef([]); - const messageTimes = useRef([]); + newItems.forEach(item => seenUUIDs.current.add(item.event_uuid!)); + pendingQueue.current.push(...newItems); + }, [reduxItems]); + // --- Sync target speed from Redux --- + useEffect(() => { + targetSpeed.current = targetSpeedRedux; + }, [targetSpeedRedux]); - // keep itemsRef in sync + // --- RAF loop --- useEffect(() => { - console.log("Adding message to ticker:", latestEvent); - itemsRef.current = latestEvents; - }, [latestEvents]); - - // const addMessage = useCallback(() => { - // console.log("Adding message to ticker:", latestEvent); - - // // calc rate over window - // messageTimes.current = messageTimes.current.filter( - // (t) => now - t < RATE_WINDOW - // ); - // // const rate = messageTimes.current.length / (RATE_WINDOW / 1000); // msgs/sec - // // targetSpeed.current = Math.min(BASE_SPEED + rate * RATE_TO_SPEED, MAX_SPEED); - - // // pendingQueue.current.push({ id: nextId.current++, text }); - // }, [latestEvent]); - - // RAF animation loop - // useEffect(() => { - // const loop = (timestamp) => { - // if (!lastTime.current) lastTime.current = timestamp; - // const delta = (timestamp - lastTime.current) / 1000; // seconds - // lastTime.current = timestamp; - - // // ease speed toward target - // currentSpeed.current += - // (targetSpeed.current - currentSpeed.current) * SPEED_EASING; - - // // consume pending queue - // if (pendingQueue.current.length > 0) { - // const newItems = pendingQueue.current.splice(0); - // setItems((prev) => [...prev, ...newItems]); - // } - - // // advance strip - // translateX.current -= currentSpeed.current * delta; - - // // apply transform directly — no re-render - // if (stripRef.current) { - // stripRef.current.style.transform = `translateX(${translateX.current}px)`; - // } - - // // update speed display periodically (for the indicator) - // setSpeed(Math.round(currentSpeed.current)); - - // // prune items that have scrolled off-screen - // if (stripRef.current && containerRef.current) { - // const children = Array.from(stripRef.current.children); - // const containerLeft = containerRef.current.getBoundingClientRect().left; - // let pruneCount = 0; - // for (const child of children) { - // const rect = child.getBoundingClientRect(); - // if (rect.right < containerLeft - 20) { - // pruneCount++; - // } else { - // break; - // } - // } - // if (pruneCount > 0) { - // // measure how much width we're removing so we can adjust translateX - // let removedWidth = 0; - // for (let i = 0; i < pruneCount; i++) { - // removedWidth += children[i].getBoundingClientRect().width; - // } - // translateX.current += removedWidth; - // setItems((prev) => prev.slice(pruneCount)); - // } - // } - - // rafRef.current = requestAnimationFrame(loop); - // }; - - // rafRef.current = requestAnimationFrame(loop); - // return () => cancelAnimationFrame(rafRef.current); - // }, []); - - // speed bucket for visual indicator - // const speedLevel = - // currentSpeed.current < 100 ? 0 : - // currentSpeed.current < 180 ? 1 : - // currentSpeed.current < 260 ? 2 : 3; - // const speedLabels = ["NORMAL", "ELEVATED", "HIGH", "SURGE"]; - // const speedColors = ["#22d3ee", "#f59e0b", "#f97316", "#ef4444"]; + // 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={{ - fontFamily: "'Courier New', monospace", minHeight: "3vh", display: "flex", flexDirection: "column", justifyContent: "center", alignItems: "center", gap: "0" }}> @@ -210,7 +213,7 @@ export default function NewsTicker() { zIndex: 2, }} > - {latestEvents.map((item, i) => ( + {scrollItems.map(item => ( <EventComponent event={item} /> ))} @@ -222,12 +225,6 @@ export default function NewsTicker() { </div> - <style>{` - @keyframes pulse { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.3; } - } - `}</style> </div> ); } |
