Report Components: Enhanced Markdown
Enhanced Markdown is a powerful syntax for building rich text, initially created at Warcraft Logs for writing content for articles, but now also used in various parts of development as a concise way to build common parts of UI. It is exposed as an option in Report Components so that users can also use it to easily style and decorate their generated content.
When Should I Use It?
EnhancedMarkdown
is a good fit when you want to describe what has happened in a sentence or paragraph. This might be a simple concise aggregation of how many players were hit by a certain ability based on damage events, or could be a suggestion on how a player could improve their performance in the next pull based on buff uptimes and ability usage.
What Is Available?
You can check the Enhanced Markdown help article to see what is available. Note that the article covers all possible Enhanced Markdown, but not all of that is available in Report Components. Notably, the following components are removed:
SiteTitle
,
GameName
,
SupportEmailLink
,
DiscordInviteLink
,
TwitterLink
,
If
,
Iframe
,
Image
,
Snippet
.
But that still leaves plenty to work with. The most useful will likely be the various Icon
-based components for abilities and actors. Importantly, the ability icons include tooltips like the rest of the site.
Example: Narrative Mechanical Analysis
The best way to learn is with an example! It's recommended to follow along using your own Report Components dashboard.
This example is going to use Warcraft Logs. We're going to build a component that tells us how well we performed for Halondrus's Aftershock ability.
The final result will look like this:
You can test this component against this Heroic Sepulcher of the First Ones Report.
1. Guard against incorrect use
First of all, we know that this component is only going to work when the user is looking at Halondrus fights. When the user is not looking at Halondrus, we should show them a friendly message instead of trying to run and crashing.
reportGroup
is always available to component scripts, and contains most of the information we'll use for building our visualisations. reportGroup.fights
contains all the fights that the user currently has selected.
Note that reportGroup.fights
is different to reportGroup.allFights
. The latter includes all the fights across all the reports being viewed regardless of what filters the user has selected, and should rarely be used. This is also the case for properties like events
vs allEvents
.
Each fight
has an encounterId
property that indicates which encounter it is for. We know that Halondrus's encounterId
is 2529
, so we can detect if all the fights selected are for Halondrus using:
getComponent = () => {
const halondrusEncounterId = 2529;
const isHalondrus = reportGroup.fights
.every(fight => fight.encounterId === halondrusEncounterId);
return isHalondrus;
}
When we run this, we should see something like:
This is just using the default JsonTree
component to render our result. It's a quick way to verify the value of a variable. However, we would rather show a user-friendly message, so lets return some Enhanced Markdown instead:
getComponent = () => {
const halondrusEncounterId = 2529;
const isHalondrus = reportGroup.fights
.every(fight => fight.encounterId === halondrusEncounterId);
if (!isHalondrus) {
return {
component: 'EnhancedMarkdown',
props: {
content: `This component only works for <EncounterIcon id="${halondrusEncounterId}">Halondrus</EncounterIcon>.`
}
}
}
return 'Not yet implemented.';
}
Now, if the encounter is not Halondrus, we specify that we want to render the EnhancedMarkdown
component with the content of This component only works for <EncounterIcon id="2529">Halondrus</EncounterIcon>.
(JavaScript string interpolation will replace ${halondrusEncounterId}
with the value we set it to earlier). The EncounterIcon
Enhanced Markdown component shows a small boss icon next to the boss name, with the name colored in the same style as other boss names on Warcraft Logs. Test this component while selecting Halondrus and while not selecting Halondrus. When not selecting Halondrus, you should see:
2. Filter to the relevant events
Now that we know we're looking at the right encounter, we can start our analysis. We want to count how many times the raid got hit by Aftershock. We will use the ability ID of Aftershock to find the damage events associated with it.
First, it's worth noting that there was a game update that changed the ability ID of Aftershock. This means that, to support reports from both before and after the game update, we need to look for 2 possible ability IDs. Sometimes, abilities have different IDs on different difficulties, and a similar strategy needs to be used then, too.
reportGroup
also has an abilities
property. This contains all the abilities that were present across all the reports. Note that this isn't filtered by the user's selection, so will also contain abilities from other encounters. To find our Aftershock ability, we do:
const aftershock = reportGroup.abilities
.find(x => [369650, 369651].includes(x.id));
Feel free to return aftershock
if you want to inspect what data it includes.
Now that we have the ability, lets filter the events in the selected fights. fight
has an events
property with this data. Let's start simple: if we were only interested in the first selected fight, we could do:
const damageEvents = reportGroup.fights[0].events
.filter(event => event.type === 'damage' &&
event.ability.id === aftershock.id);
However, because filtering events by type is such a common operation, fight
also has an eventsByCategoryAndDisposition
for this. This uses caching and should always be preferred where possible. So now we have:
const damageEvents = reportGroup.fights[0]
.eventsByCategoryAndDisposition('damage', 'enemy')
.filter(event => event.ability.id === aftershock.id);
Next, it's worth noting that the value of our aftershock
variable could be undefined
. For example, if the only fight in the reports is a very short Halondrus wipe where Aftershock is never cast, it will never be logged and will not be present in reportGroup.abilities
. This means that the code will crash when we try to access aftershock.id
. The JavaScript Optional Chaining operator ?.
can help us out. When aftershock
is undefined
, aftershock?.id
will also return undefined
, instead of crashing. This won't match any abilities and will mean damageEvents
is an empty array.
const damageEvents = reportGroup.fights[0]
.eventsByCategoryAndDisposition('damage', 'enemy')
.filter(event => event.ability.id === aftershock?.id);
Finally, we obviously want to make sure our component works when more than a single fight is selected. The flatMap
array method can help us here. This lets us perform an operation on each fight
in fights
, and then concatenate the results together:
const damageEvents = reportGroup.fights
.flatMap(fight => fight
.eventsByCategoryAndDisposition('damage', 'enemy')
.filter(event => event.ability.id === aftershock?.id)
);
Finally, we can calculate the total hits using const totalHits = damageEvents.length
and return it for some very basic analysis. Our script now looks like this:
getComponent = () => {
// Guard against incorrect use
const halondrusEncounterId = 2529;
const isHalondrus = reportGroup.fights
.every(fight => fight.encounterId === halondrusEncounterId);
if (!isHalondrus) {
return {
component: 'EnhancedMarkdown',
props: {
content: `This component only works for <EncounterIcon id="${halondrusEncounterId}">Halondrus</EncounterIcon>.`
}
}
}
// Filter to the relevant events
const aftershock = reportGroup.abilities
.find(x => [369650, 369651].includes(x.id));
const damageEvents = reportGroup.fights
.flatMap(fight => fight
.eventsByCategoryAndDisposition('damage', 'enemy')
.filter(event => event.ability.id === aftershock?.id)
);
const totalHits = damageEvents.length;
return totalHits;
}
And will render something like:
3. Construct our Total Hits message
Rather than just returning a number, we can add some narrative to our component to hint to the user how to improve. For this particular mechanic, the Aftershock effect areas are spawned and indicated when the boss casts Earthbreaker Missiles, so we want to say something like:
"Failed to move out of Aftershock left behind by Earthbreaker Missiles X time(s)."
Part 1: "Failed to move out of Aftershock"
We can use the AbilityIcon
Enhanced Markdown component to render the Aftershock ability with the correct icon, coloring, and (importantly) tooltip. At the very bottom of our script, below our getComponent
definition, let's add a helper function for converting an ability
to Enhanced Markdown:
function convertAbilityToMarkdown(ability) {
return `<AbilityIcon id="${ability.id}" icon="${ability.icon}" type="${ability.type}">${ability.name}</AbilityIcon>`;
}
We can use this to start constructing our message:
const message = `Failed to move out of ${convertAbilityToMarkdown(aftershock)}`;
Part 2: "Left behind by Earthbreaker Missiles"
For the second part of our message, we need to get the ability
for Earthbreaker Missiles so we can also show that with a nice tooltip. Similarly to Aftershock, Earthbreaker Missiles has 2 possible ability IDs:
const earthbreakerMissiles = reportGroup.abilities
.find(x => [369648, 361676].includes(x.id));
Now we can re-use our convertAbilityToMarkdown
to add the next part of our message:
const message = `Failed to move out of ${convertAbilityToMarkdown(aftershock)} left behind by ${convertAbilityToMarkdown(earthbreakerMissiles)}`;
Part 3: "X Time(s)"
Firstly, we need to work out whether to write time
or times
. We can work out the plural fairly simply:
const plural = totalHits === 1 ? '' : 's';
We can see that Enhanced Markdown lets us use various text styles. Typically in our UI, we use the Kill style to indicate good things, and the Wipe style to indicate bad things. As the total number of hits is a bad thing, let's style it with Wipe:
const message = `Failed to move out of ${convertAbilityToMarkdown(aftershock)} left behind by ${convertAbilityToMarkdown(earthbreakerMissiles)} <Wipe>${totalHits}</Wipe> time${plural}.`
You might notice a stray
in that final message. Occasionally, there's a glitch with Enhanced Markdown where it collapses the white space in between components. You can usually force the white space back by using
instead.
What About When No One Is Hit?
It might be nice to show a "Success!" message if the mechanic wasn't failed at all. We can do this by showing a different message if totalHits === 0
:
const message = totalHits === 0
? `<Kill><Icon type="check"></Kill> Successfully dodged <AbilityIcon id="369650" icon="spell_nature_earthquake.jpg" type="Physical">Aftershock</AbilityIcon> every time!`
: `Failed to move out of ${convertAbilityToMarkdown(aftershock)} left behind by ${convertAbilityToMarkdown(earthbreakerMissiles)} <Wipe>${totalHits}</Wipe> time${plural}.`;
Note that:
- We've used the
Icon
Enhanced Markdown component to show a checkmark at the start of our message, and we've colored it Kill green. - We have hand-written the
AbilityIcon
Enhanced Markdown for Aftershock. This is because if no one is hit, the ability may not have been logged at all, so we cannot rely on ouraftershock
variable not beingundefined
.
Putting It All Together
Finally, we can return our message as an EnhancedMarkdown
component with:
return {
component: 'EnhancedMarkdown',
props: {
content: message
}
}
Which gives us a full script of:
getComponent = () => {
// Guard against incorrect use
const halondrusEncounterId = 2529;
const isHalondrus = reportGroup.fights
.every(fight => fight.encounterId === halondrusEncounterId);
if (!isHalondrus) {
return {
component: 'EnhancedMarkdown',
props: {
content: `This component only works for <EncounterIcon id="${halondrusEncounterId}">Halondrus</EncounterIcon>.`
}
}
}
// Filter to the relevant events
const aftershock = reportGroup.abilities
.find(x => [369650, 369651].includes(x.id));
const damageEvents = reportGroup.fights
.flatMap(fight => fight
.eventsByCategoryAndDisposition('damage', 'enemy')
.filter(event => event.ability.id === aftershock?.id)
);
const totalHits = damageEvents.length;
// Construct our Total Hits message
const plural = totalHits === 1 ? '' : 's';
const earthbreakerMissiles = reportGroup.abilities
.find(x => [369648, 361676].includes(x.id));
const message = totalHits === 0
? `<Kill><Icon type="check"></Kill> Successfully dodged <AbilityIcon id="369650" icon="spell_nature_earthquake.jpg" type="Physical">Aftershock</AbilityIcon> every time!`
: `Failed to move out of ${convertAbilityToMarkdown(aftershock)} left behind by ${convertAbilityToMarkdown(earthbreakerMissiles)} <Wipe>${totalHits}</Wipe> time${plural}.`;
return {
component: 'EnhancedMarkdown',
props: {
content: message
}
}
}
function convertAbilityToMarkdown(ability) {
return `<AbilityIcon id="${ability.id}" icon="${ability.icon}" type="${ability.type}">${ability.name}</AbilityIcon>`;
}
Testing it, we should see this message when no Aftershock damage events are found:
And this when they are:
4. Build the data for the description
It might also be nice to show a succinct list of who was hit and how many times. Let's aim to show something like this:
"Player A (X), Player B (Y), Player C (Z)"
Where X, Y, and Z are the number of times each player was hit.
Before we build our message, let's construct the data to represent it. We want to build an array, where each element is an object with a name
property and a hits
property. We already have an array of damage events, and the reduce
array method will help us collapse that into an array of players and hits:
const playersAndHits = damageEvents
.reduce((playersAndHits, event) => {
const playerAndHits = playersAndHits.find(x => x.id === event.target.id);
if (playerAndHits) {
playerAndHits.hits++;
} else {
playersAndHits.push({
id: event.target.id,
name: `<${event.target.subType}>${event.target.name}</${event.target.subType}>`,
hits: 1
});
}
return playersAndHits;
}, [])
.sort((x, y) => y.hits - x.hits);
reduce
let's us pass a function that will be called for every damage event. The arguments for the function are an accumulator (which we've namedplayersAndHits
) and the current damage event (which we've namedevent
).The second parameter to
reduce
gives our accumulator its initial value. In our case[]
: an empty array.For each damage event, we check if we already have an element in our array that matches the target of the event:
const playerAndHits = playersAndHits.find(x => x.id === event.target.id);
If we find an existing element, then we increment the
hits
property by 1:if (playerAndHits) { playerAndHits.hits++; }
Otherwise, we add a new element to our array with starting values:
playersAndHits.push({ id: event.target.id, name: `<${event.target.subType}>${event.target.name}</${event.target.subType}>`, hits: 1 });
We set
id
to theid
of the damage event'starget
(thetarget
for damage events is the actor that takes damage, which in our case is the player). This is because we use this at the start of each loop to check if we already have an element for the player or not.For the name, we set it to an Enhanced Markdown string of the target's name styled with the target's
subType
. For player's, thesubType
will be the name of the player's class, which when used like this will cause the player's name to be colored using their class color, a common stylistic choice in our UI.Finally, we set the initial
hits
count to 1, which will be increased on each loop if another damage event is found for thistarget
.It's important to return the
playersAndHits
accumulator at the end of each loop, as this is what gets passed into the next loop. The return value of thereduce
function is the final returned accumulator.Finally, we chain
.sort
after our.reduce
, to then sort the returnedplayersAndHits
array by the number of hits, descending.
If you are new to programming and the reduce
method seems confusing, try writing an equivalent script using a for loop.
To get an idea of the data structure we've built, we can temporarily call:
return playersAndHits;
to inspect it as a JsonTree
.
5. Construct our description
Now that we've built our data, constructing our description is actually quite easy:
const playersAndHitsDescription = playersAndHits
.map(playerAndHit => `${playerAndHit.name} (${playerAndHit.hits})`)
.join(', ');
To show our total hits message and our description as separate paragraphs, we need to separate them with new lines. One way to do this that lets us add new paragraphs easily is:
const content = [
message,
playersAndHitsDescription
].join('\n\n');
6. Add a title
Headings in Enhanced Markdown work like regular markdown headings, and are created using the #
symbol. Adding a title to our content at this point is really easy:
const content = [
'# Aftershock',
message,
playersAndHitsDescription
].join('\n\n');
7. Put it all together
Putting everything together, we get a full script of:
getComponent = () => {
// Guard against incorrect use
const halondrusEncounterId = 2529;
const isHalondrus = reportGroup.fights
.every(fight => fight.encounterId === halondrusEncounterId);
if (!isHalondrus) {
return {
component: 'EnhancedMarkdown',
props: {
content: `This component only works for <EncounterIcon id="${halondrusEncounterId}">Halondrus</EncounterIcon>.`
}
}
}
// Filter to the relevant events
const aftershock = reportGroup.abilities
.find(x => [369650, 369651].includes(x.id));
const damageEvents = reportGroup.fights
.flatMap(fight => fight
.eventsByCategoryAndDisposition('damage', 'enemy')
.filter(event => event.ability.id === aftershock?.id)
);
const totalHits = damageEvents.length;
// Construct our Total Hits message
const plural = totalHits === 1 ? '' : 's';
const earthbreakerMissiles = reportGroup.abilities
.find(x => [369648, 361676].includes(x.id));
const message = totalHits === 0
? `<Kill><Icon type="check"></Kill> Successfully dodged <AbilityIcon id="369650" icon="spell_nature_earthquake.jpg" type="Physical">Aftershock</AbilityIcon> every time!`
: `Failed to move out of ${convertAbilityToMarkdown(aftershock)} left behind by ${convertAbilityToMarkdown(earthbreakerMissiles)} <Wipe>${totalHits}</Wipe> time${plural}.`;
// Build the data for the description
const playersAndHits = damageEvents
.reduce((playersAndHits, event) => {
const playerAndHits = playersAndHits
.find(x => x.id === event.target.id);
if (playerAndHits) {
playerAndHits.hits++;
} else {
playersAndHits.push({
id: event.target.id,
name: `<${event.target.subType}>${event.target.name}</${event.target.subType}>`,
hits: 1
});
}
return playersAndHits;
}, [])
.sort((x, y) => y.hits - x.hits);
// Construct our description
const playersAndHitsDescription = playersAndHits
.map(playerAndHit => `${playerAndHit.name} (${playerAndHit.hits})`)
.join(', ');
// Add a title
const content = [
'# Aftershock',
message,
playersAndHitsDescription
].join('\n\n');
return {
component: 'EnhancedMarkdown',
props: {
content
}
}
}
function convertAbilityToMarkdown(ability) {
return `<AbilityIcon id="${ability.id}" icon="${ability.icon}" type="${ability.type}">${ability.name}</AbilityIcon>`;
}
Which when run, shows us our final result:
Closing Thoughts
Building EnhancedMarkdown
Report Components lets us render rich text (including tooltips) as part of our analysis. This can be useful when trying to express something succinctly, or when trying to also give the user hints about how they can improve.
Learning how to use Enhanced Markdown properly is also important for the next component we're going to look at: Table
. This is because every cell in a Table
Report Component can include Enhanced Markdown.