aboutsummaryrefslogtreecommitdiff
path: root/jb-ui/src/components/EventMarquee.tsx
diff options
context:
space:
mode:
authorMax Nanis2026-03-03 04:55:07 -0500
committerMax Nanis2026-03-03 04:55:07 -0500
commit979e789c388e7a1e9a90e448d82e297c9c296a3e (patch)
treeb2ad366338249416f029e1820a7d02e9caf87c57 /jb-ui/src/components/EventMarquee.tsx
parentdec4f45c0755f65a322f6c66833c711dec2c6abb (diff)
downloadamt-jb-979e789c388e7a1e9a90e448d82e297c9c296a3e.tar.gz
amt-jb-979e789c388e7a1e9a90e448d82e297c9c296a3e.zip
country-flag-icon, grlEventsSlice overhaul (ditch latestModel tracking for useRef list in Component). speed calc in createSelector(), seperated components.
Diffstat (limited to 'jb-ui/src/components/EventMarquee.tsx')
-rw-r--r--jb-ui/src/components/EventMarquee.tsx235
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>
);
}