Skip to content

Conversation

@Sharrq
Copy link
Contributor

@Sharrq Sharrq commented Nov 2, 2025

Additional new guide components as part of the larger rework that i have been working on. Alongside this PR, I also split some of the previous components to create some more generalized base components that people can reuse for other purposes if they decide to use them for other purposes or use cases.

Cast Efficiency - I differentiated the layout depending on whether the spell has charges or not. If it does not have charges it will show colored segments for when the ability is on cooldown with white pins for the casts. Gaps are shown as red highlights in the background. For the charged spells, the bullet graph main bar shows the total casts with the target line for the number that was possible. The bar beneath it shows the amount of time that they were capped on charges. I separated SegmentedTimeline and BulletGraph from this to be reused elsewhere. This also shows the difference between the standard layout and the compact layout that consolidates things into a single row.
Screenshot from 2025-11-01 19 59 46
Screenshot from 2025-11-01 20 15 14

Intensity Graph - This separates the fight into blocks of equal time depending on the length of the fight and the number of blocks. In this example, we are taking the ignite damage ticks over the fight and creating a heatmap of where the largest spikes of ignite was. I separated Heatmap from this to be used for other use cases.
Screenshot from 2025-11-01 20 06 55

Intensity Bar - Similar to Intensity Graph, this shows the amount of time ignite was above particular thresholds. StackedBarChart was separated from this to be used elsewhere.
Screenshot from 2025-11-01 20 07 06

Damage Contribution - This shows the amount of damage that was contributed to some time period. In this case any damage done while Combustion was active. DonutChart was separated from this to be used elsewhere.
Screenshot from 2025-11-01 20 12 05

Also in this PR I created GuideDataWrapper to consolidate the overall style elements that i have been including with every guide component i have been creating, this just helps to keep it consistent across them. I also moved the relevant Divs from GuideDivs to this since the wrapper can be used wherever in place of using all those style divs repeatedly.

I purposely didnt overwrite CastEfficiencyBar because i didnt want to replace all uses of it in this PR, so this does duplicate that component with some changes. Ill do that replacement in a separate PR assuming people like it. Along with this rework i removed some options like the gap highlight mode and just had it show the gaps regardless.

@emallson
Copy link
Collaborator

emallson commented Nov 5, 2025

i haven't forgotten about this PR, its just going to take a bit to get through. rather than forcing you to go back and split it up, i'm going to basically split it up in review, focusing on one or two components at a time to make it manageable. so if you see a review come in and it only covers a couple things, don't be surprised.

Code Review Status

  • Bullet Graph
  • Cast Efficiency Ribbon/Panel
  • DamageContribution
  • Donut Chart
  • Heatmap
  • Intensity Bar / Chart
  • Segmented Timeline
  • Stacked Bar

Non-Code Notes

Besides code stuff, I have a few notes about the design & composition.

Colors

Up front: colors are fucking hard. I don't have a silver bullet for this, but what I have found works well is using one highly-saturated color to highlight the thing you want, and then low-saturation colors for non-focus, informative elements.

An examples of what I'd say are not great choices:

image

Both colors here are low-saturation and low-brightness, which means the whole thing is kind of visually indistinct. One of the colors should be high-saturation / high-brightness so that it stands out. The current state makes the pins the element of focus (because they are bright, though they lack saturation), which is probably not correct.

For an element like this, my preference would be to make the downtime segment the high-vis color so that the eye is drawn to the problem points (this is what is done in the downtime timeline, seen below)
image

The other way to do it is done for the uptime segment to be bright, as done here:
image

I have my preference, but I'm open to persuasion that we should highlight uptime rather than downtime here.

Another element that I think does this quite well is this chart:
image

The high-DPS points are highly saturated, while the low dps points have low saturation. The whole thing has somewhat inconsistent brightness (low dps values have high brightness, high dps values have low brightness), but that is really annoyingly time-consuming to fix because simply adjusting the S value in HSL is not actually going to produce good-looking results. Overall, it looks quite good so having an inverse relation between saturation and brightness might be good tech for later stuff.

All of that said: I don't think we need to let people choose colors for this. Or if we do, it should be from a limited palette (magic schools don't work great as illustrated by the mage specs: everything is fire, or everything is frost, or everything is arcane). Individually, choosing colors can look appealing. However, across many repeated elements it becomes visually chaotic. Compare these:

current:
image

Arcane (rescaled using hsl(from ${ARCANE} h 15% 20%))
image

Frost (rescaled using hsl(from ${FROST} h 15% 20%))
image

These colors are not ideal (the Frost and Arcane colors are kinda defined by being bright & saturated) but IMO the gaps are much clearer. I think letting a spec page have a "theme color" to use (so spec pages look like spec pages) is okay, but varying it per-element is probably not what we want.

As an aside: I would like to make our color usage more consistent and readable, and this nudge to not introduce even more new, different colors is a part of that :)

Role of Charts & Diagrams

I think it is helpful to look at a chart / diagram and ask "why shouldn't this be a table?". Nott literally an HTML <table>, but a structured list of numbers like this:
image

For every one of these diagrams: why should we use it instead of a table? There are two common reasons:

  1. There is too much data to put in a table. Timelines are a great example of this. They summarize thousands of data points in a compact form. Reading a table with thousands of rows isn't very useful.
  2. They visualize the relation between values in the table (including in ways that are helpful for people that aren't numerically inclined)

Both of these do the latter:
image
image

Fundamentally, they're actually the same thing in a different form: they help visualize the ratio between each pair of numbers in the table.

Now, these:
image

Written as a table:

Spell Efficiency Casts Max Casts Capped Time
Phoenix Flames 99% 51 52 3s
Fire Blast 99% 138 140 6s

The table is smaller than the charts, so the reason for the diagram can't be "too much data", it has to be comparison.

What do you want to compare? Usually either:

  • Casts to Max Casts (aka efficiency / downtime) within the same row (aka the same spell), or
  • Efficiency across rows (aka different spells)

I'd argue that the structure of these diagrams actually encourages comparing casts / max casts with capped time, which is wrong! This is due to these bars being placed next to each other, while simultaneously being visually separated from other cast efficiency diagrams.

IMO the tabular form with embedded bars is both more space efficient and clearer.
image

It doesn't have the same eye-catching "what is this", but it is more useful. Making things look good is worthwhile, but it shouldn't be at the cost of (much) functionality.

There's a balance here---once of the big bits of feedback we got from a very loud minority after subcreation migrated to Archon was that Archon was too space-inefficient. People couldn't fit nearly as much on one screen. But that spacing had a purpose: it made Archon look good! We made some compromises (eliminating some padding that cost more than it was worth), but ultimately it is a judgment call. my 2c is that the BulletGraph component doesn't pass the bar yet.

Other Notes

Intensity Chart

I think this turned out really well. I have some concerns about comparability across different reports, but within the same report it is awesome. My one nit is that when viewing per target (or any multiple) that these to be laid out in tabular form instead of alternating labels and diagrams:

Here is what the per-target currently looks like
image

Here is some quick css hackery to make them more heatmap-style:

image

(yes, they're no longer square. Not sure they need to be square, but also with less-hacky changes (e.g. reducing the bin count slightly) they could be made square)

Intensity Bar

It feels weird that the data is basically repeated twice. Once in the "table" data-strip at the top, and once in the diagram itself:
image

Segmented Timeline

I like how the pins panned out. They may interfere with zero-padding stacking of these, but having a "start of bar" marker helps solve the problem of "perfectly back-to-back cooldowns looks like one long cooldown"

@Sharrq
Copy link
Contributor Author

Sharrq commented Nov 6, 2025

Looking through the feedback. I am probably going to make some changes here

  1. I agree that having the labels and boxes side by side instead of alternating on the intensity graph is better, ill change it
  2. Ill eliminate the redundant information on intensitybar
  3. I do like having the bullet graph for the charged spells, but i agree about the minor bar conflicting, so ill probably move the capped time to a stat box alongside the cast efficiency percent and then just have the one bar/target line.
  4. I see the point on the color saturation. I tried a few options originally including one that was a patterned background instead of a solid color (like a gray bar with red diagonals across it), but didnt really like it so i went back to the solid background. Ill mess with the colors and make them uniform throughout.
  5. As for tables, I would argue that tables are often boring and require filtering through a bunch of text to find the info you are looking for (especially when they have a lot of data). At a glance, for something like cast efficiency for charged spells, I can easily see "bar has a lot of empty space and thats bad". Drawing attention to the good/bad first and the numbers secondarily.

Copy link
Collaborator

@emallson emallson left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i am not the biggest fan of donut charts---they are very big, and aside from looking nice they don't do much. comparing ratios is easier with a bar chart (or a stacked bar chart, like you have in this PR). I'd pitch deleting the Donut Chart entirely, personally, but not going to force that issue and am fine including it.

However, the DamageContribution component doesn't really have a place (IMO). it doesn't do enough as a helper to justify the extra space. The useful bits of it (mostly the legend) should be moved to the base component.

* @param showCenterText - Whether to show center text with total (default: true)
* @param centerText - Custom center text override
*/
export default function DonutChart({
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what is the purpose of having a new DonutChart (instead of using parser/ui/DonutChart)? is it primarily the new aesthetic parts (e.g. hover animation?)

(it does look nicer than the old one)

* @param helperText - Optional helper text to display below the header
* @param stats - Optional stat cards to display in the header
*/
export default function DamageContribution({
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i don't believe that this component is at the correct level of abstraction. that is making it very narrow & specific.

this component merges bits of analysis (injected via calculateContribution) with display (Legend layout with DonutChart).

what i'd suggest is:

  • move the Legend stuff to DonutChart (can be optional with a boolean prop)
  • maybe add a total prop to DonutChart to make it easy to add the "other" segment (not convinced we need this, since ultimately it is just total - sum(value))
  • remove this component

based on your use of this component for Combustion, I don't think that it makes sense to have a separate component for DamageContribution. this component has almost no actual logic, and primarily just calls the user-supplied calculateContribution for every element. but: the hard part of using this is not calling calculateContribution, but writing it---and this doesn't help with that.

Comment on lines +76 to +77
// Add "Other" to contributions if it exists
const allContributions: Array<SpellContribution & { isOther?: boolean }> = [...contributions];
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this copy is not needed, contributions is never used again.

Comment on lines +70 to +71
// Calculate total from all damage (including unspecified)
const overallTotal = calculateContribution(-1); // -1 signals to get total of all
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i mentioned eliminating calculateContribution above but if it stays, it shouldn't have this -1 behavior. the user can just pass in the overallTotal prop. they would already to have to write a separate branch to handle the -1 case and calculate it, so it isn't saving any work to just pass the value in.

Comment on lines +263 to +273
spells={[
{ spell: TALENTS.PYROBLAST_TALENT, color: '#ff6600' },
{ spell: SPELLS.FIRE_BLAST, color: '#ff9933' },
{ spell: SPELLS.PHOENIX_FLAMES_DAMAGE, color: '#ffcc00' },
{ spell: SPELLS.IGNITE, color: '#910808ff' },
{ spell: SPELLS.FLAMESTRIKE, color: '#cc3300' },
{ spell: SPELLS.ARCANE_PHOENIX_DAMAGE, color: '#7b1d92ff' },
]}
calculateContribution={(spellId: number) =>
this.combustionDamageTracker.getDamageForSpell(spellId)
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so as an example of the issue with calculateContribution: this could be replaced by:

  1. calling [ ... ].map(entry => ({ ...entry, value: getDamageForSpell(entry.spell) }))
  2. passing totalValue={combustionDamageTracker.totalDamage}

with virtually no additional work on the consumer side, but without the added logic & complexity in the DamageContribution component.

@ToppleTheNun
Copy link
Contributor

I haven't gone through this full thing yet but I wanted to echo the donut chart feedback.

@Sharrq
Copy link
Contributor Author

Sharrq commented Nov 6, 2025

I didn't notice that there was a donut chart already, my mistake. I'll take a look at it and see if it's worth replacing it with mine.

@emallson
Copy link
Collaborator

emallson commented Nov 6, 2025 via email

@Sharrq
Copy link
Contributor Author

Sharrq commented Nov 7, 2025

Ok. I havent messed with the Donut Chart yet. But made some other adjustments.

I reskinned the colors for things and picked out some muted spec colors and went that direction
Screenshot_2025-11-06_18-50-21

I also carried those colors into IntensityChart and IntensityBar
Screenshot_2025-11-06_18-49-20

I cleaned up IntensityBar a little bit and added a legend to it instead of the stat boxes because i didnt like that the damage thresholds were buried in the tooltip
Screenshot_2025-11-06_18-49-35

And I reworked the CastEfficiencyRibbon to not show a secondary bar on the bullet graph and to move the capped time into a stat box
Screenshot_2025-11-06_18-48-55

Also, I did not change the per target section of IntensityChart largely because It tends to make the boxes on the heatmap incredibly small, especially if the targets have long names or if it is constrained to a smaller container like if it is used alongside one of the explanations. I could have reduced the number of blocks to compensate I suppose, but it started to get really annoying to work with ... like the text of the names needed to be large enough, but the bigger you make the text the less room the heatmap boxes have .... so it seemed better to keep it the way it is.

Copy link
Collaborator

@emallson emallson left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BulletGraph

image

The cast count bars here are CastEfficiencyRibbons, which in turn render BulletGraphs. I think bullet graphs are an interesting potential visualization. We do a lot of "show point value X, vs (often hidden) target values A, B, C...", which bullet graphs cover.

However, I think this implementation needs work---both on the code side and the design side.

There are two distinguishing features for Bullet Graphs:

  • The use of the background to communicate a (not necessarily linear) scale, like our Bad / Ok / Good values. (implemented as PerformanceZone)
  • The presence of a target "tick" showing the goal, which the progress bar may exceed. (implemented as TargetMarker)

The implementation as it is in this PR does not have functional PerformanceZones, but this is easy to fix so it isn't a big deal. However, after fixing them they aren't really...visible?

The colors used in CastEfficiencyRibbon are very muted (opacity of ~15% against a black-adjacent background), and the bar itself completely covers the backgrounds. This leads to a situation where if you can see the backgrounds, it looks more like a StackedBar.

A little bit more CSS allows insetting the bar, but tbh I wasn't able to find a combination of colors that I think worked well. That isn't to say we can't, but, well, colors are hard and using the bright primary red/yellow/green doesn't make it easier:
image

image image

My 2c would be to cut BulletGraph from this PR. It wasn't really being used in the elements you presented (those were really just bars with a max-value line---which is still useful, but also much simpler and could be less code). I think they might be interesting to investigate further, but need some focused work to make good (and maybe some changes to our core color palette 🙃 )

if you are interested, here are the css changes I made for the first "fixed" screenshot:

diff --git a/src/interface/guide/components/BulletGraph.tsx b/src/interface/guide/components/BulletGraph.tsx
index 962ac69e28..04c0872ec2 100644
--- a/src/interface/guide/components/BulletGraph.tsx
+++ b/src/interface/guide/components/BulletGraph.tsx
@@ -104,35 +104,40 @@
 `;
 
 const GraphBackground = styled.div`
+  position: absolute;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+
   width: 100%;
-  height: 32px;
   background: rgba(0, 0, 0, 0.4);
   border-radius: 4px;
   overflow: hidden;
   display: flex;
-  position: relative;
   box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.4);
-  border: 1px solid rgba(255, 255, 255, 0.05);
+  z-index: 0;
+  box-sizing: content-box;
 `;
 
 const PerformanceZone = styled.div<{ width: number; color: string }>`
+  background-color: ${(props) => props.color};
   width: ${(props) => props.width}%;
   height: 100%;
   position: relative;
 `;
 
 const MainBar = styled.div<{ width: number; color: string }>`
-  position: absolute;
-  left: 0;
-  top: 0;
   width: ${(props) => props.width}%;
-  height: 32px;
   background: ${(props) => props.color};
   display: flex;
   align-items: center;
   padding-left: 8px;
   transition: width 0.3s ease;
-  border-radius: 4px 0 0 4px;
+  margin-top: 1rem;
+  margin-bottom: 1rem;
+  z-index: 1;
+  box-shadow: 0 0 3px black;
 `;
 
 const BarLabel = styled.span`
@@ -149,7 +154,7 @@
   position: absolute;
   left: 100%;
   top: 0;
-  height: 32px;
+  bottom: 0;
   display: flex;
   align-items: center;
   transform: translateX(-50%);


const GraphBackground = styled.div`
width: 100%;
height: 32px;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

avoid using fixed heights for things like this. it means that the resizing the parent element doesn't actually re-size the contents and results in visual breakage.

Comment on lines +125 to +127
position: absolute;
left: 0;
top: 0;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

conventionally, the background would be absolute and the content would be laid out. that isn't always the case because sometimes you need to do it the other way, but in this case I think it makes the most sense.

of note: using position: absolute like this prevents (or at least makes difficult) doing the center-alignment for a bullet graph like shown on wikipedia:
image

(you can center in absolute with some calc magic but it is harder than display: flex; align-items: center;)

Comment on lines +118 to +122
const PerformanceZone = styled.div<{ width: number; color: string }>`
width: ${(props) => props.width}%;
height: 100%;
position: relative;
`;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this doesn't set the background-color attribute using color, so the PerformanceZones don't actually work.

Suggested change
const PerformanceZone = styled.div<{ width: number; color: string }>`
width: ${(props) => props.width}%;
height: 100%;
position: relative;
`;
const PerformanceZone = styled.div<{ width: number; color: string }>`
width: ${(props) => props.width}%;
background-color: ${(props) => props.color};
height: 100%;
position: relative;
`;

Copy link
Collaborator

@emallson emallson left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CastEfficiencyRibbon / CastEfficiencyPanel / SegmentedTimeline

aside from the issues i raised with the BulletGraph last time (which would simplify the charges version of the CastEfficiencyRibbon component, these look good

// Helper functions to convert combat log events to timeline segments/markers
function createCooldownSegments(
spellId: number,
events: ReturnType<typeof useEvents>,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: this is clearer as AnyEvent[]

Suggested change
events: ReturnType<typeof useEvents>,
events: AnyEvent[],

export default function SegmentedTimeline({
windows,
segments,
markers = EMPTY_MARKERS,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: this can just be []

Suggested change
markers = EMPTY_MARKERS,
markers = [],

Comment on lines +33 to +34
/** Optional windows to show on the timeline (defaults to full fight if not specified) */
windows?: TimeWindow[];
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

multiple windows makes this component a lot more complex to get right. however, 90% of that complexity comes from the possibility of overlapping windows.

consider adding a dev-mode error check like:

if (import.meta.env.DEV) {
  windows.sort((a, b) => a.startTime - b.startTime);
  windows.forEach((window, index) => {
    const nextWindow = windows[index + 1];
    if (nextWindow && nextWindow.startTime < window.endTime) {
      throw new Error("must not provide overlapping windows to SegmentedTimeline");
    }
  });
}

alternately, we could just document that in the docblock and if people do it it will look broken :)

return wastedTime;
};

const wastedTime = calculateWastedTime();
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this needs to be memoized with useMemo or every re-render is going to reprocess all events for every CastEfficiencyRibbon rendered.

Suggested change
const wastedTime = calculateWastedTime();
const wastedTime = useMemo(calculateWastedTime(), [eventHistory, hasCharges, maxCharges]);

the function is pretty complicated, so you might want to move it out of the component and call it with proper parameters instead like:

Suggested change
const wastedTime = calculateWastedTime();
const wastedTime = useMemo(() => calculateWastedTime(eventHistory, ability), [ability, eventHistory]);

return { h: 35, s: 90, l: 55 };
}

function generateGradient(baseColor: string): string[] {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not getting through a full heatmap review right now, but you can replace the color parsing and gradient construction code with css relative colors like:

function generateGradient(baseColor: string): string[] {
  return [
    // Lightest - tier 1
    `hsl(from ${baseColor} h calc(s - 10) calc(l + 20))`,
    // Lighter - tier 2
    `hsl(from ${baseColor} h calc(s - 5) calc(l + 10))`,
    // Base - tier 3
    baseColor,
    // Darker - tier 4
    `hsl(from ${baseColor} calc(h - 10) s calc(l - 10))`,
    // Darkest - tier 5
    `hsl(from ${baseColor} calc(h - 20) s calc(l - 20))`,
  ];
}

this will allow using colors that aren't written in hsl format (the browser will convert automatically)

samples.push(dps);
}
}

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is very very quadratic complexity. there are some other places with quadratic complexity in this pr but they mostly involve small numbers so it is fine. this is <target count> * <event count> * <fight duration in seconds>.

unfortunately we don't have good primitives for making slicing like this easy, and it needs to be manually unrolled into a loop. ideally it'd be single-pass, but the caller is providing data broken out by target already so it can be a single pass per target like:

for (const target of targets) {
  const buckets = new Map();
  for (const event of target.events) {
    const index = Math.floor((event.timestamp - fightStart) / sampleInterval);
    const currentValue = buckets.get(index) ?? 0;
    buckets.set(index, currentValue + event.amount);
  }

  samples.push(...buckets.values());
}

}

// Calculate median for threshold centering
const sortedSamples = [...samples].sort((a, b) => a - b);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

don't copy samples here

Suggested change
const sortedSamples = [...samples].sort((a, b) => a - b);
const sortedSamples = samples.sort((a, b) => a - b);

Copy link
Collaborator

@emallson emallson left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sorry for taking so long, final round of comments for the initial PR contents. Heatmap/Intensity bar stuff looks good visually, but has some duplication issues internally. these things are pretty tied together, so it might make sense for them to be in the same file and sharing the same internals.

Alternately, make the stuff that is duplicated in IntensityChart exported from Heatmap / IntensityBar so avoid duplication.

width: ${({ $size }) => $size}px;
height: ${({ $size }) => $size}px;
flex-shrink: 0;
background-color: ${({ $color }) => $color};
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

using $prop like this doesn't seem to be officially supported but I don't see the junk props on elements in the inspector so...i guess its fine?

Comment on lines +84 to +88
function roundThreshold(value: number): number {
if (value > 100000) return Math.round(value / 10000) * 10000;
if (value > 50000) return Math.round(value / 5000) * 5000;
return Math.round(value / 1000) * 1000;
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i guess its intended that each of these is bucketed into values 1/10 of the threshold? (so 100k gets 10k, 50k gets 5k)?

Comment on lines +182 to +189
// Create 5 intensity tiers centered around median
const thresholds = [
{ min: 0, max: Math.max(0, medianRounded - step), label: 'Very Low' },
{ min: Math.max(0, medianRounded - step), max: medianRounded, label: 'Low' },
{ min: medianRounded, max: medianRounded + step, label: 'Medium' },
{ min: medianRounded + step, max: medianRounded + step * 2, label: 'High' },
{ min: medianRounded + step * 2, max: Infinity, label: 'Very High' },
];
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the way these are auto-scaled is reasonable as an informative element. however, these aren't comparable across fights. they don't necessarily need to be, but conventionally our stuff is.

there isn't really a way to make it comparable across reports without picking a somewhat-arbitrary center point for the scale, unfortunately. so this is probably okay, but we'll see what people's feedback is.

Comment on lines +25 to +91
function parseColor(color: string): HSL {
// Handle HSL format
const hslMatch = color.match(/hsl\((\d+),\s*(\d+)%?,\s*(\d+)%?\)/);
if (hslMatch) {
return {
h: parseInt(hslMatch[1]),
s: parseInt(hslMatch[2]),
l: parseInt(hslMatch[3]),
};
}

// Handle hex format
const hexMatch = color.match(/^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i);
if (hexMatch) {
const r = parseInt(hexMatch[1], 16) / 255;
const g = parseInt(hexMatch[2], 16) / 255;
const b = parseInt(hexMatch[3], 16) / 255;

const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const l = (max + min) / 2;

if (max === min) {
return { h: 0, s: 0, l: Math.round(l * 100) };
}

const d = max - min;
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);

let h = 0;
if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
else if (max === g) h = ((b - r) / d + 2) / 6;
else h = ((r - g) / d + 4) / 6;

return {
h: Math.round(h * 360),
s: Math.round(s * 100),
l: Math.round(l * 100),
};
}

// Default fallback to fire orange
return { h: 35, s: 90, l: 55 };
}

function generateGradient(baseColor: string): string[] {
const { h, s, l } = parseColor(baseColor);

return [
// Lightest - tier 1
`hsl(${h}, ${Math.max(0, s - 10)}%, ${Math.min(100, l + 20)}%)`,
// Lighter - tier 2
`hsl(${h}, ${Math.max(0, s - 5)}%, ${Math.min(100, l + 10)}%)`,
// Base - tier 3
`hsl(${h}, ${s}%, ${l}%)`,
// Darker - tier 4
`hsl(${Math.max(0, h - 10)}, ${s}%, ${Math.max(0, l - 10)}%)`,
// Darkest - tier 5
`hsl(${Math.max(0, h - 20)}, ${s}%, ${Math.max(0, l - 20)}%)`,
];
}

function roundThreshold(value: number): number {
if (value > 100000) return Math.round(value / 10000) * 10000;
if (value > 50000) return Math.round(value / 5000) * 5000;
return Math.round(value / 1000) * 1000;
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is like, entirely duplicated from Heatmap with minor (incompatible!) changes. it should not be.

// Calculate stats
const allBlocks = heatmapData.flatMap((t) => t.blocks);
const nonZero = allBlocks.filter((v) => v > 0);
const sorted = [...nonZero].sort((a, b) => a - b);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

another needless copy.

Comment on lines +183 to +198
? (() => {
// Group targets by name
const grouped = data.reduce(
(acc, target) => {
if (!acc[target.targetName]) {
acc[target.targetName] = [];
}
acc[target.targetName].push(target);
return acc;
},
{} as Record<string, TargetData[]>,
);

// Build data for each unique target name
return Object.entries(grouped).map(([name, targets]) => buildTargetData(targets, name));
})()
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

use an if-else instead of a ternary + IIFE

Comment on lines +214 to +223
const median = sorted[Math.floor(sorted.length / 2)] || maxValue / 2;
const medianRounded = roundThreshold(median);
const step = roundThreshold(median * 0.4);
const thresholds = [
0,
medianRounded - step,
medianRounded,
medianRounded + step,
medianRounded + step * 2,
];
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this logic is also duplicated from IntensityBar

Comment on lines +77 to +83
<>
<strong>{segment.label}</strong>
<br />
Value: {segment.value.toFixed(0)}
<br />
Percentage: {percent.toFixed(1)}%
</>
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

use <div> not <br/>

Comment on lines +33 to +36
/** Custom label formatter. Receives segment and percentage, returns label content */
labelFormat?: (segment: StackedBarSegment, percent: number) => React.ReactNode;
/** Custom tooltip formatter. Receives segment and percentage, returns tooltip content */
tooltipFormat?: (segment: StackedBarSegment, percent: number) => React.ReactNode;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pick one method to do labels / tooltips. either they should be supplied on Segments (like you're doing currently), or they should be format props. Not both.

my 2c is to remove these and keep the segment props.

@Sharrq Sharrq marked this pull request as draft November 22, 2025 19:09
@Sharrq
Copy link
Contributor Author

Sharrq commented Nov 22, 2025

Moved this back to draft for now. Going to work through the feedback, but wont be able to test those changes until i get the mage specs re-enabled in Midnight which i dont want to do in this PR.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants