@jcsoftdev/ui 0.1.0 → 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.
@@ -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 reserve = variant === "semi" ? needle ? 52 : 30 : needle ? 40 : 16;
144
- const pad = 12;
145
- const tail = variant === "semi" ? 0 : Math.sin(degToRad(45));
146
- const radius = Math.max(
147
- 40,
148
- Math.min(width / 2 - pad - (minMaxLabels ? 6 : 0), (height - pad - reserve) / (1 + tail))
149
- );
150
- const band = Math.min(thickness, radius * 0.4);
151
- const extent = radius * (1 + tail) + reserve;
152
- const cx = width / 2;
153
- const cy = Math.max(pad + radius, (height - extent) / 2 + radius);
154
- const mid = radius - band / 2;
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 = needle ? cy + 22 + valueSize * 0.8 : variant === "semi" ? cy - 6 : cy + valueSize * 0.3;
216
- const captionY = needle ? valueY + 6 + captionSize : valueY + 8 + captionSize;
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 - (mid + band * 0.3)} L${cx + 4} ${cy} Z`,
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
- minMaxLabels ? /* @__PURE__ */ jsxs("g", { ...DECORATIVE, className: "pointer-events-none", children: [
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: caption ? captionY + 16 : captionY,
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: needle ? cy + 22 : variant === "semi" ? cy - 6 : cy,
401
- width: Math.max(40, radius * 1.5),
402
- transform: needle ? "translate(-50%, 0)" : variant === "semi" ? "translate(-50%, -100%)" : "translate(-50%, -50%)"
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
  {
2
2
  "name": "@jcsoftdev/ui",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Token-driven React component library: React 19, Radix, Tailwind v4.",
5
5
  "author": "jcsoftdev",
6
6
  "license": "MIT",
@@ -61,6 +61,17 @@
61
61
  "import": "./dist/*/index.js"
62
62
  }
63
63
  },
64
+ "scripts": {
65
+ "build": "pnpm build:styles && tsup && tsc -p tsconfig.build.json",
66
+ "build:styles": "node bin/ui-theme.mjs build tokens/default.json --out styles",
67
+ "lint": "biome check .",
68
+ "storybook": "storybook dev -p 6006",
69
+ "build-storybook": "storybook build -o storybook-static",
70
+ "format": "biome check --write .",
71
+ "typecheck": "tsc --noEmit",
72
+ "test": "node --test \"test/*.test.mjs\"",
73
+ "check:treeshake": "node test/treeshake.mjs"
74
+ },
64
75
  "dependencies": {
65
76
  "@radix-ui/react-avatar": "^1.2.6",
66
77
  "@radix-ui/react-checkbox": "^1.3.11",
@@ -103,16 +114,5 @@
103
114
  },
104
115
  "publishConfig": {
105
116
  "access": "public"
106
- },
107
- "scripts": {
108
- "build": "pnpm build:styles && tsup && tsc -p tsconfig.build.json",
109
- "build:styles": "node bin/ui-theme.mjs build tokens/default.json --out styles",
110
- "lint": "biome check .",
111
- "storybook": "storybook dev -p 6006",
112
- "build-storybook": "storybook build -o storybook-static",
113
- "format": "biome check --write .",
114
- "typecheck": "tsc --noEmit",
115
- "test": "node --test \"test/*.test.mjs\"",
116
- "check:treeshake": "node test/treeshake.mjs"
117
117
  }
118
- }
118
+ }
@@ -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 reserve = variant === "semi" ? (needle ? 52 : 30) : needle ? 40 : 16;
242
- const pad = 12;
243
- const tail = variant === "semi" ? 0 : Math.sin(degToRad(45));
244
- const radius = Math.max(
245
- 40,
246
- Math.min(width / 2 - pad - (minMaxLabels ? 6 : 0), (height - pad - reserve) / (1 + tail)),
247
- );
248
- const band = Math.min(thickness, radius * 0.4);
249
- const extent = radius * (1 + tail) + reserve;
250
- const cx = width / 2;
251
- const cy = Math.max(pad + radius, (height - extent) / 2 + radius);
252
- const mid = radius - band / 2;
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 = needle
323
- ? cy + 22 + valueSize * 0.8
324
- : variant === "semi"
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
- bandPaths.map((path, index) => {
389
- const item = bands[index];
390
- if (!item || !path) return null;
391
- return (
392
- <BandMark
393
- // biome-ignore lint/suspicious/noArrayIndexKey: bands are positional and static.
394
- key={index}
395
- d={path}
396
- width={band}
397
- color={rangeColor(item)}
398
- opacity={needle ? 0.92 : 0.26}
399
- dimmed={focused != null && focused !== index}
400
- onEnter={(event) => nav.hover(index, event)}
401
- onLeave={nav.leave}
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 - (mid + band * 0.3)} L${cx + 4} ${cy} Z`}
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
- {minMaxLabels ? (
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={caption ? captionY + 16 : captionY}
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: needle ? cy + 22 : variant === "semi" ? cy - 6 : cy,
523
- width: Math.max(40, radius * 1.5),
524
- transform: needle
525
- ? "translate(-50%, 0)"
526
- : variant === "semi"
546
+ top: cy + layout.customDy,
547
+ width: layout.customWidth,
548
+ transform:
549
+ layout.customAnchor === "above"
527
550
  ? "translate(-50%, -100%)"
528
- : "translate(-50%, -50%)",
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
+ }