All files / src/features/Settlements/ReconciliationHub/components HubGauge.tsx

100% Statements 34/34
38.09% Branches 8/21
100% Functions 3/3
100% Lines 34/34

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 170 171                                    3x 3x 3x 3x       3x 3x       3x 3x 3x               3x     3x 3x 3x 3x 3x 3x         3x 98x 98x             3x 49x 49x 49x 49x       3x               3x             23x 23x   23x     23x       23x   23x 23x   23x                                                                                                                                            
import { Stack } from "@mui/material";
import GiveText from "@shared/Text/GiveText";
import { useAppTheme } from "@theme/v2/Provider";
 
type GaugeColor = "warning" | "success";
 
interface HubGaugeProps {
  label: string;
  /** 0–100; clamped for the arc. */
  percent: number;
  /** Value rendered in the centre of the ring (e.g. "263 BPS" / "13.7%"). */
  centerText: string;
  /** Value rendered beside the label (e.g. "263 BPS (2.63%)"). */
  valueText: string;
  /** Arc colour family: warning (orange) or success (green). */
  color?: GaugeColor;
}
 
const SIZE = 128;
const STROKE = 14;
const RADIUS = (SIZE - STROKE) / 2;
const CENTER = SIZE / 2;
// The dotted track sits outside the coloured ring, so its outer edge extends
// past the SIZE box. Pad the viewBox by this much on every side, otherwise the
// dots at 12/3/6/9 o'clock are clipped by the SVG bounds (an incomplete ring).
const PAD = 6;
const VIEWBOX = `${-PAD} ${-PAD} ${SIZE + PAD * 2} ${SIZE + PAD * 2}`;
// The ring is a near-full circle with a gap centred at the bottom (6 o'clock),
// so the value arc sweeps up from the bottom-left. Angles are measured
// clockwise from 12 o'clock (0°=12, 90°=3, 180°=6, 270°=9), matching polar().
const GAP_DEG = 50;
const ARC_DEG = 360 - GAP_DEG;
const START_DEG = 180 + GAP_DEG / 2; // just clockwise of the bottom gap
 
// Dotted outer track. A fixed dash pattern (e.g. "1.5 5") leaves a partial dot
// where the path closes, because the circumference isn't a whole multiple of
// the pattern. Derive the gap from the circumference so a whole number of dots
// fits with no seam. The dash is kept small (with a round cap it reads as a
// dot) but must stay clearly above zero — a near-zero dash makes the rasterizer
// drop dots, leaving the ring looking incomplete.
const TRACK_RADIUS = RADIUS + STROKE / 2 + 3;
// The dotted track follows the same arc (and bottom gap) as the coloured ring,
// so space the dots along the arc length rather than the full circumference.
const TRACK_ARC_LENGTH = 2 * Math.PI * TRACK_RADIUS * (ARC_DEG / 360);
const DOT_STROKE = 3;
const DOT_LENGTH = 1; // dash length; round cap rounds it into a dot
const DOT_SPACING = 7; // target centre-to-centre distance between dots
const DOT_COUNT = Math.round(TRACK_ARC_LENGTH / DOT_SPACING);
const DOT_DASHARRAY = `${DOT_LENGTH} ${
  TRACK_ARC_LENGTH / DOT_COUNT - DOT_LENGTH
}`;
 
/** Point on the gauge at `angleDeg` (clockwise from 12 o'clock) and `radius`. */
const polar = (angleDeg: number, radius: number = RADIUS) => {
  const rad = ((angleDeg - 90) * Math.PI) / 180;
  return {
    x: CENTER + radius * Math.cos(rad),
    y: CENTER + radius * Math.sin(rad),
  };
};
 
/** SVG arc path covering `sweep` degrees clockwise from the gauge start. */
const arcPath = (sweep: number, radius: number = RADIUS) => {
  const start = polar(START_DEG, radius);
  const end = polar(START_DEG + sweep, radius);
  const largeArc = sweep > 180 ? 1 : 0;
  return `M ${start.x} ${start.y} A ${radius} ${radius} 0 ${largeArc} 1 ${end.x} ${end.y}`;
};
 
/** The dotted outer track: same arc/gap as the value ring, at the track radius. */
const DOTTED_TRACK_PATH = arcPath(ARC_DEG, TRACK_RADIUS);
 
/**
 * Donut gauge used for the Cost Rate / Chargeback Rate indicators: a dotted
 * track ring, a light-tint base arc, the coloured value arc, and the value
 * centred inside the ring — paired with a label/value column to the right,
 * all inside a bordered card.
 */
const HubGauge = ({
  label,
  percent,
  centerText,
  valueText,
  color = "success",
}: HubGaugeProps) => {
  const { palette } = useAppTheme();
  const clamped = Math.max(0, Math.min(100, percent));
  const fill =
    palette.primitive?.[color]?.[50] ??
    (color === "warning" ? "#FF8124" : "#088750");
  const track =
    palette.primitive?.[color]?.[25] ??
    (color === "warning" ? "#FFE1CC" : "#E6F3EC");
  // Dotted track uses a tint of the arc colour (not neutral grey) per the mock.
  const dotted =
    palette.primitive?.[color]?.[25] ??
    (color === "warning" ? "#FFE1CC" : "#E6F3EC");
  const cardBorder = palette.border?.primary ?? "#E5E5E3";
  const cardBackground = palette.surface?.primary ?? palette.background?.paper;
 
  return (
    <Stack
      alignItems="center"
      justifyContent="center"
      data-testid={`hub-gauge-${label}`}
      sx={{
        flex: "1 1 360px",
        minWidth: "320px",
        minHeight: "147px",
        padding: "16px 20px",
        borderRadius: "8px",
        border: `1px solid ${cardBorder}`,
        background: cardBackground,
      }}
    >
      <Stack
        direction="row"
        alignItems="center"
        justifyContent="center"
        gap="24px"
      >
        <Stack sx={{ position: "relative", width: SIZE, height: SIZE }}>
          <svg width={SIZE} height={SIZE} viewBox={VIEWBOX}>
            {/* dotted outer track (same arc/gap as the value ring) */}
            <path
              d={DOTTED_TRACK_PATH}
              fill="none"
              stroke={dotted}
              strokeWidth={DOT_STROKE}
              strokeDasharray={DOT_DASHARRAY}
              strokeLinecap="round"
            />
            {/* light base arc */}
            <path
              d={arcPath(ARC_DEG)}
              fill="none"
              stroke={track}
              strokeWidth={STROKE}
              strokeLinecap="round"
            />
            {/* coloured value arc */}
            <path
              d={arcPath((ARC_DEG * clamped) / 100)}
              fill="none"
              stroke={fill}
              strokeWidth={STROKE}
              strokeLinecap="round"
            />
          </svg>
          <Stack
            alignItems="center"
            justifyContent="center"
            sx={{ position: "absolute", inset: 0 }}
          >
            <GiveText variant="bodyM">{centerText}</GiveText>
          </Stack>
        </Stack>
 
        <Stack gap="4px" sx={{ width: "147px" }}>
          <GiveText variant="bodyS" color="secondary">
            {label}
          </GiveText>
          <GiveText variant="bodyL">{valueText}</GiveText>
        </Stack>
      </Stack>
    </Stack>
  );
};
 
export default HubGauge;