| name | events-panels |
| description | EventsPanels system for Chronicle combat log analysis. Processes WoW combat log event streams
(damage, heal, resource_change, slain, cast, aura, extra_attack) into displayable metrics.
Key architecture: processors are worker-safe pure TypeScript (NO React), panels are React wrappers.
Processing runs in a Web Worker pool to keep UI responsive.
Includes Focus feature: right-click player rows to drill into per-ability breakdown (URL-persisted).
|
Events Panels
When to Use This Skill
Use this skill when:
- Adding a new panel to display combat log metrics
- Modifying existing panel aggregation logic
- Adding new stream types or event processing
- Adding or modifying the Focus feature (right-click → ability breakdown)
- Debugging panel performance issues
Architecture Overview
EventsPanels split into two files: processors (worker-safe) and React wrappers.
Processor (worker-safe) + Panel (React wrapper)
───────────────────────── ────────────────────────
processors/foo.processor.ts FooPanel/Foo.tsx
- id, streams, createState - label, icon
- processEvent() - render()
- spreads processor props
Data Flow:
usePanelAggregation fetches required streams from InstanceEventsContext (cached)
- Streams + context are sent to Web Worker via
postMessage
- Worker iterates events, calls
processor.processEvent()
- Worker returns serialized result (Maps become arrays with markers)
- Hook deserializes result and triggers re-render
Key Directory Structure:
frontend/chronicle/src/pages/Instance/EventsPanels/
├── types.ts # React-side types (PanelContext, PanelDefinition)
├── processorTypes.ts # Worker-safe types (ProcessorContext, PanelProcessor)
├── panelWorker.ts # Web Worker that runs processEvent
├── workerPool.ts # Worker pool manager
├── usePanelAggregation.ts # Hook that manages worker lifecycle
├── EventsPanel.tsx # Container with PANELS registry
├── PanelSelector.tsx # Dropdown with PANEL_CATEGORIES
├── processors/ # Shared processors and utilities
│ ├── index.ts # processorRegistry + exports
│ ├── guidCache.ts # GUID parsing cache
│ └── abilityBreakout.ts # Shared breakout accumulation
└── <PanelName>/ # Panel directories (e.g., DamageDone/)
├── <name>.processor.ts
└── <PanelName>.tsx
Authoritative Reference: frontend/chronicle/src/pages/Instance/EventsPanels/DESIGN.md
Checklist for Adding a New Panel
Step 1: Create the processor
Create MyPanel/myPanel.processor.ts (or processors/myPanel.processor.ts for shared processors):
import type { PanelProcessor, ProcessorContext, HealProcessorEvent } from "../processorTypes";
export interface MyPanelResult {
data: Map<string, number>;
}
export const myPanelProcessor: PanelProcessor<MyPanelResult, HealProcessorEvent> = {
id: "my_panel",
streams: ["heal"],
createState: (): MyPanelResult => ({
data: new Map(),
}),
processEvent: (state, event, encounterID, firstTimestamp, streamType, context) => {
if (!context.selectedEncounterIds.has(encounterID)) return;
if (context.entitySelection.playerIds.size > 0) {
if (!context.entitySelection.playerIds.has(event.caster)) return;
}
const key = event.caster || "Unknown";
state.data.set(key, (state.data.get(key) || 0) + event.amount);
},
};
Step 2: Register the processor in processors/index.ts
import { myPanelProcessor } from "../MyPanel/myPanel.processor";
export { myPanelProcessor } from "../MyPanel/myPanel.processor";
export type { MyPanelResult } from "../MyPanel/myPanel.processor";
export const processorRegistry: Record<string, PanelProcessor<any, any>> = {
my_panel: myPanelProcessor,
};
Step 3: Create the React wrapper (MyPanel/MyPanel.tsx)
import { Heart } from "lucide-react";
import type { PanelDefinition, PanelRenderProps } from "../types";
import { myPanelProcessor, type MyPanelResult } from "./myPanel.processor";
export function createMyPanel(): PanelDefinition<MyPanelResult, HealProcessorEvent> {
return {
...myPanelProcessor,
label: "My Panel",
icon: <Heart className="h-4 w-4" />,
supportsPerSecond: true,
render: (props: PanelRenderProps<MyPanelResult>) => (
<MyPanelContent {...props} />
),
};
}
function MyPanelContent({ result, context, perSecond, durationMs }: PanelRenderProps<MyPanelResult>) {
return <div>...</div>;
}
Step 4: Register in EventsPanel.tsx
import { createMyPanel } from "./MyPanel/MyPanel";
export const PANELS: Record<string, PanelDefinition<any, any>> = {
my_panel: createMyPanel(),
};
export type EventsPanelType = keyof typeof PANELS;
Step 5: Add to PANEL_CODES in hooks/useUrlState.ts
const PANEL_CODES: Record<PanelType, string> = {
my_panel: 'mp',
};
TypeScript will error if you miss this step.
Step 6: Add to PANEL_CATEGORIES in PanelSelector.tsx
const PANEL_CATEGORIES: PanelCategory[] = [
{
label: "Healing",
items: ["healing_done", "healing_taken", "my_panel"],
},
];
Key Types
Stream Types
Available streams for processor.streams:
type StreamType = "damage" | "heal" | "resource_change" | "slain" | "cast" | "aura" | "extra_attack";
ProcessorEvent Types
Events are typed based on the stream they come from:
type ProcessorEvent =
| DamageProcessorEvent
| HealProcessorEvent
| ResourceChangeProcessorEvent
| ExtraAttackProcessorEvent
| SlainProcessorEvent
| CastProcessorEvent
| AuraProcessorEvent;
interface EventMeta {
index: number;
offsetMilli: number;
}
PanelProcessor<TResult, TEvent> (Worker-safe)
interface PanelProcessor<TResult, TEvent extends ProcessorEvent = ProcessorEvent> {
id: string;
streams: StreamType[];
createState: () => TResult;
processEvent: (
state: TResult,
event: TEvent,
encounterID: string,
firstTimestamp: Date,
streamType: StreamType,
context: ProcessorContext,
) => void;
}
ProcessorContext (Worker-safe)
interface ProcessorContext {
players: Record<string, { name: string; class: string }>;
units?: Record<string, { name: string; owner: string | null; entry: number }>;
selectedEncounterIds: Set<string>;
entitySelection: {
enemyIds: Set<string>;
playerIds: Set<string>;
};
pagination?: ProcessorPagination;
}
PanelDefinition (React wrapper)
interface PanelDefinition<TResult, TEvent> extends PanelProcessor<TResult, TEvent> {
label: string;
icon: React.ReactNode;
supportsPerSecond?: boolean;
checkboxLabel?: string;
selfManagesAggregation?: boolean;
render: (props: PanelRenderProps<TResult>) => React.ReactNode;
}
PanelRenderProps
interface PanelRenderProps<TResult> {
result: TResult;
totalEvents: number;
processingTimeMs: number | null;
durationMs: number;
perSecond: boolean;
checkboxChecked: boolean;
loading: boolean;
processing: boolean;
error: Error | null;
context: PanelContext;
}
Utilities
GuidCache
For panels that parse GUIDs frequently:
import { createGuidCache, getCachedGuid, isPlayerGuidFast } from "../processors/guidCache";
createState: () => ({
data: new Map(),
guidCache: createGuidCache(),
}),
processEvent: (state, event, ...) => {
if (isPlayerGuidFast(event.caster)) { }
const guid = getCachedGuid(state.guidCache, event.caster);
if (guid.isPlayer()) { ... }
if (guid.isPet()) { ... }
},
AbilityBreakout
For panels that show damage/healing by ability:
import { accumulateAbilityBreakout, type DamageAbilityBreakout } from "../processors/abilityBreakout";
accumulateAbilityBreakout(state.ByAbility, entityId, abilityName, amount, hitType);
Caching Behavior
| What | Cached Where | Lifetime |
|---|
Raw stream data (Uint8Array) | InstanceEventsContext | Until instance changes |
| Decoded events | Never cached | Re-decoded per worker request |
| Aggregated results | React state | Until context/panel changes |
Multiple panels requesting the same stream type share cached data.
Focus Feature (Right-Click → Ability Breakdown)
Panels that show per-player bar charts (via PlayerMetricChart) support a Focus feature: right-clicking a player row opens a context menu with a "Focus" option that replaces the per-player view with a per-ability bar chart for the focused player.
Currently Supported Panels
- DamageDone (
DamageDoneContent.tsx) — uses result.ByAbility
- HealingDone (
HealingDoneContent.tsx) — uses result.HealerByAbility / HealerByAbilityOverheal / HealerByAbilityTotal
- HealingTaken (
HealingTakenContent.tsx) — uses result.TargetByAbility / TargetByAbilityOverheal / TargetByAbilityTotal
How It Works
PlayerMetricChart has an onRowContextMenu prop that fires on right-click with (playerId, event)
RowContextMenu (components/ui/PlayerMetricChart/RowContextMenu.tsx) renders a portal-based context menu
- Focus state is persisted in
panelOption (URL) as a f:<playerId> token, comma-separated with other tokens
- Focused view maps
ByAbility entries to PlayerMetricChartData[] and renders them through the same PlayerMetricChart
- Focused breakout shows hit-type detail (hits/crits/misses/min/max) when clicking an ability bar
registerChartData registers the active view's data (focused abilities or normal players) for cross-panel comparison
URL Encoding (panelOption)
Focus is encoded as f:<playerId> alongside other panel-specific tokens:
dd[f:0xABC123] — damage panel focused on player
pdd[pet,f:0xABC123] — pet panel with grouping + focus
hd[overheal,ranks,f:0xABC123] — healing done: overheal mode + ranks + focus
ht[f:0xABC123] — healing taken focused on player
Adding Focus to a New Panel
If your panel renders a PlayerMetricChart with per-player data and has a ByAbility-style map in its processor result, add Focus by following the pattern in DamageDoneContent.tsx:
const focusedPlayerId = useMemo(() => {
if (!panelOption) return null;
const token = panelOption.split(",").find(t => t.startsWith("f:"));
return token ? token.slice(2) : null;
}, [panelOption]);
const setFocusedPlayerId = useCallback((id: string | null) => {
setPanelOption?.(serializeWithFocus(existingTokens, id));
}, [setPanelOption, ...]);
const focusedAbilityData = useMemo(() => {
if (!focusedPlayerId || !result) return null;
const abilities = result.ByAbility.get(focusedPlayerId);
if (!abilities) return null;
const barClassName = focusedPlayer?.className ?? "foreground";
return [...abilities.entries()]
.map(([name, stats]) => ({
playerID: name, playerName: name,
className: barClassName, specialization: "",
value: stats.Total,
}))
.sort((a, b) => b.value - a.value);
}, [focusedPlayerId, result, focusedPlayer?.className]);
const focusedBreakout = useCallback((abilityName: string, pinned: boolean) => {
}, [...]);
useEffect(() => {
registerChartData?.(focusedAbilityData ?? normalData);
}, [registerChartData, focusedAbilityData, normalData]);
const activeData = focusedAbilityData ?? normalData;
const total = activeData.reduce((sum, d) => sum + d.value, 0);
{focusedPlayerId && focusedAbilityData ? (
<PlayerMetricChart data={focusedAbilityData} breakout={focusedBreakout} ... />
) : (
<PlayerMetricChart data={normalData} breakout={breakout}
onRowContextMenu={handleRowContextMenu} ... />
)}
Key components to import:
RowContextMenu from @/components/ui/PlayerMetricChart/RowContextMenu
AbilityBreakout, type AbilityData from @/components/ui/AbilityBreakout
ChevronLeft from lucide-react (for back button)
Interaction Behavior
| Action | Normal View | Focused View |
|---|
| Left-click row | Breakout tooltip (ability list) | Breakout tooltip (hit-type stats) |
| Right-click row | Context menu → "Focus" | No context menu |
| Back / ESC | n/a | Returns to normal view |
| Total display | Sum of all players | Sum of focused player's abilities |
| Comparison table | Matches by player ID | Matches by ability name |
| Per-second toggle | Per player | Per ability |
Anti-Patterns
Processors MUST NOT:
- Import React or JSX (runs in Web Worker)
- Store event object references (the object is reused - copy values)
- Hold onto non-serializable data (no functions, no circular refs)
Performance Tips:
Common Mistakes:
- Forgetting to add processor to
processorRegistry (Step 2)
- Forgetting
PANEL_CODES in useUrlState.ts (Step 5) - TypeScript will error
- Importing processor types from
types.ts instead of processorTypes.ts in worker code
Example: Minimal Panel
export interface EmptyResult {}
export const emptyProcessor: PanelProcessor<EmptyResult> = {
id: "empty",
streams: [],
createState: () => ({}),
processEvent: () => {},
};
export function createEmptyPanel(): PanelDefinition<EmptyResult> {
return {
...emptyProcessor,
label: "Empty",
icon: <Square className="h-4 w-4" />,
render: () => <div>Select a panel type</div>,
};
}