Spaces:
Runtime error
Runtime error
| import { useEffect, useMemo, useRef } from "react"; | |
| import { eventIcon, severityClass } from "../eventDecor"; | |
| import type { CountrySnapshot, EntitySnapshot, GameLogEventSnapshot } from "../types"; | |
| type EventFeedPanelProps = { | |
| events: GameLogEventSnapshot[]; | |
| countries?: CountrySnapshot[]; | |
| entities?: EntitySnapshot[]; | |
| }; | |
| export function EventFeedPanel({ events, countries = [], entities = [] }: EventFeedPanelProps) { | |
| const listRef = useRef<HTMLDivElement>(null); | |
| const isPinnedToBottomRef = useRef(true); | |
| // Resolve a speaker's nation colour (matching the scoreboard: Nemotron blue, | |
| // Qwen purple) so chronicle speech lines are tinted by who is talking. | |
| const colorByCountry = useMemo( | |
| () => new Map(countries.map((country) => [country.id, country.color])), | |
| [countries], | |
| ); | |
| const countryByActor = useMemo( | |
| () => new Map(entities.map((entity) => [entity.id, entity.country_id ?? null])), | |
| [entities], | |
| ); | |
| const speechColor = (event: GameLogEventSnapshot): string | undefined => { | |
| if (event.type !== "speech" || !event.actor_id) { | |
| return undefined; | |
| } | |
| // Fall back to the id prefix when the speaker has since died/left the snapshot. | |
| let countryId = countryByActor.get(event.actor_id) ?? null; | |
| if (!countryId) { | |
| if (event.actor_id.startsWith("qwen")) countryId = "qwen"; | |
| else if (event.actor_id.startsWith("npc")) countryId = "nemotron"; | |
| } | |
| return countryId ? colorByCountry.get(countryId) : undefined; | |
| }; | |
| useEffect(() => { | |
| const list = listRef.current; | |
| if (list && isPinnedToBottomRef.current) { | |
| list.scrollTop = list.scrollHeight; | |
| } | |
| }, [events]); | |
| return ( | |
| <aside className="eventFeed" aria-label="Event feed"> | |
| <div className="sidePanelHeader"> | |
| <span className="sidePanelTitle">📰 Village Chronicle</span> | |
| <span className="sidePanelHint">{events.length} events</span> | |
| </div> | |
| <div | |
| className="eventList" | |
| ref={listRef} | |
| onScroll={() => { | |
| const list = listRef.current; | |
| if (list) { | |
| isPinnedToBottomRef.current = | |
| list.scrollHeight - list.scrollTop - list.clientHeight < 24; | |
| } | |
| }} | |
| > | |
| {events.length === 0 ? ( | |
| <p className="eventEmpty">The village is quiet... for now.</p> | |
| ) : ( | |
| events.map((event, index) => { | |
| const color = speechColor(event); | |
| return ( | |
| <div | |
| className={`eventLine ${severityClass(event.severity)}`} | |
| key={`${event.tick}-${index}-${event.type}`} | |
| // Tint a speaker's line with their nation colour as a small | |
| // highlight (left bar + faint wash); the text itself stays light. | |
| style={ | |
| color | |
| ? { borderLeftColor: color, background: `${color}14` } | |
| : undefined | |
| } | |
| > | |
| <span className="eventTick">{event.tick}</span> | |
| <span className="eventIcon" aria-hidden="true"> | |
| {eventIcon(event.type)} | |
| </span> | |
| <span className="eventText">{event.summary}</span> | |
| </div> | |
| ); | |
| }) | |
| )} | |
| </div> | |
| </aside> | |
| ); | |
| } | |