Decorative background for title

Report Components: Enhanced Markdown

Last updated: November 17, 2022

Enhanced Markdown is a powerful syntax for building rich text, initially created at FF 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 FF 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)}&nbsp;<Wipe>${totalHits}</Wipe> time${plural}.`

You might notice a stray &nbsp; 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 &nbsp; 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)}&nbsp;<Wipe>${totalHits}</Wipe> time${plural}.`;

Note that:

  1. 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.
  2. 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 our aftershock variable not being undefined.

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)}&nbsp;<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);
  1. 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 named playersAndHits) and the current damage event (which we've named event).

  2. The second parameter to reduce gives our accumulator its initial value. In our case []: an empty array.

  3. 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);
  4. If we find an existing element, then we increment the hits property by 1:

    if (playerAndHits) {
      playerAndHits.hits++;
    }
  5. 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 the id of the damage event's target (the target 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, the subType 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 this target.

  6. 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 the reduce function is the final returned accumulator.

  7. Finally, we chain .sort after our .reduce, to then sort the returned playersAndHits 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)}&nbsp;<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.