jfs-components 0.0.84 → 0.0.86
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/CHANGELOG.md +36 -0
- package/lib/commonjs/components/AllocationComparisonChart/AllocationComparisonChart.js +299 -0
- package/lib/commonjs/components/AppBar/AppBar.js +36 -22
- package/lib/commonjs/components/AreaLineChart/AreaLineChart.js +866 -0
- package/lib/commonjs/components/AreaLineChart/chartMath.js +252 -0
- package/lib/commonjs/components/Attached/Attached.js +34 -4
- package/lib/commonjs/components/BubbleChart/BubbleChart.js +191 -0
- package/lib/commonjs/components/BubbleChart/bubblePacking.js +378 -0
- package/lib/commonjs/components/ClusterBubble/ClusterBubble.js +272 -0
- package/lib/commonjs/components/FullscreenModal/FullscreenModal.js +52 -89
- package/lib/commonjs/components/MetricLegendItem/MetricLegendItem.js +7 -1
- package/lib/commonjs/components/index.js +34 -0
- package/lib/commonjs/design-tokens/Coin Variables-variables-full.json +1 -1
- package/lib/commonjs/icons/registry.js +1 -1
- package/lib/module/components/AllocationComparisonChart/AllocationComparisonChart.js +293 -0
- package/lib/module/components/AppBar/AppBar.js +36 -22
- package/lib/module/components/AreaLineChart/AreaLineChart.js +859 -0
- package/lib/module/components/AreaLineChart/chartMath.js +242 -0
- package/lib/module/components/Attached/Attached.js +34 -4
- package/lib/module/components/BubbleChart/BubbleChart.js +185 -0
- package/lib/module/components/BubbleChart/bubblePacking.js +370 -0
- package/lib/module/components/ClusterBubble/ClusterBubble.js +267 -0
- package/lib/module/components/FullscreenModal/FullscreenModal.js +53 -90
- package/lib/module/components/MetricLegendItem/MetricLegendItem.js +7 -1
- package/lib/module/components/index.js +4 -0
- package/lib/module/design-tokens/Coin Variables-variables-full.json +1 -1
- package/lib/module/icons/registry.js +1 -1
- package/lib/typescript/src/components/AllocationComparisonChart/AllocationComparisonChart.d.ts +118 -0
- package/lib/typescript/src/components/AreaLineChart/AreaLineChart.d.ts +212 -0
- package/lib/typescript/src/components/AreaLineChart/chartMath.d.ts +90 -0
- package/lib/typescript/src/components/BubbleChart/BubbleChart.d.ts +81 -0
- package/lib/typescript/src/components/BubbleChart/bubblePacking.d.ts +83 -0
- package/lib/typescript/src/components/ClusterBubble/ClusterBubble.d.ts +76 -0
- package/lib/typescript/src/components/FullscreenModal/FullscreenModal.d.ts +21 -25
- package/lib/typescript/src/components/MetricLegendItem/MetricLegendItem.d.ts +7 -1
- package/lib/typescript/src/components/index.d.ts +4 -0
- package/lib/typescript/src/icons/registry.d.ts +1 -1
- package/package.json +1 -1
- package/src/components/AllocationComparisonChart/AllocationComparisonChart.tsx +450 -0
- package/src/components/AppBar/AppBar.tsx +37 -24
- package/src/components/AreaLineChart/AreaLineChart.tsx +1161 -0
- package/src/components/AreaLineChart/chartMath.ts +265 -0
- package/src/components/Attached/Attached.tsx +36 -5
- package/src/components/BubbleChart/BubbleChart.tsx +319 -0
- package/src/components/BubbleChart/bubblePacking.ts +397 -0
- package/src/components/ClusterBubble/ClusterBubble.tsx +359 -0
- package/src/components/FullscreenModal/FullscreenModal.tsx +61 -119
- package/src/components/MetricLegendItem/MetricLegendItem.tsx +20 -6
- package/src/components/index.ts +4 -0
- package/src/design-tokens/Coin Variables-variables-full.json +1 -1
- package/src/icons/registry.ts +1 -1
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
Object.defineProperty(exports, "__esModule", {
|
|
4
|
+
value: true
|
|
5
|
+
});
|
|
6
|
+
exports.chooseLabelDirections = chooseLabelDirections;
|
|
7
|
+
exports.estimateLabelBox = estimateLabelBox;
|
|
8
|
+
exports.fitRadiiToBox = fitRadiiToBox;
|
|
9
|
+
exports.scaleRadii = scaleRadii;
|
|
10
|
+
exports.simulateCluster = simulateCluster;
|
|
11
|
+
/**
|
|
12
|
+
* Dependency-free layout math for `BubbleChart`.
|
|
13
|
+
*
|
|
14
|
+
* Instead of a rigid ring, bubbles are arranged with a small **force
|
|
15
|
+
* simulation** — think of bubbles floating in a rectangular pool: each one
|
|
16
|
+
* repels the others (collision), a gentle pull keeps the cluster balanced
|
|
17
|
+
* (gravity), and the pool walls confine everything inside the container box so
|
|
18
|
+
* nothing overflows. Radii are first `sqrt`-scaled from the data magnitudes and
|
|
19
|
+
* down-scaled to fit the available area. Finally, for bubbles whose text must
|
|
20
|
+
* sit *outside* the circle, a direction chooser picks the side (top / bottom /
|
|
21
|
+
* left / right) with the most free space so labels avoid further collisions.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
// --- Radius scaling --------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Map magnitudes to radii so a bubble's *area* scales with its value
|
|
28
|
+
* (`sqrt`-encoding), clamped into `[minRadius, maxRadius]`.
|
|
29
|
+
*/
|
|
30
|
+
function scaleRadii(magnitudes, minRadius, maxRadius) {
|
|
31
|
+
if (magnitudes.length === 0) return [];
|
|
32
|
+
const safe = magnitudes.map(m => Number.isFinite(m) && m > 0 ? m : 0);
|
|
33
|
+
let lo = Infinity;
|
|
34
|
+
let hi = -Infinity;
|
|
35
|
+
for (const m of safe) {
|
|
36
|
+
if (m < lo) lo = m;
|
|
37
|
+
if (m > hi) hi = m;
|
|
38
|
+
}
|
|
39
|
+
if (!Number.isFinite(lo) || !Number.isFinite(hi) || hi <= 0 || hi === lo) {
|
|
40
|
+
return safe.map(() => maxRadius);
|
|
41
|
+
}
|
|
42
|
+
const sqrtLo = Math.sqrt(lo);
|
|
43
|
+
const sqrtHi = Math.sqrt(hi);
|
|
44
|
+
const span = sqrtHi - sqrtLo;
|
|
45
|
+
return safe.map(m => {
|
|
46
|
+
if (m <= 0) return minRadius;
|
|
47
|
+
const t = span > 0 ? (Math.sqrt(m) - sqrtLo) / span : 1;
|
|
48
|
+
return minRadius + t * (maxRadius - minRadius);
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Uniformly shrink radii (preserving relative proportions) so the bubbles plus
|
|
54
|
+
* some breathing room and label allowance fit inside the `width × height` box.
|
|
55
|
+
* Returns the radii unchanged when they already fit.
|
|
56
|
+
*/
|
|
57
|
+
function fitRadiiToBox(radii, width, height, options = {}) {
|
|
58
|
+
const {
|
|
59
|
+
density = 0.58,
|
|
60
|
+
labelArea = 0,
|
|
61
|
+
minRadius = 6
|
|
62
|
+
} = options;
|
|
63
|
+
if (radii.length === 0 || width <= 0 || height <= 0) return radii;
|
|
64
|
+
let circleArea = 0;
|
|
65
|
+
for (const r of radii) circleArea += Math.PI * r * r;
|
|
66
|
+
const required = circleArea / density + labelArea;
|
|
67
|
+
const available = width * height;
|
|
68
|
+
if (required <= available) return radii;
|
|
69
|
+
|
|
70
|
+
// Area scales with the square of the linear factor; ignore the (non-scaling)
|
|
71
|
+
// label term in the factor for simplicity — the sim's hard clamp covers the
|
|
72
|
+
// small remainder.
|
|
73
|
+
const factor = Math.sqrt(Math.max(0.05, (available - labelArea) / circleArea) * density);
|
|
74
|
+
return radii.map(r => Math.max(minRadius, r * Math.min(1, factor)));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// --- Force simulation ------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
/** Tiny deterministic PRNG so layouts are reproducible across renders. */
|
|
80
|
+
function mulberry32(seed) {
|
|
81
|
+
let s = seed >>> 0;
|
|
82
|
+
return () => {
|
|
83
|
+
s = s + 0x6d2b79f5 >>> 0;
|
|
84
|
+
let t = s;
|
|
85
|
+
t = Math.imul(t ^ t >>> 15, t | 1);
|
|
86
|
+
t ^= t + Math.imul(t ^ t >>> 7, t | 61);
|
|
87
|
+
return ((t ^ t >>> 14) >>> 0) / 4294967296;
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Arrange circles inside a `width × height` box with a collision + gravity
|
|
93
|
+
* relaxation. Bubbles never overlap (separated by at least `gap`) and are hard-
|
|
94
|
+
* clamped within the walls so the cluster stays inside the container.
|
|
95
|
+
*/
|
|
96
|
+
function simulateCluster(radii, options) {
|
|
97
|
+
const {
|
|
98
|
+
width,
|
|
99
|
+
height,
|
|
100
|
+
gap = 8,
|
|
101
|
+
iterations = 500,
|
|
102
|
+
gravity = 0.02,
|
|
103
|
+
insetX = 0,
|
|
104
|
+
insetY = 0,
|
|
105
|
+
perimeter
|
|
106
|
+
} = options;
|
|
107
|
+
const n = radii.length;
|
|
108
|
+
const nodes = radii.map(r => ({
|
|
109
|
+
x: 0,
|
|
110
|
+
y: 0,
|
|
111
|
+
r
|
|
112
|
+
}));
|
|
113
|
+
if (n === 0) return nodes;
|
|
114
|
+
const cx = width / 2;
|
|
115
|
+
const cy = height / 2;
|
|
116
|
+
const rand = mulberry32(0x9e3779b9 ^ n * 2654435761);
|
|
117
|
+
|
|
118
|
+
// Seed positions on a phyllotaxis spiral so the relaxation starts spread out.
|
|
119
|
+
const spread = Math.min(width - 2 * insetX, height - 2 * insetY) * 0.45;
|
|
120
|
+
for (let i = 0; i < n; i++) {
|
|
121
|
+
const nd = nodes[i];
|
|
122
|
+
const a = i * 2.399963229728653; // golden angle
|
|
123
|
+
const rad = Math.sqrt((i + 0.5) / n) * spread;
|
|
124
|
+
nd.x = cx + Math.cos(a) * rad + (rand() - 0.5) * 2;
|
|
125
|
+
nd.y = cy + Math.sin(a) * rad + (rand() - 0.5) * 2;
|
|
126
|
+
}
|
|
127
|
+
const margin = gap / 2;
|
|
128
|
+
let maxR = 0;
|
|
129
|
+
for (const r of radii) if (r > maxR) maxR = r;
|
|
130
|
+
maxR = maxR || 1;
|
|
131
|
+
const clamp = () => {
|
|
132
|
+
for (const nd of nodes) {
|
|
133
|
+
const minX = insetX + nd.r + margin;
|
|
134
|
+
const maxX = Math.max(minX, width - insetX - nd.r - margin);
|
|
135
|
+
const minY = insetY + nd.r + margin;
|
|
136
|
+
const maxY = Math.max(minY, height - insetY - nd.r - margin);
|
|
137
|
+
nd.x = Math.min(maxX, Math.max(minX, nd.x));
|
|
138
|
+
nd.y = Math.min(maxY, Math.max(minY, nd.y));
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
for (let it = 0; it < iterations; it++) {
|
|
142
|
+
// Big bubbles are pulled to the center; "perimeter" bubbles (the small,
|
|
143
|
+
// outside-labelled ones) are pulled toward the wall ring so their labels
|
|
144
|
+
// land in the open band — like small bubbles floating around a big one.
|
|
145
|
+
for (let i = 0; i < n; i++) {
|
|
146
|
+
const nd = nodes[i];
|
|
147
|
+
if (perimeter && perimeter[i]) {
|
|
148
|
+
const dx = nd.x - cx;
|
|
149
|
+
const dy = nd.y - cy;
|
|
150
|
+
const d = Math.sqrt(dx * dx + dy * dy) || 1;
|
|
151
|
+
const targetD = Math.max(0, Math.min(width / 2 - insetX - nd.r - margin, height / 2 - insetY - nd.r - margin));
|
|
152
|
+
const f = (targetD - d) / d * gravity * 2;
|
|
153
|
+
nd.x += dx * f;
|
|
154
|
+
nd.y += dy * f;
|
|
155
|
+
} else {
|
|
156
|
+
const ratio = nd.r / maxR;
|
|
157
|
+
const g = gravity * (0.4 + 0.6 * ratio);
|
|
158
|
+
nd.x += (cx - nd.x) * g;
|
|
159
|
+
nd.y += (cy - nd.y) * g;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Collision: push overlapping pairs apart. Two passes per tick keeps it
|
|
164
|
+
// stable for dense clusters. Smaller bubbles move more than bigger ones.
|
|
165
|
+
for (let pass = 0; pass < 2; pass++) {
|
|
166
|
+
for (let i = 0; i < n; i++) {
|
|
167
|
+
for (let j = i + 1; j < n; j++) {
|
|
168
|
+
const a = nodes[i];
|
|
169
|
+
const b = nodes[j];
|
|
170
|
+
let dx = b.x - a.x;
|
|
171
|
+
let dy = b.y - a.y;
|
|
172
|
+
let dist = Math.sqrt(dx * dx + dy * dy);
|
|
173
|
+
const min = a.r + b.r + gap;
|
|
174
|
+
if (dist < min) {
|
|
175
|
+
if (dist < 1e-6) {
|
|
176
|
+
dx = rand() - 0.5 || 0.01;
|
|
177
|
+
dy = rand() - 0.5 || 0.01;
|
|
178
|
+
dist = Math.sqrt(dx * dx + dy * dy);
|
|
179
|
+
}
|
|
180
|
+
const overlap = min - dist;
|
|
181
|
+
const ux = dx / dist;
|
|
182
|
+
const uy = dy / dist;
|
|
183
|
+
const total = a.r + b.r;
|
|
184
|
+
const aShare = b.r / total;
|
|
185
|
+
const bShare = a.r / total;
|
|
186
|
+
a.x -= ux * overlap * aShare;
|
|
187
|
+
a.y -= uy * overlap * aShare;
|
|
188
|
+
b.x += ux * overlap * bShare;
|
|
189
|
+
b.y += uy * overlap * bShare;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
clamp();
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return nodes;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// --- Outside-label sizing + direction --------------------------------------
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Roughly estimate the rendered size of an outside label block (a bold value
|
|
203
|
+
* line stacked over a lighter caption line), used purely for layout scoring.
|
|
204
|
+
*/
|
|
205
|
+
function estimateLabelBox(value, label, options = {}) {
|
|
206
|
+
const {
|
|
207
|
+
valueFontSize = 24,
|
|
208
|
+
labelFontSize = 14
|
|
209
|
+
} = options;
|
|
210
|
+
// Slightly over-estimate width so the direction chooser reserves enough
|
|
211
|
+
// room and never picks a side that would clip the (wider) rendered text.
|
|
212
|
+
const valueW = value.length * valueFontSize * 0.68;
|
|
213
|
+
const labelW = label.length * labelFontSize * 0.6;
|
|
214
|
+
const valueH = value ? valueFontSize * 1.18 : 0;
|
|
215
|
+
const labelH = label ? labelFontSize * 1.32 : 0;
|
|
216
|
+
const gapBetween = value && label ? 2 : 0;
|
|
217
|
+
return {
|
|
218
|
+
w: Math.max(valueW, labelW, 1),
|
|
219
|
+
h: Math.max(valueH + labelH + gapBetween, 1)
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
const DIRECTIONS = ['bottom', 'right', 'left', 'top'];
|
|
223
|
+
function directionVector(dir) {
|
|
224
|
+
switch (dir) {
|
|
225
|
+
case 'top':
|
|
226
|
+
return {
|
|
227
|
+
x: 0,
|
|
228
|
+
y: -1
|
|
229
|
+
};
|
|
230
|
+
case 'bottom':
|
|
231
|
+
return {
|
|
232
|
+
x: 0,
|
|
233
|
+
y: 1
|
|
234
|
+
};
|
|
235
|
+
case 'left':
|
|
236
|
+
return {
|
|
237
|
+
x: -1,
|
|
238
|
+
y: 0
|
|
239
|
+
};
|
|
240
|
+
case 'right':
|
|
241
|
+
return {
|
|
242
|
+
x: 1,
|
|
243
|
+
y: 0
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** The rect an outside label occupies for a given direction (at `r + gap`). */
|
|
249
|
+
function labelRect(node, box, dir, gap) {
|
|
250
|
+
const {
|
|
251
|
+
x: cx,
|
|
252
|
+
y: cy,
|
|
253
|
+
r
|
|
254
|
+
} = node;
|
|
255
|
+
switch (dir) {
|
|
256
|
+
case 'bottom':
|
|
257
|
+
return {
|
|
258
|
+
x: cx - box.w / 2,
|
|
259
|
+
y: cy + r + gap,
|
|
260
|
+
w: box.w,
|
|
261
|
+
h: box.h
|
|
262
|
+
};
|
|
263
|
+
case 'top':
|
|
264
|
+
return {
|
|
265
|
+
x: cx - box.w / 2,
|
|
266
|
+
y: cy - r - gap - box.h,
|
|
267
|
+
w: box.w,
|
|
268
|
+
h: box.h
|
|
269
|
+
};
|
|
270
|
+
case 'right':
|
|
271
|
+
return {
|
|
272
|
+
x: cx + r + gap,
|
|
273
|
+
y: cy - box.h / 2,
|
|
274
|
+
w: box.w,
|
|
275
|
+
h: box.h
|
|
276
|
+
};
|
|
277
|
+
case 'left':
|
|
278
|
+
return {
|
|
279
|
+
x: cx - r - gap - box.w,
|
|
280
|
+
y: cy - box.h / 2,
|
|
281
|
+
w: box.w,
|
|
282
|
+
h: box.h
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
function rectOverlapArea(a, b) {
|
|
287
|
+
const ox = Math.max(0, Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x));
|
|
288
|
+
const oy = Math.max(0, Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y));
|
|
289
|
+
return ox * oy;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Approximate a circle as its bounding square for cheap rect/circle overlap. */
|
|
293
|
+
function rectCircleOverlap(rect, node) {
|
|
294
|
+
const circleRect = {
|
|
295
|
+
x: node.x - node.r,
|
|
296
|
+
y: node.y - node.r,
|
|
297
|
+
w: node.r * 2,
|
|
298
|
+
h: node.r * 2
|
|
299
|
+
};
|
|
300
|
+
return rectOverlapArea(rect, circleRect);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* For each node that has an outside label (`labels[i]` non-null), pick the
|
|
305
|
+
* direction whose label rect best avoids the other circles, already-placed
|
|
306
|
+
* labels, and the container walls — biased to point *outward* (away from the
|
|
307
|
+
* cluster centroid, toward free space).
|
|
308
|
+
*
|
|
309
|
+
* Returns a direction per node, or `null` for nodes without an outside label.
|
|
310
|
+
*/
|
|
311
|
+
function chooseLabelDirections(nodes, labels, options) {
|
|
312
|
+
const {
|
|
313
|
+
width,
|
|
314
|
+
height,
|
|
315
|
+
gap = 8
|
|
316
|
+
} = options;
|
|
317
|
+
const result = nodes.map(() => null);
|
|
318
|
+
if (nodes.length === 0) return result;
|
|
319
|
+
let gx = 0;
|
|
320
|
+
let gy = 0;
|
|
321
|
+
for (const nd of nodes) {
|
|
322
|
+
gx += nd.x;
|
|
323
|
+
gy += nd.y;
|
|
324
|
+
}
|
|
325
|
+
gx /= nodes.length;
|
|
326
|
+
gy /= nodes.length;
|
|
327
|
+
|
|
328
|
+
// Place outer bubbles first — they sit near free space and deserve the best slot.
|
|
329
|
+
const order = nodes.map((_, i) => i).filter(i => labels[i]).sort((a, b) => {
|
|
330
|
+
const na = nodes[a];
|
|
331
|
+
const nb = nodes[b];
|
|
332
|
+
const da = (na.x - gx) ** 2 + (na.y - gy) ** 2;
|
|
333
|
+
const db = (nb.x - gx) ** 2 + (nb.y - gy) ** 2;
|
|
334
|
+
return db - da;
|
|
335
|
+
});
|
|
336
|
+
const placed = [];
|
|
337
|
+
for (const i of order) {
|
|
338
|
+
const node = nodes[i];
|
|
339
|
+
const box = labels[i];
|
|
340
|
+
const ox = node.x - gx;
|
|
341
|
+
const oy = node.y - gy;
|
|
342
|
+
const outLen = Math.hypot(ox, oy) || 1;
|
|
343
|
+
let best = 'bottom';
|
|
344
|
+
let bestScore = Infinity;
|
|
345
|
+
for (const dir of DIRECTIONS) {
|
|
346
|
+
const rect = labelRect(node, box, dir, gap);
|
|
347
|
+
let score = 0;
|
|
348
|
+
|
|
349
|
+
// Out-of-bounds is forbidden whenever any in-bounds option exists:
|
|
350
|
+
// a clipped label is the worst possible outcome. A large fixed
|
|
351
|
+
// penalty plus a proportional term enforces this.
|
|
352
|
+
const outX = Math.max(0, -rect.x) + Math.max(0, rect.x + rect.w - width);
|
|
353
|
+
const outY = Math.max(0, -rect.y) + Math.max(0, rect.y + rect.h - height);
|
|
354
|
+
if (outX > 0 || outY > 0) score += 1_000_000 + (outX + outY) * 150;
|
|
355
|
+
|
|
356
|
+
// Overlap with other circles.
|
|
357
|
+
for (let k = 0; k < nodes.length; k++) {
|
|
358
|
+
if (k === i) continue;
|
|
359
|
+
score += rectCircleOverlap(rect, nodes[k]);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Overlap with previously-placed labels (penalized extra to spread).
|
|
363
|
+
for (const p of placed) score += rectOverlapArea(rect, p) * 2;
|
|
364
|
+
|
|
365
|
+
// Mild preference for pointing outward (toward open space).
|
|
366
|
+
const dv = directionVector(dir);
|
|
367
|
+
const align = (dv.x * ox + dv.y * oy) / outLen; // -1..1
|
|
368
|
+
score += (1 - align) * 15;
|
|
369
|
+
if (score < bestScore) {
|
|
370
|
+
bestScore = score;
|
|
371
|
+
best = dir;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
result[i] = best;
|
|
375
|
+
placed.push(labelRect(node, box, best, gap));
|
|
376
|
+
}
|
|
377
|
+
return result;
|
|
378
|
+
}
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
Object.defineProperty(exports, "__esModule", {
|
|
4
|
+
value: true
|
|
5
|
+
});
|
|
6
|
+
exports.default = void 0;
|
|
7
|
+
var _react = _interopRequireWildcard(require("react"));
|
|
8
|
+
var _reactNative = require("react-native");
|
|
9
|
+
var _figmaVariablesResolver = require("../../design-tokens/figma-variables-resolver");
|
|
10
|
+
var _JFSThemeProvider = require("../../design-tokens/JFSThemeProvider");
|
|
11
|
+
var _reactUtils = require("../../utils/react-utils");
|
|
12
|
+
var _jsxRuntime = require("react/jsx-runtime");
|
|
13
|
+
function _interopRequireWildcard(e, t) { if ("function" == typeof WeakMap) var r = new WeakMap(), n = new WeakMap(); return (_interopRequireWildcard = function (e, t) { if (!t && e && e.__esModule) return e; var o, i, f = { __proto__: null, default: e }; if (null === e || "object" != typeof e && "function" != typeof e) return f; if (o = t ? n : r) { if (o.has(e)) return o.get(e); o.set(e, f); } for (const t in e) "default" !== t && {}.hasOwnProperty.call(e, t) && ((i = (o = Object.defineProperty) && Object.getOwnPropertyDescriptor(e, t)) && (i.get || i.set) ? o(f, t, i) : f[t] = e[t]); return f; })(e, t); }
|
|
14
|
+
/** Where the value/label text sits relative to the circle. */
|
|
15
|
+
|
|
16
|
+
/** Which side of the circle an *outside* label is anchored to. */
|
|
17
|
+
|
|
18
|
+
const DEFAULT_FILL = '#5d00b5';
|
|
19
|
+
|
|
20
|
+
/** Parse `#rgb`, `#rrggbb`, `rgb()` / `rgba()` into 0–255 channels. */
|
|
21
|
+
function parseColor(input) {
|
|
22
|
+
if (typeof input !== 'string') return null;
|
|
23
|
+
const value = input.trim();
|
|
24
|
+
if (value[0] === '#') {
|
|
25
|
+
let hex = value.slice(1);
|
|
26
|
+
if (hex.length === 3) {
|
|
27
|
+
hex = hex.split('').map(ch => ch + ch).join('');
|
|
28
|
+
}
|
|
29
|
+
if (hex.length >= 6) {
|
|
30
|
+
const r = parseInt(hex.slice(0, 2), 16);
|
|
31
|
+
const g = parseInt(hex.slice(2, 4), 16);
|
|
32
|
+
const b = parseInt(hex.slice(4, 6), 16);
|
|
33
|
+
if ([r, g, b].every(n => Number.isFinite(n))) return {
|
|
34
|
+
r,
|
|
35
|
+
g,
|
|
36
|
+
b
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
const match = value.match(/rgba?\(([^)]+)\)/i);
|
|
42
|
+
if (match) {
|
|
43
|
+
const parts = match[1].split(',').map(p => parseFloat(p));
|
|
44
|
+
if (parts.length >= 3 && parts.slice(0, 3).every(n => Number.isFinite(n))) {
|
|
45
|
+
return {
|
|
46
|
+
r: parts[0],
|
|
47
|
+
g: parts[1],
|
|
48
|
+
b: parts[2]
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Pick a legible foreground (near-black or white) for a given background. */
|
|
56
|
+
function readableTextColor(background) {
|
|
57
|
+
const rgb = parseColor(background);
|
|
58
|
+
if (!rgb) return '#ffffff';
|
|
59
|
+
// Perceived luminance (ITU-R BT.601).
|
|
60
|
+
const luminance = (0.299 * rgb.r + 0.587 * rgb.g + 0.114 * rgb.b) / 255;
|
|
61
|
+
return luminance > 0.6 ? '#0f0d0a' : '#ffffff';
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* `ClusterBubble` is the atomic circle that composes a `BubbleChart`. It renders
|
|
66
|
+
* a single token-colored disc with a bold `value` and a secondary `label`. The
|
|
67
|
+
* text can sit inside the circle or anchor just outside its edge on any side
|
|
68
|
+
* (`labelDirection`) at a precise `labelGap` distance — so consumers (or the
|
|
69
|
+
* chart) can steer labels toward free space. The inside text color adapts to
|
|
70
|
+
* the fill for legibility. It is fully usable standalone.
|
|
71
|
+
*
|
|
72
|
+
* @component
|
|
73
|
+
*/
|
|
74
|
+
function ClusterBubble({
|
|
75
|
+
value,
|
|
76
|
+
label,
|
|
77
|
+
size = 120,
|
|
78
|
+
appearance = 'Primary',
|
|
79
|
+
color,
|
|
80
|
+
labelPlacement = 'auto',
|
|
81
|
+
labelDirection = 'bottom',
|
|
82
|
+
labelGap = 8,
|
|
83
|
+
autoInsideMinSize = 88,
|
|
84
|
+
insideTextColor,
|
|
85
|
+
onPress,
|
|
86
|
+
valueStyle,
|
|
87
|
+
labelStyle,
|
|
88
|
+
circleStyle,
|
|
89
|
+
style,
|
|
90
|
+
modes: propModes = _reactUtils.EMPTY_MODES,
|
|
91
|
+
accessibilityLabel
|
|
92
|
+
}) {
|
|
93
|
+
const {
|
|
94
|
+
modes: globalModes
|
|
95
|
+
} = (0, _JFSThemeProvider.useTokens)();
|
|
96
|
+
const modes = (0, _react.useMemo)(() => ({
|
|
97
|
+
...globalModes,
|
|
98
|
+
...propModes
|
|
99
|
+
}), [globalModes, propModes]);
|
|
100
|
+
|
|
101
|
+
// Emphasis is read from the `Emphasis / DataViz` mode (defaulting to the
|
|
102
|
+
// token's own default) rather than a dedicated prop.
|
|
103
|
+
const fill = (0, _react.useMemo)(() => {
|
|
104
|
+
if (color) return color;
|
|
105
|
+
return (0, _figmaVariablesResolver.getVariableByName)('dataViz/bg', {
|
|
106
|
+
...modes,
|
|
107
|
+
'Appearance / DataViz': appearance
|
|
108
|
+
}) ?? DEFAULT_FILL;
|
|
109
|
+
}, [color, modes, appearance]);
|
|
110
|
+
const fontFamily = (0, _figmaVariablesResolver.getVariableByName)('text/fontFamily', modes) ?? 'JioType';
|
|
111
|
+
const outsideTextColor = (0, _figmaVariablesResolver.getVariableByName)('text/foreground', modes) ?? '#0f0d0a';
|
|
112
|
+
const placement = labelPlacement === 'auto' ? size >= autoInsideMinSize ? 'inside' : 'outside' : labelPlacement;
|
|
113
|
+
|
|
114
|
+
// Measure the outside label so it can be anchored precisely on any side
|
|
115
|
+
// without guessing its dimensions.
|
|
116
|
+
const [labelSize, setLabelSize] = (0, _react.useState)(null);
|
|
117
|
+
const handleLabelLayout = e => {
|
|
118
|
+
const {
|
|
119
|
+
width,
|
|
120
|
+
height
|
|
121
|
+
} = e.nativeEvent.layout;
|
|
122
|
+
setLabelSize(prev => prev && Math.abs(prev.w - width) < 0.5 && Math.abs(prev.h - height) < 0.5 ? prev : {
|
|
123
|
+
w: width,
|
|
124
|
+
h: height
|
|
125
|
+
});
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
// Default typography scales with the bubble when inside (so it fits the
|
|
129
|
+
// disc); fixed comfortable sizes when anchored outside.
|
|
130
|
+
const valueFontSize = placement === 'inside' ? Math.round(Math.min(48, Math.max(13, size * 0.17))) : 24;
|
|
131
|
+
const labelFontSize = placement === 'inside' ? Math.round(Math.min(18, Math.max(10, size * 0.085))) : 14;
|
|
132
|
+
const textColor = placement === 'inside' ? insideTextColor ?? readableTextColor(fill) : outsideTextColor;
|
|
133
|
+
const renderText = (node, baseStyle, override) => {
|
|
134
|
+
if (node === undefined || node === null || node === false) return null;
|
|
135
|
+
if (typeof node === 'string' || typeof node === 'number') {
|
|
136
|
+
return /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
|
|
137
|
+
style: [baseStyle, override],
|
|
138
|
+
numberOfLines: 2,
|
|
139
|
+
children: node
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
return node;
|
|
143
|
+
};
|
|
144
|
+
const valueNode = renderText(value, {
|
|
145
|
+
color: textColor,
|
|
146
|
+
fontFamily,
|
|
147
|
+
fontSize: valueFontSize,
|
|
148
|
+
lineHeight: Math.round(valueFontSize * 1.15),
|
|
149
|
+
fontWeight: '700',
|
|
150
|
+
textAlign: 'center',
|
|
151
|
+
letterSpacing: -0.5
|
|
152
|
+
}, valueStyle);
|
|
153
|
+
const labelNode = renderText(label, {
|
|
154
|
+
color: textColor,
|
|
155
|
+
fontFamily,
|
|
156
|
+
fontSize: labelFontSize,
|
|
157
|
+
lineHeight: Math.round(labelFontSize * 1.3),
|
|
158
|
+
fontWeight: '400',
|
|
159
|
+
textAlign: 'center',
|
|
160
|
+
letterSpacing: -0.2
|
|
161
|
+
}, labelStyle);
|
|
162
|
+
const hasText = !!valueNode || !!labelNode;
|
|
163
|
+
const textBlock = hasText ? /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
|
|
164
|
+
style: styles.textBlock,
|
|
165
|
+
children: [valueNode, labelNode]
|
|
166
|
+
}) : null;
|
|
167
|
+
const derivedA11y = [value, label].filter(v => typeof v === 'string' || typeof v === 'number').join(', ');
|
|
168
|
+
const a11yLabel = accessibilityLabel ?? (derivedA11y || undefined);
|
|
169
|
+
const circle = /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
|
|
170
|
+
style: [styles.circle, {
|
|
171
|
+
width: size,
|
|
172
|
+
height: size,
|
|
173
|
+
borderRadius: size / 2,
|
|
174
|
+
backgroundColor: fill
|
|
175
|
+
}, circleStyle],
|
|
176
|
+
children: placement === 'inside' ? textBlock : null
|
|
177
|
+
});
|
|
178
|
+
let content;
|
|
179
|
+
if (placement === 'inside' || !textBlock) {
|
|
180
|
+
content = /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
|
|
181
|
+
style: [styles.inlineContainer, style],
|
|
182
|
+
children: circle
|
|
183
|
+
});
|
|
184
|
+
} else {
|
|
185
|
+
// Anchor the label exactly `labelGap` beyond the radius on the chosen
|
|
186
|
+
// side. Hidden until measured to avoid a positioning flash.
|
|
187
|
+
const offset = labelOffset(labelDirection, size, labelGap, labelSize);
|
|
188
|
+
content = /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
|
|
189
|
+
style: [{
|
|
190
|
+
width: size,
|
|
191
|
+
height: size
|
|
192
|
+
}, style],
|
|
193
|
+
children: [circle, /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
|
|
194
|
+
onLayout: handleLabelLayout,
|
|
195
|
+
style: [styles.outsideLabel, {
|
|
196
|
+
left: offset.left,
|
|
197
|
+
top: offset.top,
|
|
198
|
+
opacity: labelSize ? 1 : 0
|
|
199
|
+
}],
|
|
200
|
+
pointerEvents: "none",
|
|
201
|
+
children: textBlock
|
|
202
|
+
})]
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
if (onPress) {
|
|
206
|
+
return /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Pressable, {
|
|
207
|
+
onPress: onPress,
|
|
208
|
+
accessibilityRole: "button",
|
|
209
|
+
accessibilityLabel: a11yLabel,
|
|
210
|
+
style: ({
|
|
211
|
+
pressed
|
|
212
|
+
}) => pressed ? styles.pressed : undefined,
|
|
213
|
+
children: content
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
return /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
|
|
217
|
+
accessibilityRole: "image",
|
|
218
|
+
accessibilityLabel: a11yLabel,
|
|
219
|
+
children: content
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Compute the absolute `left/top` of the outside label box for a direction. */
|
|
224
|
+
function labelOffset(direction, size, gap, labelSize) {
|
|
225
|
+
const center = size / 2;
|
|
226
|
+
const w = labelSize?.w ?? 0;
|
|
227
|
+
const h = labelSize?.h ?? 0;
|
|
228
|
+
switch (direction) {
|
|
229
|
+
case 'top':
|
|
230
|
+
return {
|
|
231
|
+
left: center - w / 2,
|
|
232
|
+
top: -(gap + h)
|
|
233
|
+
};
|
|
234
|
+
case 'bottom':
|
|
235
|
+
return {
|
|
236
|
+
left: center - w / 2,
|
|
237
|
+
top: size + gap
|
|
238
|
+
};
|
|
239
|
+
case 'left':
|
|
240
|
+
return {
|
|
241
|
+
left: -(gap + w),
|
|
242
|
+
top: center - h / 2
|
|
243
|
+
};
|
|
244
|
+
case 'right':
|
|
245
|
+
return {
|
|
246
|
+
left: size + gap,
|
|
247
|
+
top: center - h / 2
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
const styles = _reactNative.StyleSheet.create({
|
|
252
|
+
inlineContainer: {
|
|
253
|
+
alignItems: 'center'
|
|
254
|
+
},
|
|
255
|
+
circle: {
|
|
256
|
+
alignItems: 'center',
|
|
257
|
+
justifyContent: 'center',
|
|
258
|
+
overflow: 'hidden'
|
|
259
|
+
},
|
|
260
|
+
textBlock: {
|
|
261
|
+
alignItems: 'center',
|
|
262
|
+
justifyContent: 'center',
|
|
263
|
+
paddingHorizontal: 8
|
|
264
|
+
},
|
|
265
|
+
outsideLabel: {
|
|
266
|
+
position: 'absolute'
|
|
267
|
+
},
|
|
268
|
+
pressed: {
|
|
269
|
+
opacity: 0.85
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
var _default = exports.default = ClusterBubble;
|