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;
|