@jcsoftdev/ui 0.1.1 → 0.1.2
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/dist/charts/gauge-chart/gauge-chart.js +50 -24
- package/dist/charts/gauge-chart/gauge-layout.d.ts +46 -0
- package/dist/charts/gauge-chart/gauge-layout.js +110 -0
- package/package.json +1 -1
- package/src/charts/gauge-chart/gauge-chart.tsx +71 -46
- package/src/charts/gauge-chart/gauge-layout.ts +183 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
3
|
-
import { memo, useCallback, useMemo, useRef } from "react";
|
|
3
|
+
import { memo, useCallback, useId, useMemo, useRef } from "react";
|
|
4
4
|
import { cn } from "../../utils/cn.js";
|
|
5
5
|
import { DECORATIVE } from "../core/a11y.js";
|
|
6
6
|
import { ChartFrame } from "../core/chart-frame.js";
|
|
@@ -12,6 +12,7 @@ import { arcLine, degToRad, polarPoint, radToDeg } from "../core/polar.js";
|
|
|
12
12
|
import { ChartTooltip } from "../core/tooltip.js";
|
|
13
13
|
import { resolveLabels } from "../core/types.js";
|
|
14
14
|
import { useIndexNavigation } from "../core/use-index-navigation.js";
|
|
15
|
+
import { layoutGauge, showEndLabels } from "./gauge-layout.js";
|
|
15
16
|
const BASE_TONE = "accent";
|
|
16
17
|
function rangeColor(range) {
|
|
17
18
|
return range.color ?? TONE_COLORS[range.tone ?? BASE_TONE].ink;
|
|
@@ -140,18 +141,22 @@ function GaugePlot({
|
|
|
140
141
|
const a1 = degToRad(half);
|
|
141
142
|
const span = a1 - a0;
|
|
142
143
|
const needle = mode === "needle";
|
|
143
|
-
const
|
|
144
|
-
const
|
|
145
|
-
const
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
144
|
+
const endLabels = showEndLabels(minMaxLabels, ticks);
|
|
145
|
+
const uid = useId();
|
|
146
|
+
const valueChars = Math.max(format(min).length, format(max).length, format(value).length);
|
|
147
|
+
const layout = layoutGauge({
|
|
148
|
+
width,
|
|
149
|
+
height,
|
|
150
|
+
variant,
|
|
151
|
+
mode,
|
|
152
|
+
thickness,
|
|
153
|
+
endLabels,
|
|
154
|
+
customCenter: Boolean(centerContent),
|
|
155
|
+
caption: Boolean(caption),
|
|
156
|
+
bandLabel: bands.length > 0,
|
|
157
|
+
valueChars
|
|
158
|
+
});
|
|
159
|
+
const { radius, band, cx, cy, mid, valueSize } = layout;
|
|
155
160
|
const toFraction = (v) => Math.min(1, Math.max(0, (v - min) / (max - min)));
|
|
156
161
|
const angleFor = useCallback(
|
|
157
162
|
(v) => a0 + span * Math.min(1, Math.max(0, (v - min) / (max - min))),
|
|
@@ -210,10 +215,10 @@ function GaugePlot({
|
|
|
210
215
|
},
|
|
211
216
|
{ disabled: !animate }
|
|
212
217
|
);
|
|
213
|
-
const valueSize = Math.round(Math.min(40, Math.max(20, radius * 0.34)));
|
|
214
218
|
const captionSize = 12;
|
|
215
|
-
const valueY =
|
|
216
|
-
const captionY =
|
|
219
|
+
const valueY = cy + layout.valueDy;
|
|
220
|
+
const captionY = cy + layout.captionDy;
|
|
221
|
+
const labelY = cy + layout.labelDy;
|
|
217
222
|
const focused = nav.active != null ? nav.active : null;
|
|
218
223
|
const focusedBand = bands.length > 0 && focused != null ? bands[focused] : void 0;
|
|
219
224
|
const rows = focusedBand ? [
|
|
@@ -250,6 +255,27 @@ function GaugePlot({
|
|
|
250
255
|
height,
|
|
251
256
|
className: "block overflow-visible",
|
|
252
257
|
children: [
|
|
258
|
+
roundedCaps && bands.length > 0 ? /* @__PURE__ */ jsx("defs", { children: /* @__PURE__ */ jsx(
|
|
259
|
+
"mask",
|
|
260
|
+
{
|
|
261
|
+
id: `${uid}-track`,
|
|
262
|
+
maskUnits: "userSpaceOnUse",
|
|
263
|
+
x: 0,
|
|
264
|
+
y: 0,
|
|
265
|
+
width,
|
|
266
|
+
height,
|
|
267
|
+
children: /* @__PURE__ */ jsx(
|
|
268
|
+
"path",
|
|
269
|
+
{
|
|
270
|
+
d: arcLine(cx, cy, mid, a0 + capAngle, a1 - capAngle),
|
|
271
|
+
fill: "none",
|
|
272
|
+
stroke: "white",
|
|
273
|
+
strokeWidth: band,
|
|
274
|
+
strokeLinecap: "round"
|
|
275
|
+
}
|
|
276
|
+
)
|
|
277
|
+
}
|
|
278
|
+
) }) : null,
|
|
253
279
|
bands.length === 0 ? /* @__PURE__ */ jsx(
|
|
254
280
|
"path",
|
|
255
281
|
{
|
|
@@ -259,7 +285,7 @@ function GaugePlot({
|
|
|
259
285
|
strokeLinecap: roundedCaps ? "round" : "butt",
|
|
260
286
|
style: { stroke: "var(--chart-grid)" }
|
|
261
287
|
}
|
|
262
|
-
) : bandPaths.map((path, index) => {
|
|
288
|
+
) : /* @__PURE__ */ jsx("g", { mask: roundedCaps ? `url(#${uid}-track)` : void 0, children: bandPaths.map((path, index) => {
|
|
263
289
|
const item = bands[index];
|
|
264
290
|
if (!item || !path) return null;
|
|
265
291
|
return /* @__PURE__ */ jsx(
|
|
@@ -275,7 +301,7 @@ function GaugePlot({
|
|
|
275
301
|
},
|
|
276
302
|
index
|
|
277
303
|
);
|
|
278
|
-
}),
|
|
304
|
+
}) }),
|
|
279
305
|
!needle && fraction > 0 ? /* @__PURE__ */ jsx(
|
|
280
306
|
"path",
|
|
281
307
|
{
|
|
@@ -325,7 +351,7 @@ function GaugePlot({
|
|
|
325
351
|
/* @__PURE__ */ jsx(
|
|
326
352
|
"path",
|
|
327
353
|
{
|
|
328
|
-
d: `M${cx - 4} ${cy} L${cx} ${cy -
|
|
354
|
+
d: `M${cx - 4} ${cy} L${cx} ${cy - layout.needleLength} L${cx + 4} ${cy} Z`,
|
|
329
355
|
style: { fill: "var(--ink)" }
|
|
330
356
|
}
|
|
331
357
|
),
|
|
@@ -334,7 +360,7 @@ function GaugePlot({
|
|
|
334
360
|
]
|
|
335
361
|
}
|
|
336
362
|
) : null,
|
|
337
|
-
|
|
363
|
+
endLabels ? /* @__PURE__ */ jsxs("g", { ...DECORATIVE, className: "pointer-events-none", children: [
|
|
338
364
|
/* @__PURE__ */ jsx(
|
|
339
365
|
"text",
|
|
340
366
|
{
|
|
@@ -381,7 +407,7 @@ function GaugePlot({
|
|
|
381
407
|
"text",
|
|
382
408
|
{
|
|
383
409
|
x: cx,
|
|
384
|
-
y:
|
|
410
|
+
y: labelY,
|
|
385
411
|
className: "font-bold text-[11px]",
|
|
386
412
|
style: { fill: valueColor },
|
|
387
413
|
children: active.label
|
|
@@ -397,9 +423,9 @@ function GaugePlot({
|
|
|
397
423
|
className: "pointer-events-none absolute flex flex-col items-center text-center",
|
|
398
424
|
style: {
|
|
399
425
|
left: cx,
|
|
400
|
-
top:
|
|
401
|
-
width:
|
|
402
|
-
transform:
|
|
426
|
+
top: cy + layout.customDy,
|
|
427
|
+
width: layout.customWidth,
|
|
428
|
+
transform: layout.customAnchor === "above" ? "translate(-50%, -100%)" : layout.customAnchor === "middle" ? "translate(-50%, -50%)" : "translate(-50%, 0)"
|
|
403
429
|
},
|
|
404
430
|
children: centerContent({
|
|
405
431
|
value,
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
export type GaugeVariant = "semi" | "three-quarter";
|
|
2
|
+
export type GaugeMode = "progress" | "needle";
|
|
3
|
+
export interface GaugeLayoutInput {
|
|
4
|
+
width: number;
|
|
5
|
+
height: number;
|
|
6
|
+
variant: GaugeVariant;
|
|
7
|
+
mode: GaugeMode;
|
|
8
|
+
thickness: number;
|
|
9
|
+
/** Min and max printed under the arc ends. */
|
|
10
|
+
endLabels: boolean;
|
|
11
|
+
/** The value block is custom HTML of unknown size. */
|
|
12
|
+
customCenter: boolean;
|
|
13
|
+
caption: boolean;
|
|
14
|
+
bandLabel: boolean;
|
|
15
|
+
/** Longest formatted value, in characters; sizes the value block width. */
|
|
16
|
+
valueChars: number;
|
|
17
|
+
}
|
|
18
|
+
export interface GaugeLayout {
|
|
19
|
+
radius: number;
|
|
20
|
+
/** Stroke width of the track and value arc. */
|
|
21
|
+
band: number;
|
|
22
|
+
cx: number;
|
|
23
|
+
cy: number;
|
|
24
|
+
/** Radius of the arc centre line. */
|
|
25
|
+
mid: number;
|
|
26
|
+
valueSize: number;
|
|
27
|
+
/** Distance from the centre to the top of the value block. */
|
|
28
|
+
blockTop: number;
|
|
29
|
+
/** Baselines relative to the centre. */
|
|
30
|
+
valueDy: number;
|
|
31
|
+
captionDy: number;
|
|
32
|
+
labelDy: number;
|
|
33
|
+
/** Where the custom centre block is anchored, relative to the centre. */
|
|
34
|
+
customAnchor: "above" | "below" | "middle";
|
|
35
|
+
customDy: number;
|
|
36
|
+
customWidth: number;
|
|
37
|
+
/** Needle reach from the hub; stops at the arc. */
|
|
38
|
+
needleLength: number;
|
|
39
|
+
}
|
|
40
|
+
/** Min/max labels duplicate the tick labels at the arc ends, so ticks win. */
|
|
41
|
+
export declare function showEndLabels(minMaxLabels: boolean, ticks: number): boolean;
|
|
42
|
+
/**
|
|
43
|
+
* Largest arc that fits the box in both directions, with the value block
|
|
44
|
+
* placed where the needle and hub never reach it.
|
|
45
|
+
*/
|
|
46
|
+
export declare function layoutGauge(input: GaugeLayoutInput): GaugeLayout;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
const PAD = 12;
|
|
2
|
+
const HUB_RADIUS = 8;
|
|
3
|
+
const MIN_RADIUS = 40;
|
|
4
|
+
const CAPTION_SIZE = 12;
|
|
5
|
+
const CUSTOM_BLOCK_HEIGHT = 72;
|
|
6
|
+
const SIN_45 = Math.sin(Math.PI / 4);
|
|
7
|
+
function showEndLabels(minMaxLabels, ticks) {
|
|
8
|
+
return minMaxLabels && !(ticks >= 1);
|
|
9
|
+
}
|
|
10
|
+
function clamp(value, lo, hi) {
|
|
11
|
+
return Math.min(hi, Math.max(lo, value));
|
|
12
|
+
}
|
|
13
|
+
const GLYPH_WIDTH = 0.6;
|
|
14
|
+
function valueSizeFor(input, radius, band) {
|
|
15
|
+
const bySize = clamp(radius * 0.34, 20, 52);
|
|
16
|
+
if (input.customCenter || input.valueChars <= 0) return Math.round(bySize);
|
|
17
|
+
const inner = input.mode === "needle" ? radius * 2 : (radius - band) * 2 - 16;
|
|
18
|
+
const byWidth = inner / (input.valueChars * GLYPH_WIDTH);
|
|
19
|
+
return Math.max(12, Math.min(Math.round(bySize), Math.floor(byWidth)));
|
|
20
|
+
}
|
|
21
|
+
function blockFor(input, radius, valueSize) {
|
|
22
|
+
const { variant, mode, customCenter } = input;
|
|
23
|
+
const halfWidth = customCenter ? radius * 0.35 : input.valueChars * GLYPH_WIDTH * valueSize / 2;
|
|
24
|
+
const needle = mode === "needle";
|
|
25
|
+
let top;
|
|
26
|
+
if (needle) {
|
|
27
|
+
top = variant === "semi" ? HUB_RADIUS + 14 : Math.max(HUB_RADIUS + 14, halfWidth + 12);
|
|
28
|
+
} else if (variant === "semi") {
|
|
29
|
+
top = customCenter ? -(CUSTOM_BLOCK_HEIGHT + 6) : -(valueSize * 0.9 + 6);
|
|
30
|
+
} else {
|
|
31
|
+
top = customCenter ? -CUSTOM_BLOCK_HEIGHT / 2 : -valueSize * 0.5;
|
|
32
|
+
}
|
|
33
|
+
const gap = needle ? 6 : 8;
|
|
34
|
+
const valueDy = top + valueSize * 0.85;
|
|
35
|
+
const captionDy = valueDy + gap + CAPTION_SIZE;
|
|
36
|
+
const withCaption = input.caption ? captionDy : valueDy;
|
|
37
|
+
const labelDy = withCaption + (input.caption || !needle ? 16 : 14);
|
|
38
|
+
const last = input.bandLabel ? labelDy : withCaption;
|
|
39
|
+
const bottom = customCenter ? top + CUSTOM_BLOCK_HEIGHT : last + 4;
|
|
40
|
+
return { top, valueDy, captionDy, labelDy, bottom };
|
|
41
|
+
}
|
|
42
|
+
function below(input, radius, band) {
|
|
43
|
+
const valueSize = valueSizeFor(input, radius, band);
|
|
44
|
+
const mid = radius - band / 2;
|
|
45
|
+
const arcBottom = input.variant === "semi" ? band / 2 : mid * SIN_45 + band / 2;
|
|
46
|
+
const withLabels = arcBottom + (input.endLabels ? 19 : 2);
|
|
47
|
+
const block = blockFor(input, radius, valueSize).bottom;
|
|
48
|
+
const needleHub = input.mode === "needle" ? HUB_RADIUS : 0;
|
|
49
|
+
return Math.max(withLabels, block, needleHub);
|
|
50
|
+
}
|
|
51
|
+
function layoutGauge(input) {
|
|
52
|
+
const { width, height, thickness } = input;
|
|
53
|
+
const maxByWidth = width / 2 - PAD - (input.endLabels ? 6 : 0);
|
|
54
|
+
const fits = (radius2) => {
|
|
55
|
+
const band2 = Math.min(thickness, radius2 * 0.4);
|
|
56
|
+
return radius2 + below(input, radius2, band2) + PAD * 2 <= height;
|
|
57
|
+
};
|
|
58
|
+
let radius = MIN_RADIUS;
|
|
59
|
+
if (maxByWidth > MIN_RADIUS) {
|
|
60
|
+
if (fits(maxByWidth)) {
|
|
61
|
+
radius = maxByWidth;
|
|
62
|
+
} else {
|
|
63
|
+
let lo = MIN_RADIUS;
|
|
64
|
+
let hi = maxByWidth;
|
|
65
|
+
for (let i = 0; i < 30; i++) {
|
|
66
|
+
const probe = (lo + hi) / 2;
|
|
67
|
+
if (fits(probe)) lo = probe;
|
|
68
|
+
else hi = probe;
|
|
69
|
+
}
|
|
70
|
+
radius = lo;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const band = Math.min(thickness, radius * 0.4);
|
|
74
|
+
const mid = radius - band / 2;
|
|
75
|
+
const valueSize = valueSizeFor(input, radius, band);
|
|
76
|
+
const block = blockFor(input, radius, valueSize);
|
|
77
|
+
const extent = radius + below(input, radius, band);
|
|
78
|
+
const cx = width / 2;
|
|
79
|
+
const cy = Math.max(PAD + radius, (height - extent) / 2 + radius);
|
|
80
|
+
const needle = input.mode === "needle";
|
|
81
|
+
const customAnchor = needle || input.variant === "three-quarter" ? "below" : "above";
|
|
82
|
+
const customWidth = Math.max(40, radius * 1.5);
|
|
83
|
+
let customDy = block.top;
|
|
84
|
+
let anchor = customAnchor;
|
|
85
|
+
if (!needle && input.variant === "semi") customDy = -6;
|
|
86
|
+
else if (!needle) {
|
|
87
|
+
anchor = "middle";
|
|
88
|
+
customDy = 0;
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
radius,
|
|
92
|
+
band,
|
|
93
|
+
cx,
|
|
94
|
+
cy,
|
|
95
|
+
mid,
|
|
96
|
+
valueSize,
|
|
97
|
+
blockTop: block.top,
|
|
98
|
+
valueDy: block.valueDy,
|
|
99
|
+
captionDy: block.captionDy,
|
|
100
|
+
labelDy: block.labelDy,
|
|
101
|
+
customAnchor: anchor,
|
|
102
|
+
customDy,
|
|
103
|
+
customWidth,
|
|
104
|
+
needleLength: mid + band * 0.3
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
export {
|
|
108
|
+
layoutGauge,
|
|
109
|
+
showEndLabels
|
|
110
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { memo, type ReactNode, useCallback, useMemo, useRef } from "react";
|
|
3
|
+
import { memo, type ReactNode, useCallback, useId, useMemo, useRef } from "react";
|
|
4
4
|
import { cn } from "../../utils/cn.js";
|
|
5
5
|
import { DECORATIVE } from "../core/a11y.js";
|
|
6
6
|
import { ChartFrame } from "../core/chart-frame.js";
|
|
@@ -12,6 +12,7 @@ import { arcLine, degToRad, polarPoint, radToDeg } from "../core/polar.js";
|
|
|
12
12
|
import { ChartTooltip } from "../core/tooltip.js";
|
|
13
13
|
import { type ChartLabels, resolveLabels } from "../core/types.js";
|
|
14
14
|
import { useIndexNavigation } from "../core/use-index-navigation.js";
|
|
15
|
+
import { layoutGauge, showEndLabels } from "./gauge-layout.js";
|
|
15
16
|
|
|
16
17
|
export interface GaugeRange {
|
|
17
18
|
from: number;
|
|
@@ -238,18 +239,22 @@ function GaugePlot({
|
|
|
238
239
|
const a1 = degToRad(half);
|
|
239
240
|
const span = a1 - a0;
|
|
240
241
|
const needle = mode === "needle";
|
|
241
|
-
const
|
|
242
|
-
const
|
|
243
|
-
const
|
|
244
|
-
const
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
242
|
+
const endLabels = showEndLabels(minMaxLabels, ticks);
|
|
243
|
+
const uid = useId();
|
|
244
|
+
const valueChars = Math.max(format(min).length, format(max).length, format(value).length);
|
|
245
|
+
const layout = layoutGauge({
|
|
246
|
+
width,
|
|
247
|
+
height,
|
|
248
|
+
variant,
|
|
249
|
+
mode,
|
|
250
|
+
thickness,
|
|
251
|
+
endLabels,
|
|
252
|
+
customCenter: Boolean(centerContent),
|
|
253
|
+
caption: Boolean(caption),
|
|
254
|
+
bandLabel: bands.length > 0,
|
|
255
|
+
valueChars,
|
|
256
|
+
});
|
|
257
|
+
const { radius, band, cx, cy, mid, valueSize } = layout;
|
|
253
258
|
|
|
254
259
|
const toFraction = (v: number) => Math.min(1, Math.max(0, (v - min) / (max - min)));
|
|
255
260
|
const angleFor = useCallback(
|
|
@@ -317,14 +322,10 @@ function GaugePlot({
|
|
|
317
322
|
);
|
|
318
323
|
|
|
319
324
|
// Value text ---------------------------------------------------------------
|
|
320
|
-
const valueSize = Math.round(Math.min(40, Math.max(20, radius * 0.34)));
|
|
321
325
|
const captionSize = 12;
|
|
322
|
-
const valueY =
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
? cy - 6
|
|
326
|
-
: cy + valueSize * 0.3;
|
|
327
|
-
const captionY = needle ? valueY + 6 + captionSize : valueY + 8 + captionSize;
|
|
326
|
+
const valueY = cy + layout.valueDy;
|
|
327
|
+
const captionY = cy + layout.captionDy;
|
|
328
|
+
const labelY = cy + layout.labelDy;
|
|
328
329
|
|
|
329
330
|
// Tooltip -----------------------------------------------------------------
|
|
330
331
|
const focused = nav.active != null ? nav.active : null;
|
|
@@ -376,6 +377,27 @@ function GaugePlot({
|
|
|
376
377
|
height={height}
|
|
377
378
|
className="block overflow-visible"
|
|
378
379
|
>
|
|
380
|
+
{roundedCaps && bands.length > 0 ? (
|
|
381
|
+
<defs>
|
|
382
|
+
<mask
|
|
383
|
+
id={`${uid}-track`}
|
|
384
|
+
maskUnits="userSpaceOnUse"
|
|
385
|
+
x={0}
|
|
386
|
+
y={0}
|
|
387
|
+
width={width}
|
|
388
|
+
height={height}
|
|
389
|
+
>
|
|
390
|
+
<path
|
|
391
|
+
d={arcLine(cx, cy, mid, a0 + capAngle, a1 - capAngle)}
|
|
392
|
+
fill="none"
|
|
393
|
+
stroke="white"
|
|
394
|
+
strokeWidth={band}
|
|
395
|
+
strokeLinecap="round"
|
|
396
|
+
/>
|
|
397
|
+
</mask>
|
|
398
|
+
</defs>
|
|
399
|
+
) : null}
|
|
400
|
+
|
|
379
401
|
{bands.length === 0 ? (
|
|
380
402
|
<path
|
|
381
403
|
d={arcLine(cx, cy, mid, a0 + capAngle, a1 - capAngle)}
|
|
@@ -385,23 +407,25 @@ function GaugePlot({
|
|
|
385
407
|
style={{ stroke: "var(--chart-grid)" }}
|
|
386
408
|
/>
|
|
387
409
|
) : (
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
410
|
+
<g mask={roundedCaps ? `url(#${uid}-track)` : undefined}>
|
|
411
|
+
{bandPaths.map((path, index) => {
|
|
412
|
+
const item = bands[index];
|
|
413
|
+
if (!item || !path) return null;
|
|
414
|
+
return (
|
|
415
|
+
<BandMark
|
|
416
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: bands are positional and static.
|
|
417
|
+
key={index}
|
|
418
|
+
d={path}
|
|
419
|
+
width={band}
|
|
420
|
+
color={rangeColor(item)}
|
|
421
|
+
opacity={needle ? 0.92 : 0.26}
|
|
422
|
+
dimmed={focused != null && focused !== index}
|
|
423
|
+
onEnter={(event) => nav.hover(index, event)}
|
|
424
|
+
onLeave={nav.leave}
|
|
425
|
+
/>
|
|
426
|
+
);
|
|
427
|
+
})}
|
|
428
|
+
</g>
|
|
405
429
|
)}
|
|
406
430
|
|
|
407
431
|
{!needle && fraction > 0 ? (
|
|
@@ -451,7 +475,7 @@ function GaugePlot({
|
|
|
451
475
|
}}
|
|
452
476
|
>
|
|
453
477
|
<path
|
|
454
|
-
d={`M${cx - 4} ${cy} L${cx} ${cy -
|
|
478
|
+
d={`M${cx - 4} ${cy} L${cx} ${cy - layout.needleLength} L${cx + 4} ${cy} Z`}
|
|
455
479
|
style={{ fill: "var(--ink)" }}
|
|
456
480
|
/>
|
|
457
481
|
<circle cx={cx} cy={cy} r={8} style={{ fill: "var(--ink)" }} />
|
|
@@ -459,7 +483,7 @@ function GaugePlot({
|
|
|
459
483
|
</g>
|
|
460
484
|
) : null}
|
|
461
485
|
|
|
462
|
-
{
|
|
486
|
+
{endLabels ? (
|
|
463
487
|
<g {...DECORATIVE} className="pointer-events-none">
|
|
464
488
|
<text
|
|
465
489
|
x={minPoint.x}
|
|
@@ -503,7 +527,7 @@ function GaugePlot({
|
|
|
503
527
|
{active?.label && bands.length > 0 ? (
|
|
504
528
|
<text
|
|
505
529
|
x={cx}
|
|
506
|
-
y={
|
|
530
|
+
y={labelY}
|
|
507
531
|
className="font-bold text-[11px]"
|
|
508
532
|
style={{ fill: valueColor }}
|
|
509
533
|
>
|
|
@@ -519,13 +543,14 @@ function GaugePlot({
|
|
|
519
543
|
className="pointer-events-none absolute flex flex-col items-center text-center"
|
|
520
544
|
style={{
|
|
521
545
|
left: cx,
|
|
522
|
-
top:
|
|
523
|
-
width:
|
|
524
|
-
transform:
|
|
525
|
-
|
|
526
|
-
: variant === "semi"
|
|
546
|
+
top: cy + layout.customDy,
|
|
547
|
+
width: layout.customWidth,
|
|
548
|
+
transform:
|
|
549
|
+
layout.customAnchor === "above"
|
|
527
550
|
? "translate(-50%, -100%)"
|
|
528
|
-
:
|
|
551
|
+
: layout.customAnchor === "middle"
|
|
552
|
+
? "translate(-50%, -50%)"
|
|
553
|
+
: "translate(-50%, 0)",
|
|
529
554
|
}}
|
|
530
555
|
>
|
|
531
556
|
{centerContent({
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
export type GaugeVariant = "semi" | "three-quarter";
|
|
2
|
+
export type GaugeMode = "progress" | "needle";
|
|
3
|
+
|
|
4
|
+
const PAD = 12;
|
|
5
|
+
const HUB_RADIUS = 8;
|
|
6
|
+
const MIN_RADIUS = 40;
|
|
7
|
+
const CAPTION_SIZE = 12;
|
|
8
|
+
/** Height of a custom centre block (`centerContent`), which cannot be measured here. */
|
|
9
|
+
const CUSTOM_BLOCK_HEIGHT = 72;
|
|
10
|
+
const SIN_45 = Math.sin(Math.PI / 4);
|
|
11
|
+
|
|
12
|
+
export interface GaugeLayoutInput {
|
|
13
|
+
width: number;
|
|
14
|
+
height: number;
|
|
15
|
+
variant: GaugeVariant;
|
|
16
|
+
mode: GaugeMode;
|
|
17
|
+
thickness: number;
|
|
18
|
+
/** Min and max printed under the arc ends. */
|
|
19
|
+
endLabels: boolean;
|
|
20
|
+
/** The value block is custom HTML of unknown size. */
|
|
21
|
+
customCenter: boolean;
|
|
22
|
+
caption: boolean;
|
|
23
|
+
bandLabel: boolean;
|
|
24
|
+
/** Longest formatted value, in characters; sizes the value block width. */
|
|
25
|
+
valueChars: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface GaugeLayout {
|
|
29
|
+
radius: number;
|
|
30
|
+
/** Stroke width of the track and value arc. */
|
|
31
|
+
band: number;
|
|
32
|
+
cx: number;
|
|
33
|
+
cy: number;
|
|
34
|
+
/** Radius of the arc centre line. */
|
|
35
|
+
mid: number;
|
|
36
|
+
valueSize: number;
|
|
37
|
+
/** Distance from the centre to the top of the value block. */
|
|
38
|
+
blockTop: number;
|
|
39
|
+
/** Baselines relative to the centre. */
|
|
40
|
+
valueDy: number;
|
|
41
|
+
captionDy: number;
|
|
42
|
+
labelDy: number;
|
|
43
|
+
/** Where the custom centre block is anchored, relative to the centre. */
|
|
44
|
+
customAnchor: "above" | "below" | "middle";
|
|
45
|
+
customDy: number;
|
|
46
|
+
customWidth: number;
|
|
47
|
+
/** Needle reach from the hub; stops at the arc. */
|
|
48
|
+
needleLength: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Min/max labels duplicate the tick labels at the arc ends, so ticks win. */
|
|
52
|
+
export function showEndLabels(minMaxLabels: boolean, ticks: number): boolean {
|
|
53
|
+
return minMaxLabels && !(ticks >= 1);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function clamp(value: number, lo: number, hi: number): number {
|
|
57
|
+
return Math.min(hi, Math.max(lo, value));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Width of one tabular glyph as a share of the font size. */
|
|
61
|
+
const GLYPH_WIDTH = 0.6;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Value font size: scales with the radius, but long values (currency) shrink
|
|
65
|
+
* so the text stays inside the inner edge of the arc.
|
|
66
|
+
*/
|
|
67
|
+
function valueSizeFor(input: GaugeLayoutInput, radius: number, band: number): number {
|
|
68
|
+
const bySize = clamp(radius * 0.34, 20, 52);
|
|
69
|
+
if (input.customCenter || input.valueChars <= 0) return Math.round(bySize);
|
|
70
|
+
const inner = input.mode === "needle" ? radius * 2 : (radius - band) * 2 - 16;
|
|
71
|
+
const byWidth = inner / (input.valueChars * GLYPH_WIDTH);
|
|
72
|
+
return Math.max(12, Math.min(Math.round(bySize), Math.floor(byWidth)));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
interface Block {
|
|
76
|
+
/** Distance from the centre to the top of the block. */
|
|
77
|
+
top: number;
|
|
78
|
+
valueDy: number;
|
|
79
|
+
captionDy: number;
|
|
80
|
+
labelDy: number;
|
|
81
|
+
bottom: number;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function blockFor(input: GaugeLayoutInput, radius: number, valueSize: number): Block {
|
|
85
|
+
const { variant, mode, customCenter } = input;
|
|
86
|
+
const halfWidth = customCenter ? radius * 0.35 : (input.valueChars * GLYPH_WIDTH * valueSize) / 2;
|
|
87
|
+
const needle = mode === "needle";
|
|
88
|
+
let top: number;
|
|
89
|
+
if (needle) {
|
|
90
|
+
// Semicircle: the sweep is the upper half, anything under the hub is free.
|
|
91
|
+
// Three-quarter: the free area is the wedge under the hub, so the top
|
|
92
|
+
// corners of the block must sit below the 45 degree needle positions.
|
|
93
|
+
top = variant === "semi" ? HUB_RADIUS + 14 : Math.max(HUB_RADIUS + 14, halfWidth + 12);
|
|
94
|
+
} else if (variant === "semi") {
|
|
95
|
+
top = customCenter ? -(CUSTOM_BLOCK_HEIGHT + 6) : -(valueSize * 0.9 + 6);
|
|
96
|
+
} else {
|
|
97
|
+
top = customCenter ? -CUSTOM_BLOCK_HEIGHT / 2 : -valueSize * 0.5;
|
|
98
|
+
}
|
|
99
|
+
const gap = needle ? 6 : 8;
|
|
100
|
+
const valueDy = top + valueSize * 0.85;
|
|
101
|
+
const captionDy = valueDy + gap + CAPTION_SIZE;
|
|
102
|
+
const withCaption = input.caption ? captionDy : valueDy;
|
|
103
|
+
const labelDy = withCaption + (input.caption || !needle ? 16 : 14);
|
|
104
|
+
const last = input.bandLabel ? labelDy : withCaption;
|
|
105
|
+
const bottom = customCenter ? top + CUSTOM_BLOCK_HEIGHT : last + 4;
|
|
106
|
+
return { top, valueDy, captionDy, labelDy, bottom };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Distance from the centre to the lowest thing drawn. */
|
|
110
|
+
function below(input: GaugeLayoutInput, radius: number, band: number): number {
|
|
111
|
+
const valueSize = valueSizeFor(input, radius, band);
|
|
112
|
+
const mid = radius - band / 2;
|
|
113
|
+
const arcBottom = input.variant === "semi" ? band / 2 : mid * SIN_45 + band / 2;
|
|
114
|
+
const withLabels = arcBottom + (input.endLabels ? 19 : 2);
|
|
115
|
+
const block = blockFor(input, radius, valueSize).bottom;
|
|
116
|
+
const needleHub = input.mode === "needle" ? HUB_RADIUS : 0;
|
|
117
|
+
return Math.max(withLabels, block, needleHub);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Largest arc that fits the box in both directions, with the value block
|
|
122
|
+
* placed where the needle and hub never reach it.
|
|
123
|
+
*/
|
|
124
|
+
export function layoutGauge(input: GaugeLayoutInput): GaugeLayout {
|
|
125
|
+
const { width, height, thickness } = input;
|
|
126
|
+
const maxByWidth = width / 2 - PAD - (input.endLabels ? 6 : 0);
|
|
127
|
+
const fits = (radius: number) => {
|
|
128
|
+
const band = Math.min(thickness, radius * 0.4);
|
|
129
|
+
return radius + below(input, radius, band) + PAD * 2 <= height;
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
let radius = MIN_RADIUS;
|
|
133
|
+
if (maxByWidth > MIN_RADIUS) {
|
|
134
|
+
if (fits(maxByWidth)) {
|
|
135
|
+
radius = maxByWidth;
|
|
136
|
+
} else {
|
|
137
|
+
let lo = MIN_RADIUS;
|
|
138
|
+
let hi = maxByWidth;
|
|
139
|
+
for (let i = 0; i < 30; i++) {
|
|
140
|
+
const probe = (lo + hi) / 2;
|
|
141
|
+
if (fits(probe)) lo = probe;
|
|
142
|
+
else hi = probe;
|
|
143
|
+
}
|
|
144
|
+
radius = lo;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const band = Math.min(thickness, radius * 0.4);
|
|
149
|
+
const mid = radius - band / 2;
|
|
150
|
+
const valueSize = valueSizeFor(input, radius, band);
|
|
151
|
+
const block = blockFor(input, radius, valueSize);
|
|
152
|
+
const extent = radius + below(input, radius, band);
|
|
153
|
+
const cx = width / 2;
|
|
154
|
+
const cy = Math.max(PAD + radius, (height - extent) / 2 + radius);
|
|
155
|
+
|
|
156
|
+
const needle = input.mode === "needle";
|
|
157
|
+
const customAnchor = needle || input.variant === "three-quarter" ? "below" : "above";
|
|
158
|
+
const customWidth = Math.max(40, radius * 1.5);
|
|
159
|
+
let customDy = block.top;
|
|
160
|
+
let anchor: GaugeLayout["customAnchor"] = customAnchor;
|
|
161
|
+
if (!needle && input.variant === "semi") customDy = -6;
|
|
162
|
+
else if (!needle) {
|
|
163
|
+
anchor = "middle";
|
|
164
|
+
customDy = 0;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
radius,
|
|
169
|
+
band,
|
|
170
|
+
cx,
|
|
171
|
+
cy,
|
|
172
|
+
mid,
|
|
173
|
+
valueSize,
|
|
174
|
+
blockTop: block.top,
|
|
175
|
+
valueDy: block.valueDy,
|
|
176
|
+
captionDy: block.captionDy,
|
|
177
|
+
labelDy: block.labelDy,
|
|
178
|
+
customAnchor: anchor,
|
|
179
|
+
customDy,
|
|
180
|
+
customWidth,
|
|
181
|
+
needleLength: mid + band * 0.3,
|
|
182
|
+
};
|
|
183
|
+
}
|