panelui-native 0.60.0 → 0.61.1

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.
package/src/index.ts CHANGED
@@ -956,6 +956,23 @@ export {
956
956
  type ToastIndicatorProps,
957
957
  type ToastCloseProps,
958
958
  } from './components/toast';
959
+ export {
960
+ TreemapChart,
961
+ useTreemapChart,
962
+ squarifyLayout,
963
+ type TreemapChartProps,
964
+ type TreemapChartHandle,
965
+ type TreemapChartStatus,
966
+ type TreemapDatum,
967
+ type TreemapTile,
968
+ type TreemapRect,
969
+ type TreemapChartTilesProps,
970
+ type TreemapChartLabelsProps,
971
+ type TreemapChartTooltipProps,
972
+ type TreemapChartLegendProps,
973
+ type TreemapChartHeaderProps,
974
+ type TreemapChartSkeletonProps,
975
+ } from './components/treemap-chart';
959
976
  export {
960
977
  Tree,
961
978
  type TreeProps,
@@ -36,12 +36,166 @@ export function useSeriesColor(
36
36
  return explicit ?? (typeof token === 'string' ? token : FALLBACK_SERIES[index - 1]!);
37
37
  }
38
38
 
39
- /** `12.4k` rather than `12400` — a readout has one line to say it in. */
39
+ /**
40
+ * The `r, g, b` of a colour string, or `null` for one that cannot be read.
41
+ *
42
+ * Hex and `rgb()` / `rgba()` only, which is every form a theme token and a
43
+ * series colour take. A `null` is the caller's signal to keep whatever it
44
+ * would have used anyway, rather than a guess that could be the wrong end of
45
+ * the scale.
46
+ */
47
+ function channelsOf(color: string): [number, number, number] | null {
48
+ const value = color.trim().toLowerCase();
49
+
50
+ if (value.startsWith('#')) {
51
+ const body =
52
+ value.length === 4 || value.length === 5
53
+ ? value
54
+ .slice(1, 4)
55
+ .split('')
56
+ .map((digit) => digit + digit)
57
+ .join('')
58
+ : value.slice(1, 7);
59
+ if (body.length !== 6 || /[^0-9a-f]/.test(body)) return null;
60
+ return [
61
+ parseInt(body.slice(0, 2), 16),
62
+ parseInt(body.slice(2, 4), 16),
63
+ parseInt(body.slice(4, 6), 16),
64
+ ];
65
+ }
66
+
67
+ const fn = /^rgba?\(([^)]+)\)$/.exec(value);
68
+ if (!fn) return null;
69
+
70
+ const parts = fn[1]!.split(/[\s,/]+/).filter(Boolean);
71
+ if (parts.length < 3) return null;
72
+
73
+ const channels = parts.slice(0, 3).map((raw) => {
74
+ const n = parseFloat(raw);
75
+ if (Number.isNaN(n)) return null;
76
+ return raw.endsWith('%') ? (n / 100) * 255 : n;
77
+ });
78
+
79
+ return channels.some((c) => c === null)
80
+ ? null
81
+ : (channels as [number, number, number]);
82
+ }
83
+
84
+ /** One channel, undone from the sRGB transfer curve. */
85
+ function linear(value: number): number {
86
+ const c = Math.min(1, Math.max(0, value / 255));
87
+ return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
88
+ }
89
+
90
+ /** Relative luminance, 0 for black and 1 for white. */
91
+ function luminanceOf(rgb: [number, number, number]): number {
92
+ return 0.2126 * linear(rgb[0]) + 0.7152 * linear(rgb[1]) + 0.0722 * linear(rgb[2]);
93
+ }
94
+
95
+ /** Text laid over a fill: the solid colour and the softer one under it. */
96
+ export interface ChartInk {
97
+ color: string;
98
+ muted: string;
99
+ }
100
+
101
+ const LIGHT_INK: ChartInk = { color: '#ffffff', muted: 'rgba(255,255,255,0.82)' };
102
+ const DARK_INK: ChartInk = { color: '#0a0a0a', muted: 'rgba(10,10,10,0.82)' };
103
+
104
+ /**
105
+ * The contrast ratio white keeps on a fill before the label is turned over.
106
+ *
107
+ * 3:1, which is the bar for bold text at this size. Higher and the mid-tone
108
+ * hues every theme uses for its second series onwards flip to dark text as
109
+ * well, which is a different chart rather than a legible one; lower and the
110
+ * pale fills this exists for stay unreadable.
111
+ */
112
+ const INK_FLOOR = 3;
113
+
114
+ /**
115
+ * The text colour to lay over `fill`: white while white can be read on it,
116
+ * near-black once it cannot.
117
+ *
118
+ * Neither the fill nor the theme can decide this on its own. A series colour is
119
+ * a colour, not a mode — a theme is free to make it near-white, and a fixed
120
+ * white label on it disappears while the foreground token would be just as
121
+ * wrong on the next tile along.
122
+ *
123
+ * White is kept wherever it holds up, rather than always taking whichever ink
124
+ * scores higher. Charts are drawn white-on-hue, and turning over every tile
125
+ * that a darker label would technically suit changes what the chart looks like
126
+ * to fix the two that were unreadable.
127
+ *
128
+ * `behind` and `opacity` are for a fill drawn part-transparent: what the reader
129
+ * sees is the blend, and a half-strength tile on a pale ground is lighter than
130
+ * the colour it was given. Compositing happens in gamma space, the way the
131
+ * renderer does it, before the luminance is taken.
132
+ */
133
+ export function inkOn(fill: string, behind?: string, opacity = 1): ChartInk {
134
+ const front = channelsOf(fill);
135
+ if (!front) return LIGHT_INK;
136
+
137
+ const alpha = Math.min(1, Math.max(0, opacity));
138
+ const ground = alpha < 1 && behind ? channelsOf(behind) : null;
139
+ const seen: [number, number, number] = ground
140
+ ? [
141
+ alpha * front[0] + (1 - alpha) * ground[0],
142
+ alpha * front[1] + (1 - alpha) * ground[1],
143
+ alpha * front[2] + (1 - alpha) * ground[2],
144
+ ]
145
+ : front;
146
+
147
+ return 1.05 / (luminanceOf(seen) + 0.05) >= INK_FLOOR ? LIGHT_INK : DARK_INK;
148
+ }
149
+
150
+ /** The suffixes, smallest first. Each one is a thousand of the one before it. */
151
+ const MAGNITUDES = ['k', 'M', 'B', 'T'] as const;
152
+
153
+ /** Drop the zeros a fixed decimal count left behind: `12.0` is `12`. */
154
+ function trimZeros(text: string): string {
155
+ return text.includes('.') ? text.replace(/\.?0+$/, '') : text;
156
+ }
157
+
158
+ /**
159
+ * One decimal while the mantissa is a single digit, none after that.
160
+ *
161
+ * `1.2k` and `12k` rather than `1.2k` and `12.4k`: the second decimal place is
162
+ * spurious precision on a tick, and the label has one line to fit in.
163
+ */
164
+ function mantissa(value: number): string {
165
+ return trimZeros(value.toFixed(Math.abs(value) < 10 ? 1 : 0));
166
+ }
167
+
168
+ /** `12k` rather than `12400` — a readout has one line to say it in. */
40
169
  export function compactNumber(value: number): string {
170
+ if (!Number.isFinite(value)) return String(value);
171
+
41
172
  const abs = Math.abs(value);
42
- if (abs >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`;
43
- if (abs >= 1_000) return `${(value / 1_000).toFixed(1)}k`;
44
- return Number.isInteger(value) ? String(value) : value.toFixed(1);
173
+ if (abs < 1_000) {
174
+ if (Number.isInteger(value)) return String(value);
175
+ // Below one, a single decimal rounds most values to `0`, which reads as
176
+ // nothing rather than as a small number.
177
+ return trimZeros(value.toFixed(abs < 1 ? 2 : 1));
178
+ }
179
+
180
+ let index = 0;
181
+ let scaled = value / 1_000;
182
+ while (Math.abs(scaled) >= 1_000 && index < MAGNITUDES.length - 1) {
183
+ scaled /= 1_000;
184
+ index += 1;
185
+ }
186
+
187
+ /*
188
+ * Rounding can push a mantissa over a thousand — 999,999 is `1000.0k` on the
189
+ * way in, which is a thousand thousands written the long way. Formatting
190
+ * first and promoting after is what makes it `1M`.
191
+ */
192
+ let text = mantissa(scaled);
193
+ if (Math.abs(Number(text)) >= 1_000 && index < MAGNITUDES.length - 1) {
194
+ index += 1;
195
+ text = mantissa(scaled / 1_000);
196
+ }
197
+
198
+ return `${text}${MAGNITUDES[index]}`;
45
199
  }
46
200
 
47
201
  /** The drawable box inside a chart, after its padding is taken off. */