All files / src SunburstWidget.tsx

98% Statements 49/50
73.68% Branches 42/57
100% Functions 9/9
100% Lines 42/42

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169                                                      3x 19x 19x     19x 19x             19x 19x   19x 10x 10x   10x 10x 10x 18x 18x 8x 8x   4x 4x     4x 4x     18x       9x 9x 29x 14x 14x       9x 29x   29x   21x 13x     8x         19x 19x                           19x   1x 1x           19x 1x             18x                                                                             17x                
// src/SunburstWidget.tsx
import React, { FC, useCallback, useMemo } from 'react';
import Plot from 'react-plotly.js';
import { useThemeMode } from '@ska-octopus-widget-sdk/widget-sdk';
import { grey } from '@mui/material/colors';
import { getColorForLabel } from './colors';
import styles from './SunburstWidget.module.css';
 
/* ------------------------------------------------------------------ */
/*  Public type                                                       */
/* ------------------------------------------------------------------ */
export interface SunburstData {
  ids?: string[]; // optional stable ids (when provided, parents MUST reference these ids)
  labels: string[];
  parents: string[];
  values: number[];
}
 
interface Props {
  title: string;
  data: SunburstData;
  onSliceClick?: (label: string) => void;
}
 
/* ------------------------------------------------------------------ */
/*  Component                                                         */
/* ------------------------------------------------------------------ */
const SunburstWidget: FC<Props> = React.memo(({ title, data, onSliceClick }) => {
  const { labels, parents, values } = data;
  const { isDark } = useThemeMode();
 
  /* Root --------------------------------------------------------- */
  const rootIndex = useMemo(() => parents.findIndex((p) => !p), [parents]);
  const rootLabel = labels[rootIndex];
 
  /* Colors
       Fix: honor first-level color and have second-level inherit it.
       IMPORTANT: When `ids` are provided, `parents` holds parent *ids* (not labels).
       We therefore color by walking the id graph instead of comparing labels.
    ---------------------------------------------------------------- */
  const colors = useMemo(() => {
    const rootColor = isDark ? grey[900] : grey[200];
    // Case A: ids present -> parents reference ids
    if (data.ids && data.ids.length === labels.length) {
      const ids = data.ids;
      const rootId = ids[rootIndex];
      // map: id -> color; ensure parent before child traversal (arrays are built in tree order)
      const idToColor = new Map<string, string>();
      idToColor.set(rootId, rootColor);
      for (let i = 0; i < ids.length; i++) {
        const id = ids[i];
        if (i === rootIndex) continue;
        const parentId = parents[i];
        if (parentId === rootId) {
          // level-1 node: choose color by its own label
          const c = getColorForLabel(labels[i]) ?? (isDark ? grey[700] : grey[400]);
          idToColor.set(id, c);
        } else {
          // deeper levels: inherit parent color
          const pc = idToColor.get(parentId) ?? (isDark ? grey[700] : grey[400]);
          idToColor.set(id, pc);
        }
      }
      return ids.map((id) => idToColor.get(id) ?? grey[300]);
    }
    // Case B: no ids -> parents reference labels (classic Plotly mode)
    // Build a map for level-1 (status) colors by comparing parent label to root label.
    const level1Color = new Map<string, string>();
    for (let i = 0; i < labels.length; i++) {
      if (parents[i] === rootLabel) {
        const c = getColorForLabel(labels[i]) ?? (isDark ? grey[700] : grey[400]);
        level1Color.set(labels[i], c);
      }
    }
    // Emit colors for every node
    return labels.map((label, i) => {
      const parent = parents[i];
      // Root
      if (!parent) return rootColor;
      // Level-1
      if (parent === rootLabel) {
        return level1Color.get(label) ?? (isDark ? grey[700] : grey[400]);
      }
      // Deeper: inherit from its level-1 ancestor label (parent is the level-1 label)
      return level1Color.get(parent) ?? grey[300];
    });
  }, [data.ids, labels, parents, rootIndex, rootLabel, isDark]);
 
  /* Layout with uirevision to keep UI state across updates -------- */
  const layout = useMemo(
    () => ({
      template: isDark ? ('plotly_dark' as any) : undefined,
      margin: { t: 0, l: 0, r: 0, b: 0 },
      autosize: true,
      paper_bgcolor: isDark ? '#1e1e1e' : 'transparent',
      plot_bgcolor: isDark ? '#1e1e1e' : 'transparent',
      font: { color: isDark ? '#e0e0e0' : '#000' },
      // Persist drill/selection when props change
      uirevision: title || 'sunburst'
    }),
    [isDark, title]
  );
 
  /* Click handler ------------------------------------------------- */
  const handleClick = useCallback(
    (evt: any) => {
      const label = evt?.points?.[0]?.label;
      Iif (onSliceClick && label) onSliceClick(label);
    },
    [onSliceClick]
  );
 
  /* Render -------------------------------------------------------- */
  if (labels.length === 0) {
    return (
      <div className={styles.container}>
        <div data-testid="sunburst-loading">Loading {title}…</div>
      </div>
    );
  }
 
  return (
    <div className={styles.container}>
      {/* Add a hidden title for testing */}
      <div data-testid="sunburst-title" style={{ display: 'none' }}>
        {title}
      </div>
      <div className={styles.chartWrapper}>
        <Plot
          /* IMPORTANT: do NOT set `key` or `revision` here, to avoid remounts. */
          data={[
            {
              type: 'sunburst',
              ids: data.ids, // use ids when provided
              labels,
              parents,
              values,
              branchvalues: 'total',
              marker: { colors },
              hovertemplate:
                '%{label}<br>Value: %{value}<br>Percent of parent: %{percentParent:.2%}<extra></extra>',
              // Per-trace uirevision (belt-and-braces)
              uirevision: title || 'sunburst'
            } as any
          ]}
          layout={layout}
          useResizeHandler
          className={styles.plot}
          config={{ responsive: true, displaylogo: false }}
          onClick={handleClick}
        />
      </div>
    </div>
  );
}, areEqual);
 
/* ------------------------------------------------------------------ */
/*  Deep compare to avoid needless re-renders                        */
/* ------------------------------------------------------------------ */
function areEqual(a: Props, b: Props) {
  return (
    a.title === b.title &&
    a.onSliceClick === b.onSliceClick &&
    JSON.stringify(a.data) === JSON.stringify(b.data)
  );
}
 
export default SunburstWidget;