panelui-native 0.87.0 → 0.88.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/lib/module/components/animated-badge/index.js +451 -0
  2. package/lib/module/components/animated-badge/index.js.map +1 -0
  3. package/lib/module/components/bottom-sheet/index.js +17 -6
  4. package/lib/module/components/bottom-sheet/index.js.map +1 -1
  5. package/lib/module/components/bubble-chart/index.js +557 -41
  6. package/lib/module/components/bubble-chart/index.js.map +1 -1
  7. package/lib/module/components/feedback-dialog/index.js +474 -0
  8. package/lib/module/components/feedback-dialog/index.js.map +1 -0
  9. package/lib/module/components/scroll-blur/index.js +363 -0
  10. package/lib/module/components/scroll-blur/index.js.map +1 -0
  11. package/lib/module/components/scroll-canvas/index.js +2 -0
  12. package/lib/module/components/scroll-canvas/index.js.map +1 -1
  13. package/lib/module/components/scroll-text/index.js +3 -0
  14. package/lib/module/components/scroll-text/index.js.map +1 -1
  15. package/lib/module/components/search-bar/index.js +390 -45
  16. package/lib/module/components/search-bar/index.js.map +1 -1
  17. package/lib/module/hooks/use-keyboard-avoidance.js +21 -1
  18. package/lib/module/hooks/use-keyboard-avoidance.js.map +1 -1
  19. package/lib/module/hooks/use-reveal-progress.js +21 -1
  20. package/lib/module/hooks/use-reveal-progress.js.map +1 -1
  21. package/lib/module/index.js +3 -0
  22. package/lib/module/index.js.map +1 -1
  23. package/lib/typescript/src/components/animated-badge/index.d.ts +224 -0
  24. package/lib/typescript/src/components/animated-badge/index.d.ts.map +1 -0
  25. package/lib/typescript/src/components/bottom-sheet/index.d.ts +1 -15
  26. package/lib/typescript/src/components/bottom-sheet/index.d.ts.map +1 -1
  27. package/lib/typescript/src/components/bubble-chart/index.d.ts +150 -7
  28. package/lib/typescript/src/components/bubble-chart/index.d.ts.map +1 -1
  29. package/lib/typescript/src/components/feedback-dialog/index.d.ts +156 -0
  30. package/lib/typescript/src/components/feedback-dialog/index.d.ts.map +1 -0
  31. package/lib/typescript/src/components/scroll-blur/index.d.ts +113 -0
  32. package/lib/typescript/src/components/scroll-blur/index.d.ts.map +1 -0
  33. package/lib/typescript/src/components/scroll-canvas/index.d.ts.map +1 -1
  34. package/lib/typescript/src/components/search-bar/index.d.ts +142 -7
  35. package/lib/typescript/src/components/search-bar/index.d.ts.map +1 -1
  36. package/lib/typescript/src/hooks/use-keyboard-avoidance.d.ts.map +1 -1
  37. package/lib/typescript/src/hooks/use-reveal-progress.d.ts +6 -27
  38. package/lib/typescript/src/hooks/use-reveal-progress.d.ts.map +1 -1
  39. package/lib/typescript/src/index.d.ts +5 -2
  40. package/lib/typescript/src/index.d.ts.map +1 -1
  41. package/package.json +1 -1
  42. package/src/components/animated-badge/index.tsx +513 -0
  43. package/src/components/bottom-sheet/index.tsx +23 -8
  44. package/src/components/bubble-chart/index.tsx +664 -46
  45. package/src/components/feedback-dialog/index.tsx +557 -0
  46. package/src/components/scroll-blur/index.tsx +466 -0
  47. package/src/components/scroll-canvas/index.tsx +2 -1
  48. package/src/components/scroll-text/index.tsx +3 -3
  49. package/src/components/search-bar/index.tsx +471 -41
  50. package/src/hooks/use-keyboard-avoidance.ts +24 -1
  51. package/src/hooks/use-reveal-progress.ts +30 -1
  52. package/src/index.ts +27 -0
@@ -88,6 +88,7 @@ import { cn } from '../../utils/cn';
88
88
 
89
89
  const AnimatedCircle = Animated.createAnimatedComponent(Circle);
90
90
  const AnimatedG = Animated.createAnimatedComponent(G);
91
+ const AnimatedLine = Animated.createAnimatedComponent(SvgLine);
91
92
 
92
93
  /**
93
94
  * How much of the reveal is spent handing out the bubbles' start times. The
@@ -156,7 +157,22 @@ const HIT_RADIUS = 22;
156
157
  const PALETTE_SIZE = 5;
157
158
 
158
159
  /** Steps each axis is rounded out to. Matches the labels an axis draws. */
159
- const AXIS_STEPS = 2;
160
+ const AXIS_STEPS = 4;
161
+
162
+ /** Room under the numbers for an axis's own name, when it is given one. */
163
+ const AXIS_TITLE_HEIGHT = 18;
164
+
165
+ /** Gap between a quadrant's caption and the corner it is written in. */
166
+ const QUADRANT_LABEL_INSET = 6;
167
+
168
+ /** Column the size key's values are written in, beside its circles. */
169
+ const SIZE_KEY_LABEL_WIDTH = 44;
170
+
171
+ /** Gap between the size key's circles and the values naming them. */
172
+ const SIZE_KEY_GAP = 6;
173
+
174
+ /** Room beside the numbers for the y axis's name, which is turned on its side. */
175
+ const AXIS_TITLE_WIDTH = 18;
160
176
 
161
177
  type Layer = 'svg' | 'overlay' | 'header' | 'footer';
162
178
 
@@ -200,6 +216,10 @@ interface BubbleChartContextValue {
200
216
  /** The settled domains, for the parts that draw text rather than geometry. */
201
217
  xExtent: [number, number];
202
218
  yExtent: [number, number];
219
+ /** Lowest and highest value behind the areas, or null without a `sizeKey`. */
220
+ sizeExtent: [number, number] | null;
221
+ /** The radii those two values map onto. */
222
+ sizeRange: [number, number];
203
223
  /** 0 to 1 as the bubbles grow in. Shared, so they arrive as one chart. */
204
224
  reveal: SharedValue<number>;
205
225
  activeIndex: SharedValue<number>;
@@ -373,14 +393,28 @@ const BubbleChartRoot = forwardRef<BubbleChartHandle, BubbleChartProps>(
373
393
  [chart1, chart2, chart3, chart4, chart5]
374
394
  );
375
395
 
376
- const hasYAxis = useMemo(() => {
377
- let found = false;
396
+ /*
397
+ * What the axes are going to need before anything has been laid out. The
398
+ * gutter for the y labels and the strip under the x ones are the plot's
399
+ * padding, so they have to be known here rather than by the parts that draw
400
+ * them — a part that reserved its own room would be positioned against a
401
+ * plot that had already been sized without it.
402
+ */
403
+ const axes = useMemo(() => {
404
+ let y = false;
405
+ let yTitle = false;
406
+ let xTitle = false;
378
407
  Children.forEach(children, (child) => {
379
- if (isValidElement(child) && (child.type as { axis?: string }).axis === 'y') {
380
- found = true;
408
+ if (!isValidElement(child)) return;
409
+ const axis = (child.type as { axis?: string }).axis;
410
+ const labelled = Boolean((child.props as { label?: string }).label);
411
+ if (axis === 'y') {
412
+ y = true;
413
+ if (labelled) yTitle = true;
381
414
  }
415
+ if (axis === 'x' && labelled) xTitle = true;
382
416
  });
383
- return found;
417
+ return { y, yTitle, xTitle };
384
418
  }, [children]);
385
419
 
386
420
  /*
@@ -394,8 +428,12 @@ const BubbleChartRoot = forwardRef<BubbleChartHandle, BubbleChartProps>(
394
428
  const pad = {
395
429
  top: Math.max(PADDING.top, reach),
396
430
  right: Math.max(PADDING.right, reach),
397
- bottom: Math.max(PADDING.bottom, reach),
398
- left: Math.max(hasYAxis ? Y_AXIS_WIDTH : PADDING.left, reach),
431
+ bottom:
432
+ Math.max(PADDING.bottom, reach) + (axes.xTitle ? AXIS_TITLE_HEIGHT : 0),
433
+ left: Math.max(
434
+ axes.y ? Y_AXIS_WIDTH + (axes.yTitle ? AXIS_TITLE_WIDTH : 0) : PADDING.left,
435
+ reach
436
+ ),
399
437
  };
400
438
  const plot: Plot = {
401
439
  left: pad.left,
@@ -583,6 +621,8 @@ const BubbleChartRoot = forwardRef<BubbleChartHandle, BubbleChartProps>(
583
621
  yMax,
584
622
  xExtent: extents.x,
585
623
  yExtent: extents.y,
624
+ sizeExtent,
625
+ sizeRange,
586
626
  reveal,
587
627
  activeIndex,
588
628
  activeIndexJS,
@@ -606,6 +646,8 @@ const BubbleChartRoot = forwardRef<BubbleChartHandle, BubbleChartProps>(
606
646
  yMin,
607
647
  yMax,
608
648
  extents,
649
+ sizeExtent,
650
+ sizeRange,
609
651
  reveal,
610
652
  activeIndex,
611
653
  activeIndexJS,
@@ -672,29 +714,37 @@ BubbleChartRoot.displayName = 'BubbleChart';
672
714
  /* -------------------------------------------------------------------------- */
673
715
 
674
716
  export interface BubbleChartGridProps {
675
- /** Horizontal rules across the plot. */
717
+ /**
718
+ * Horizontal rules across the plot.
719
+ *
720
+ * Eight, which is twice the four intervals an axis is divided into by
721
+ * default, so every second line carries a number and the ones between it are
722
+ * halves of a labelled step rather than an unrelated rhythm. Squares this
723
+ * size recede behind the circles; the coarse grid a smaller number draws
724
+ * reads as blocks laid over the plot.
725
+ */
676
726
  rows?: number;
677
727
  /** Vertical rules up it. Both axes are measured, so both earn lines. */
678
728
  columns?: number;
679
- /*
680
- * Both default to five. A coarse grid draws a handful of large squares that
681
- * read as blocks behind the bubbles rather than as reference lines; a finer
682
- * one recedes and lets the circles be the thing on the chart.
683
- */
729
+ /** Dash pattern for the rules. Pass `undefined` for solid ones. */
730
+ dashArray?: string;
684
731
  color?: string;
685
732
  opacity?: number;
686
733
  }
687
734
 
688
735
  /** Reference lines both ways, so a bubble can be placed against two numbers. */
689
736
  function BubbleChartGrid({
690
- rows = 5,
691
- columns = 5,
737
+ rows = 8,
738
+ columns = 8,
739
+ dashArray = '4,6',
692
740
  color,
693
741
  opacity = 1,
694
742
  }: BubbleChartGridProps) {
695
743
  const { plot } = useChart('BubbleChart.Grid');
696
744
  const token = useCSSVariable('--color-border');
697
- const stroke = color ?? (typeof token === 'string' ? token : 'rgba(0,0,0,0.1)');
745
+ // The fallback is grey rather than black: it stands in when the theme cannot
746
+ // be read, and a black hairline is invisible on a dark background.
747
+ const stroke = color ?? (typeof token === 'string' ? token : 'rgba(128,128,128,0.2)');
698
748
 
699
749
  const horizontals = Array.from({ length: rows + 1 }, (_unused, i) => i / rows);
700
750
  const verticals = Array.from({ length: columns + 1 }, (_unused, i) => i / columns);
@@ -710,6 +760,7 @@ function BubbleChartGrid({
710
760
  y2={plot.top + plot.height * fraction}
711
761
  stroke={stroke}
712
762
  strokeWidth={1}
763
+ strokeDasharray={dashArray}
713
764
  />
714
765
  ))}
715
766
  {verticals.map((fraction) => (
@@ -721,6 +772,7 @@ function BubbleChartGrid({
721
772
  y2={plot.top + plot.height}
722
773
  stroke={stroke}
723
774
  strokeWidth={1}
775
+ strokeDasharray={dashArray}
724
776
  />
725
777
  ))}
726
778
  </G>
@@ -729,6 +781,176 @@ function BubbleChartGrid({
729
781
  BubbleChartGrid.displayName = 'BubbleChart.Grid';
730
782
  BubbleChartGrid.layer = 'svg' as Layer;
731
783
 
784
+ export interface BubbleChartTrendProps {
785
+ /**
786
+ * The line's slope and intercept, and how tightly the cloud sits on it, once
787
+ * they have been computed. `r` runs 0 to 1: 1 is every bubble on the line,
788
+ * 0 is a cloud with no direction at all.
789
+ *
790
+ * Given here rather than left for the caller to work out, because the fit is
791
+ * already being computed to draw the line and doing it twice invites the two
792
+ * answers to disagree.
793
+ *
794
+ * It fires when the numbers change, not on every render that produced the
795
+ * same ones, so putting the fit straight into state is safe.
796
+ */
797
+ onFit?: (fit: { slope: number; intercept: number; r: number }) => void;
798
+ color?: string;
799
+ strokeWidth?: number;
800
+ /** Dash pattern. Dashed by default: the line is a reading, not a measurement. */
801
+ dashArray?: string;
802
+ opacity?: number;
803
+ }
804
+
805
+ /**
806
+ * The straight line that fits the cloud best, drawn across the plot.
807
+ *
808
+ * It is dashed and drawn under the circles, because it is not data — it is a
809
+ * summary of the data, and a solid rule through the middle of a field of
810
+ * bubbles reads as a value somebody plotted.
811
+ *
812
+ * The fit is least squares on the raw values, so it moves with the data rather
813
+ * than with the frame: resizing the chart never changes the line's meaning.
814
+ * Fewer than two bubbles, or every bubble on one vertical, has no line to draw
815
+ * and none is drawn.
816
+ */
817
+ function BubbleChartTrend({
818
+ onFit,
819
+ color,
820
+ strokeWidth = 1.5,
821
+ dashArray = '6,5',
822
+ opacity = 0.7,
823
+ }: BubbleChartTrendProps) {
824
+ const { bubbles, plot, status, xMin, xMax, yMin, yMax, reveal } =
825
+ useChart('BubbleChart.Trend');
826
+ const token = useCSSVariable('--color-muted-foreground');
827
+ const stroke = color ?? (typeof token === 'string' ? token : 'rgba(128,128,128,0.8)');
828
+
829
+ const fit = useMemo(() => {
830
+ const n = bubbles.length;
831
+ if (n < 2) return null;
832
+ let sumX = 0;
833
+ let sumY = 0;
834
+ for (const bubble of bubbles) {
835
+ sumX += bubble.x;
836
+ sumY += bubble.y;
837
+ }
838
+ const meanX = sumX / n;
839
+ const meanY = sumY / n;
840
+ let sxy = 0;
841
+ let sxx = 0;
842
+ let syy = 0;
843
+ for (const bubble of bubbles) {
844
+ const dx = bubble.x - meanX;
845
+ const dy = bubble.y - meanY;
846
+ sxy += dx * dy;
847
+ sxx += dx * dx;
848
+ syy += dy * dy;
849
+ }
850
+ // Every bubble on one vertical: the best fit is that vertical, which has no
851
+ // slope and nothing useful to draw.
852
+ if (sxx === 0) return null;
853
+ const slope = sxy / sxx;
854
+ const denominator = Math.sqrt(sxx * syy);
855
+ return {
856
+ slope,
857
+ intercept: meanY - slope * meanX,
858
+ r: denominator === 0 ? 0 : Math.abs(sxy / denominator),
859
+ };
860
+ }, [bubbles]);
861
+
862
+ const fitRef = useRef(onFit);
863
+ useEffect(() => {
864
+ fitRef.current = onFit;
865
+ });
866
+
867
+ /*
868
+ * Reported when the numbers change, not when the object does.
869
+ *
870
+ * `bubbles` is rebuilt whenever any of its inputs changes identity, and
871
+ * `sizeRange={[14, 30]}` written at a call site is a new array every render —
872
+ * so the fit is a new object every render even when the data has not moved.
873
+ * Handing that to a caller who puts it in state is a render loop, and the
874
+ * caller has no way to see that coming.
875
+ */
876
+ const reported = useRef<{ slope: number; intercept: number; r: number } | null>(null);
877
+ useEffect(() => {
878
+ if (!fit) return;
879
+ const last = reported.current;
880
+ if (
881
+ last &&
882
+ last.slope === fit.slope &&
883
+ last.intercept === fit.intercept &&
884
+ last.r === fit.r
885
+ ) {
886
+ return;
887
+ }
888
+ reported.current = fit;
889
+ fitRef.current?.(fit);
890
+ }, [fit]);
891
+
892
+ const slope = fit?.slope ?? 0;
893
+ const intercept = fit?.intercept ?? 0;
894
+
895
+ const animatedProps = useAnimatedProps(() => {
896
+ const x0 = xMin.value;
897
+ const x1 = xMax.value;
898
+ const lowY = yMin.value;
899
+ const highY = yMax.value;
900
+
901
+ /*
902
+ * Solved in data space and then clipped there, rather than drawn across the
903
+ * plot and clipped by the frame: a line that leaves the top of the chart
904
+ * has to stop where it leaves it, and the x of that point is only knowable
905
+ * from the equation.
906
+ */
907
+ let ax = x0;
908
+ let bx = x1;
909
+ if (slope !== 0) {
910
+ const atLow = (lowY - intercept) / slope;
911
+ const atHigh = (highY - intercept) / slope;
912
+ const enter = Math.min(atLow, atHigh);
913
+ const exit = Math.max(atLow, atHigh);
914
+ ax = Math.max(ax, enter);
915
+ bx = Math.min(bx, exit);
916
+ }
917
+ if (bx < ax) {
918
+ // The line never crosses the visible box.
919
+ return { x1: 0, x2: 0, y1: 0, y2: 0, opacity: 0 };
920
+ }
921
+
922
+ // Drawn out from the middle as the bubbles land, so the line arrives with
923
+ // the field rather than being there waiting for it.
924
+ const grown = Math.max(0, Math.min(1, reveal.value));
925
+ const midpoint = (ax + bx) / 2;
926
+ const half = ((bx - ax) / 2) * grown;
927
+
928
+ const startX = midpoint - half;
929
+ const endX = midpoint + half;
930
+ return {
931
+ x1: xAt(startX, plot, x0, x1),
932
+ x2: xAt(endX, plot, x0, x1),
933
+ y1: yOf(intercept + slope * startX, plot, lowY, highY),
934
+ y2: yOf(intercept + slope * endX, plot, lowY, highY),
935
+ opacity: opacity * grown,
936
+ };
937
+ });
938
+
939
+ if (status === 'loading' || !fit) return null;
940
+
941
+ return (
942
+ <AnimatedLine
943
+ animatedProps={animatedProps}
944
+ stroke={stroke}
945
+ strokeWidth={strokeWidth}
946
+ strokeDasharray={dashArray}
947
+ strokeLinecap="round"
948
+ />
949
+ );
950
+ }
951
+ BubbleChartTrend.displayName = 'BubbleChart.Trend';
952
+ BubbleChartTrend.layer = 'svg' as Layer;
953
+
732
954
  export interface BubbleChartBubblesProps {
733
955
  /**
734
956
  * Fill opacity. Below 1 by default so that overlapping bubbles read as
@@ -967,6 +1189,332 @@ function BubbleChartLabels({
967
1189
  BubbleChartLabels.displayName = 'BubbleChart.Labels';
968
1190
  BubbleChartLabels.layer = 'overlay' as Layer;
969
1191
 
1192
+ export interface BubbleChartQuadrantsProps {
1193
+ /** Where the vertical rule stands. Defaults to the mean of the x values. */
1194
+ x?: number;
1195
+ /** Where the horizontal rule lies. Defaults to the mean of the y values. */
1196
+ y?: number;
1197
+ /** A word for each corner, written in the corner it belongs to. */
1198
+ labels?: {
1199
+ topLeft?: string;
1200
+ topRight?: string;
1201
+ bottomLeft?: string;
1202
+ bottomRight?: string;
1203
+ };
1204
+ /** Tint the high-high and low-low corners. On by default. */
1205
+ tint?: boolean;
1206
+ color?: string;
1207
+ className?: string;
1208
+ }
1209
+
1210
+ /**
1211
+ * A crosshair splitting the plot into four, with a name for each corner.
1212
+ *
1213
+ * A field of bubbles is usually read as four groups rather than as a cloud —
1214
+ * which of these is doing well on both counts, which on neither — and without
1215
+ * a divider the reader draws that line by eye, in a different place each time.
1216
+ * Putting it on the chart makes it one line everybody sees.
1217
+ *
1218
+ * It stands at the mean of each axis by default, because that is the split the
1219
+ * data itself argues for. Pass `x` and `y` for a target, a budget or last
1220
+ * year's number — a threshold somebody decided rather than one the data
1221
+ * produced.
1222
+ *
1223
+ * The tint marks the two corners a reading usually ends at. Turn it off where
1224
+ * all four corners matter equally.
1225
+ */
1226
+ function BubbleChartQuadrants({
1227
+ x,
1228
+ y,
1229
+ labels,
1230
+ tint = true,
1231
+ color,
1232
+ className,
1233
+ }: BubbleChartQuadrantsProps) {
1234
+ const { bubbles, plot, status, xMin, xMax, yMin, yMax } =
1235
+ useChart('BubbleChart.Quadrants');
1236
+ const token = useCSSVariable('--color-muted-foreground');
1237
+ const stroke = color ?? (typeof token === 'string' ? token : 'rgba(128,128,128,0.8)');
1238
+
1239
+ const centre = useMemo(() => {
1240
+ if (!bubbles.length) return null;
1241
+ let sumX = 0;
1242
+ let sumY = 0;
1243
+ for (const bubble of bubbles) {
1244
+ sumX += bubble.x;
1245
+ sumY += bubble.y;
1246
+ }
1247
+ return { x: x ?? sumX / bubbles.length, y: y ?? sumY / bubbles.length };
1248
+ }, [bubbles, x, y]);
1249
+
1250
+ const atX = centre?.x ?? 0;
1251
+ const atY = centre?.y ?? 0;
1252
+
1253
+ const verticalStyle = useAnimatedStyle(() => ({
1254
+ left: xAt(atX, plot, xMin.value, xMax.value),
1255
+ }));
1256
+ const horizontalStyle = useAnimatedStyle(() => ({
1257
+ top: yOf(atY, plot, yMin.value, yMax.value),
1258
+ }));
1259
+ /*
1260
+ * Two rectangles rather than four: the pair that is tinted is the pair the
1261
+ * reader is being pointed at, and shading all four would only be a checked
1262
+ * background.
1263
+ */
1264
+ const highStyle = useAnimatedStyle(() => {
1265
+ const cx = xAt(atX, plot, xMin.value, xMax.value);
1266
+ const cy = yOf(atY, plot, yMin.value, yMax.value);
1267
+ return {
1268
+ left: cx,
1269
+ top: plot.top,
1270
+ width: Math.max(plot.left + plot.width - cx, 0),
1271
+ height: Math.max(cy - plot.top, 0),
1272
+ };
1273
+ });
1274
+ const lowStyle = useAnimatedStyle(() => {
1275
+ const cx = xAt(atX, plot, xMin.value, xMax.value);
1276
+ const cy = yOf(atY, plot, yMin.value, yMax.value);
1277
+ return {
1278
+ left: plot.left,
1279
+ top: cy,
1280
+ width: Math.max(cx - plot.left, 0),
1281
+ height: Math.max(plot.top + plot.height - cy, 0),
1282
+ };
1283
+ });
1284
+
1285
+ if (status === 'loading' || !centre) return null;
1286
+
1287
+ const corner = {
1288
+ position: 'absolute' as const,
1289
+ width: plot.width / 2 - QUADRANT_LABEL_INSET,
1290
+ };
1291
+
1292
+ return (
1293
+ <View
1294
+ pointerEvents="none"
1295
+ style={{ position: 'absolute', inset: 0 }}
1296
+ className={cn(className)}
1297
+ >
1298
+ {tint ? (
1299
+ <>
1300
+ <Animated.View
1301
+ style={[{ position: 'absolute' }, highStyle]}
1302
+ className="bg-foreground/[0.04]"
1303
+ />
1304
+ <Animated.View
1305
+ style={[{ position: 'absolute' }, lowStyle]}
1306
+ className="bg-foreground/[0.04]"
1307
+ />
1308
+ </>
1309
+ ) : null}
1310
+ <Animated.View
1311
+ style={[
1312
+ {
1313
+ position: 'absolute',
1314
+ top: plot.top,
1315
+ height: plot.height,
1316
+ width: 1,
1317
+ backgroundColor: stroke,
1318
+ opacity: 0.4,
1319
+ },
1320
+ verticalStyle,
1321
+ ]}
1322
+ />
1323
+ <Animated.View
1324
+ style={[
1325
+ {
1326
+ position: 'absolute',
1327
+ left: plot.left,
1328
+ width: plot.width,
1329
+ height: 1,
1330
+ backgroundColor: stroke,
1331
+ opacity: 0.4,
1332
+ },
1333
+ horizontalStyle,
1334
+ ]}
1335
+ />
1336
+ {/*
1337
+ Pinned to the plot's corners rather than to the crosshair. A caption
1338
+ names the region, and a region's name belongs at the far end of it —
1339
+ following the rules it would crowd them as the split moved.
1340
+ */}
1341
+ {labels?.topLeft ? (
1342
+ <Text
1343
+ size="xs"
1344
+ muted
1345
+ numberOfLines={1}
1346
+ style={{
1347
+ ...corner,
1348
+ left: plot.left + QUADRANT_LABEL_INSET,
1349
+ top: plot.top + QUADRANT_LABEL_INSET,
1350
+ }}
1351
+ >
1352
+ {labels.topLeft}
1353
+ </Text>
1354
+ ) : null}
1355
+ {labels?.topRight ? (
1356
+ <Text
1357
+ size="xs"
1358
+ muted
1359
+ numberOfLines={1}
1360
+ style={{
1361
+ ...corner,
1362
+ left: plot.left + plot.width / 2,
1363
+ top: plot.top + QUADRANT_LABEL_INSET,
1364
+ textAlign: 'right',
1365
+ }}
1366
+ >
1367
+ {labels.topRight}
1368
+ </Text>
1369
+ ) : null}
1370
+ {labels?.bottomLeft ? (
1371
+ <Text
1372
+ size="xs"
1373
+ muted
1374
+ numberOfLines={1}
1375
+ style={{
1376
+ ...corner,
1377
+ left: plot.left + QUADRANT_LABEL_INSET,
1378
+ top: plot.top + plot.height - QUADRANT_LABEL_INSET - AXIS_LABEL_HEIGHT,
1379
+ }}
1380
+ >
1381
+ {labels.bottomLeft}
1382
+ </Text>
1383
+ ) : null}
1384
+ {labels?.bottomRight ? (
1385
+ <Text
1386
+ size="xs"
1387
+ muted
1388
+ numberOfLines={1}
1389
+ style={{
1390
+ ...corner,
1391
+ left: plot.left + plot.width / 2,
1392
+ top: plot.top + plot.height - QUADRANT_LABEL_INSET - AXIS_LABEL_HEIGHT,
1393
+ textAlign: 'right',
1394
+ }}
1395
+ >
1396
+ {labels.bottomRight}
1397
+ </Text>
1398
+ ) : null}
1399
+ </View>
1400
+ );
1401
+ }
1402
+ BubbleChartQuadrants.displayName = 'BubbleChart.Quadrants';
1403
+ BubbleChartQuadrants.layer = 'overlay' as Layer;
1404
+
1405
+ export interface BubbleChartSizeKeyProps {
1406
+ /** Which corner of the plot it sits in. */
1407
+ placement?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
1408
+ /** Turn a value into its label. Defaults to a compact number. */
1409
+ format?: (value: number) => string;
1410
+ /** A word for what the area means — "people", "revenue". */
1411
+ label?: string;
1412
+ className?: string;
1413
+ }
1414
+
1415
+ /**
1416
+ * Three nested circles saying what a bubble's area is worth.
1417
+ *
1418
+ * A bubble chart's third quantity is the one it cannot state: position can be
1419
+ * read off the axes, but area has no axis, so a reader can see that one circle
1420
+ * is bigger than another and has no way to know by how much. This is the only
1421
+ * part of the chart that answers that.
1422
+ *
1423
+ * Nested and sharing a baseline, which is how a difference in area is compared
1424
+ * — three circles in a row are three sizes, three circles inside one another
1425
+ * are one scale.
1426
+ *
1427
+ * It needs a `sizeKey` on the chart. Without one every bubble is the same size
1428
+ * and there is no scale to key.
1429
+ */
1430
+ function BubbleChartSizeKey({
1431
+ placement = 'bottom-right',
1432
+ format,
1433
+ label,
1434
+ className,
1435
+ }: BubbleChartSizeKeyProps) {
1436
+ const { plot, status, sizeExtent, sizeRange } = useChart('BubbleChart.SizeKey');
1437
+ const token = useCSSVariable('--color-muted-foreground');
1438
+ const stroke = typeof token === 'string' ? token : 'rgba(128,128,128,0.8)';
1439
+
1440
+ const steps = useMemo(() => {
1441
+ if (!sizeExtent) return null;
1442
+ const [min, max] = sizeExtent;
1443
+ const middle = (min + max) / 2;
1444
+ // Largest first, so the smallest is drawn last and stays visible inside it.
1445
+ return [max, middle, min].map((value) => ({
1446
+ value,
1447
+ r: bubbleRadius(value, sizeExtent, sizeRange),
1448
+ }));
1449
+ }, [sizeExtent, sizeRange]);
1450
+
1451
+ if (status === 'loading' || !steps) return null;
1452
+
1453
+ const outer = steps[0]!.r;
1454
+ const width = outer * 2 + SIZE_KEY_LABEL_WIDTH;
1455
+ const height = outer * 2 + (label ? AXIS_LABEL_HEIGHT : 0);
1456
+ const top = placement.startsWith('top')
1457
+ ? plot.top
1458
+ : plot.top + plot.height - height;
1459
+ const left = placement.endsWith('left')
1460
+ ? plot.left
1461
+ : plot.left + plot.width - width;
1462
+
1463
+ return (
1464
+ <View
1465
+ pointerEvents="none"
1466
+ style={{ position: 'absolute', left, top, width, height }}
1467
+ className={cn(className)}
1468
+ >
1469
+ {label ? (
1470
+ <Text size="xs" muted numberOfLines={1}>
1471
+ {label}
1472
+ </Text>
1473
+ ) : null}
1474
+ <View style={{ height: outer * 2, width }}>
1475
+ {steps.map((step) => (
1476
+ <View
1477
+ key={step.value}
1478
+ style={{
1479
+ position: 'absolute',
1480
+ // Shared bottom edge and shared centre line: the circles nest
1481
+ // rather than stack, so the areas are laid over one another.
1482
+ bottom: 0,
1483
+ left: outer - step.r,
1484
+ width: step.r * 2,
1485
+ height: step.r * 2,
1486
+ borderRadius: step.r,
1487
+ borderWidth: 1,
1488
+ borderColor: stroke,
1489
+ opacity: 0.6,
1490
+ }}
1491
+ />
1492
+ ))}
1493
+ {steps.map((step) => (
1494
+ <Text
1495
+ key={`v${step.value}`}
1496
+ size="xs"
1497
+ muted
1498
+ numberOfLines={1}
1499
+ style={{
1500
+ position: 'absolute',
1501
+ left: outer * 2 + SIZE_KEY_GAP,
1502
+ // Level with the top of the circle it names, which is the only
1503
+ // edge the three do not share.
1504
+ top: outer * 2 - step.r * 2 - AXIS_LABEL_HEIGHT / 2,
1505
+ width: SIZE_KEY_LABEL_WIDTH - SIZE_KEY_GAP,
1506
+ }}
1507
+ >
1508
+ {format ? format(step.value) : compactNumber(step.value)}
1509
+ </Text>
1510
+ ))}
1511
+ </View>
1512
+ </View>
1513
+ );
1514
+ }
1515
+ BubbleChartSizeKey.displayName = 'BubbleChart.SizeKey';
1516
+ BubbleChartSizeKey.layer = 'overlay' as Layer;
1517
+
970
1518
  function BubbleLabel({
971
1519
  bubble,
972
1520
  text,
@@ -1031,10 +1579,18 @@ function BubbleLabel({
1031
1579
  }
1032
1580
 
1033
1581
  export interface BubbleChartXAxisProps {
1034
- /** How many intervals to divide the axis into. Yields `ticks + 1` labels. */
1582
+ /**
1583
+ * How many intervals to divide the axis into. Yields `ticks + 1` labels.
1584
+ *
1585
+ * Four, and the domain is rounded out to four steps to match, so the numbers
1586
+ * come out round. Fewer leaves most of the grid unnamed — a line with nothing
1587
+ * beside it is a line the reader has to count their way to.
1588
+ */
1035
1589
  ticks?: number;
1036
1590
  /** Turn a value into its label. Defaults to a compact number. */
1037
1591
  format?: (value: number) => string;
1592
+ /** What the axis measures, written under the numbers. */
1593
+ label?: string;
1038
1594
  className?: string;
1039
1595
  }
1040
1596
 
@@ -1044,7 +1600,7 @@ export interface BubbleChartXAxisProps {
1044
1600
  * Evenly spaced, because this axis is a continuous scale rather than a list of
1045
1601
  * rows. There is no bubble for a label to sit under.
1046
1602
  */
1047
- function BubbleChartXAxis({ ticks = 2, format, className }: BubbleChartXAxisProps) {
1603
+ function BubbleChartXAxis({ ticks = 4, format, label, className }: BubbleChartXAxisProps) {
1048
1604
  const { plot, xExtent } = useChart('BubbleChart.XAxis');
1049
1605
 
1050
1606
  const labels = useMemo(() => {
@@ -1061,15 +1617,31 @@ function BubbleChartXAxis({ ticks = 2, format, className }: BubbleChartXAxisProp
1061
1617
  style={{ position: 'absolute', inset: 0, pointerEvents: 'none' }}
1062
1618
  className={cn(className)}
1063
1619
  >
1064
- {labels.map((label) => (
1620
+ {label ? (
1065
1621
  <Text
1066
- key={label.key}
1067
1622
  size="xs"
1068
1623
  muted
1069
1624
  numberOfLines={1}
1070
1625
  style={{
1071
1626
  position: 'absolute',
1072
1627
  bottom: 0,
1628
+ left: plot.left,
1629
+ width: plot.width,
1630
+ textAlign: 'center',
1631
+ }}
1632
+ >
1633
+ {label}
1634
+ </Text>
1635
+ ) : null}
1636
+ {labels.map((tick) => (
1637
+ <Text
1638
+ key={tick.key}
1639
+ size="xs"
1640
+ muted
1641
+ numberOfLines={1}
1642
+ style={{
1643
+ position: 'absolute',
1644
+ bottom: label ? AXIS_TITLE_HEIGHT : 0,
1073
1645
  // Centred on its tick, then held inside the chart. The first and
1074
1646
  // last ticks sit on the plot's own edges, so a box centred on them
1075
1647
  // hangs half its width off the side — the clamp slides those two
@@ -1077,7 +1649,7 @@ function BubbleChartXAxis({ ticks = 2, format, className }: BubbleChartXAxisProp
1077
1649
  left: Math.max(
1078
1650
  0,
1079
1651
  Math.min(
1080
- plot.left + (plot.width / ticks) * label.key - AXIS_LABEL_WIDTH / 2,
1652
+ plot.left + (plot.width / ticks) * tick.key - AXIS_LABEL_WIDTH / 2,
1081
1653
  plot.left + plot.width + PADDING.right - AXIS_LABEL_WIDTH
1082
1654
  )
1083
1655
  ),
@@ -1085,7 +1657,7 @@ function BubbleChartXAxis({ ticks = 2, format, className }: BubbleChartXAxisProp
1085
1657
  textAlign: 'center',
1086
1658
  }}
1087
1659
  >
1088
- {label.text}
1660
+ {tick.text}
1089
1661
  </Text>
1090
1662
  ))}
1091
1663
  </View>
@@ -1093,17 +1665,33 @@ function BubbleChartXAxis({ ticks = 2, format, className }: BubbleChartXAxisProp
1093
1665
  }
1094
1666
  BubbleChartXAxis.displayName = 'BubbleChart.XAxis';
1095
1667
  BubbleChartXAxis.layer = 'overlay' as Layer;
1668
+ // Read by the root, which has to leave room under the numbers before it lays
1669
+ // the plot out.
1670
+ BubbleChartXAxis.axis = 'x' as const;
1096
1671
 
1097
1672
  export interface BubbleChartYAxisProps {
1098
- /** How many intervals to divide the axis into. Yields `ticks + 1` labels. */
1673
+ /**
1674
+ * How many intervals to divide the axis into. Yields `ticks + 1` labels.
1675
+ *
1676
+ * Four, matching the four steps the domain is rounded out to and every second
1677
+ * line of the default grid.
1678
+ */
1099
1679
  ticks?: number;
1100
1680
  /** Turn a value into its label. Defaults to a compact number. */
1101
1681
  format?: (value: number) => string;
1682
+ /** What the axis measures, written up the side of it. */
1683
+ label?: string;
1102
1684
  className?: string;
1103
1685
  }
1104
1686
 
1105
- /** Value labels down the side, one per grid line. Reserves its own gutter. */
1106
- function BubbleChartYAxis({ ticks = 2, format, className }: BubbleChartYAxisProps) {
1687
+ /**
1688
+ * Value labels down the side, evenly over the axis, and the gutter they sit in.
1689
+ *
1690
+ * They land on every second line of the default grid rather than on all of
1691
+ * them: a number beside every line of a grid fine enough to read against is a
1692
+ * column of numbers, and the reader stops seeing the chart.
1693
+ */
1694
+ function BubbleChartYAxis({ ticks = 4, format, label, className }: BubbleChartYAxisProps) {
1107
1695
  const { plot, yExtent } = useChart('BubbleChart.YAxis');
1108
1696
 
1109
1697
  const labels = useMemo(() => {
@@ -1115,27 +1703,54 @@ function BubbleChartYAxis({ ticks = 2, format, className }: BubbleChartYAxisProp
1115
1703
  });
1116
1704
  }, [yExtent, ticks, format]);
1117
1705
 
1706
+ const titleWidth = label ? AXIS_TITLE_WIDTH : 0;
1707
+
1118
1708
  return (
1119
- <View
1120
- pointerEvents="none"
1121
- style={{
1122
- position: 'absolute',
1123
- left: 0,
1124
- // Centred on the grid line each label names: the strip is lifted half a
1125
- // label and grown by a whole one, so `justify-between` lands the text's
1126
- // middle on the line rather than its top edge on the first.
1127
- top: plot.top - AXIS_LABEL_HEIGHT / 2,
1128
- height: plot.height + AXIS_LABEL_HEIGHT,
1129
- width: Math.max(plot.left - Y_AXIS_GUTTER, 0),
1130
- }}
1131
- className={cn('items-end justify-between', className)}
1132
- >
1133
- {labels.map((label) => (
1134
- <Text key={label.key} size="xs" muted numberOfLines={1}>
1135
- {label.text}
1136
- </Text>
1137
- ))}
1138
- </View>
1709
+ <>
1710
+ {label ? (
1711
+ /*
1712
+ * Turned on its side, which is the only way a word fits in a gutter
1713
+ * sized for numbers. The box is laid out as tall as the plot and then
1714
+ * rotated about its own centre, so the text runs the length of the axis
1715
+ * it names rather than of whatever it happens to say.
1716
+ */
1717
+ <View
1718
+ pointerEvents="none"
1719
+ style={{
1720
+ position: 'absolute',
1721
+ left: titleWidth / 2 - plot.height / 2,
1722
+ top: plot.top + plot.height / 2 - AXIS_LABEL_HEIGHT / 2,
1723
+ width: plot.height,
1724
+ height: AXIS_LABEL_HEIGHT,
1725
+ transform: [{ rotate: '-90deg' }],
1726
+ }}
1727
+ >
1728
+ <Text size="xs" muted numberOfLines={1} style={{ textAlign: 'center' }}>
1729
+ {label}
1730
+ </Text>
1731
+ </View>
1732
+ ) : null}
1733
+ <View
1734
+ pointerEvents="none"
1735
+ style={{
1736
+ position: 'absolute',
1737
+ left: titleWidth,
1738
+ // Centred on the grid line each label names: the strip is lifted half
1739
+ // a label and grown by a whole one, so `justify-between` lands the
1740
+ // text's middle on the line rather than its top edge on the first.
1741
+ top: plot.top - AXIS_LABEL_HEIGHT / 2,
1742
+ height: plot.height + AXIS_LABEL_HEIGHT,
1743
+ width: Math.max(plot.left - Y_AXIS_GUTTER - titleWidth, 0),
1744
+ }}
1745
+ className={cn('items-end justify-between', className)}
1746
+ >
1747
+ {labels.map((tick) => (
1748
+ <Text key={tick.key} size="xs" muted numberOfLines={1}>
1749
+ {tick.text}
1750
+ </Text>
1751
+ ))}
1752
+ </View>
1753
+ </>
1139
1754
  );
1140
1755
  }
1141
1756
  BubbleChartYAxis.displayName = 'BubbleChart.YAxis';
@@ -1517,8 +2132,11 @@ BubbleChartHeader.layer = 'header' as Layer;
1517
2132
  export const BubbleChart = Object.assign(BubbleChartRoot, {
1518
2133
  Header: BubbleChartHeader,
1519
2134
  Grid: BubbleChartGrid,
2135
+ Quadrants: BubbleChartQuadrants,
2136
+ Trend: BubbleChartTrend,
1520
2137
  Bubbles: BubbleChartBubbles,
1521
2138
  Labels: BubbleChartLabels,
2139
+ SizeKey: BubbleChartSizeKey,
1522
2140
  Skeleton: BubbleChartSkeleton,
1523
2141
  XAxis: BubbleChartXAxis,
1524
2142
  YAxis: BubbleChartYAxis,