@jcsoftdev/ui 0.1.1 → 0.1.3

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))),
@@ -159,7 +164,7 @@ function GaugePlot({
159
164
  );
160
165
  const capAngle = roundedCaps ? band / 2 / mid : 0;
161
166
  const fraction = toFraction(shown);
162
- const active = bands.find((item) => shown >= item.from && shown <= item.to);
167
+ const active = bands.find((item) => value >= item.from && value <= item.to);
163
168
  const valueColor = active ? rangeColor(active) : "var(--accent-word)";
164
169
  const progressEnd = a0 + capAngle + (span - 2 * capAngle) * fraction;
165
170
  const bandPaths = useMemo(
@@ -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.1",
3
+ "version": "0.1.3",
4
4
  "description": "Token-driven React component library: React 19, Radix, Tailwind v4.",
5
5
  "author": "jcsoftdev",
6
6
  "license": "MIT",
@@ -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(
@@ -259,7 +264,9 @@ function GaugePlot({
259
264
  const capAngle = roundedCaps ? band / 2 / mid : 0;
260
265
 
261
266
  const fraction = toFraction(shown);
262
- const active = bands.find((item) => shown >= item.from && shown <= item.to);
267
+ // Colour and label follow the target value, so they do not flip band by band
268
+ // while the arc or needle sweeps towards it.
269
+ const active = bands.find((item) => value >= item.from && value <= item.to);
263
270
  const valueColor = active ? rangeColor(active) : "var(--accent-word)";
264
271
  const progressEnd = a0 + capAngle + (span - 2 * capAngle) * fraction;
265
272
 
@@ -317,14 +324,10 @@ function GaugePlot({
317
324
  );
318
325
 
319
326
  // Value text ---------------------------------------------------------------
320
- const valueSize = Math.round(Math.min(40, Math.max(20, radius * 0.34)));
321
327
  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;
328
+ const valueY = cy + layout.valueDy;
329
+ const captionY = cy + layout.captionDy;
330
+ const labelY = cy + layout.labelDy;
328
331
 
329
332
  // Tooltip -----------------------------------------------------------------
330
333
  const focused = nav.active != null ? nav.active : null;
@@ -376,6 +379,27 @@ function GaugePlot({
376
379
  height={height}
377
380
  className="block overflow-visible"
378
381
  >
382
+ {roundedCaps && bands.length > 0 ? (
383
+ <defs>
384
+ <mask
385
+ id={`${uid}-track`}
386
+ maskUnits="userSpaceOnUse"
387
+ x={0}
388
+ y={0}
389
+ width={width}
390
+ height={height}
391
+ >
392
+ <path
393
+ d={arcLine(cx, cy, mid, a0 + capAngle, a1 - capAngle)}
394
+ fill="none"
395
+ stroke="white"
396
+ strokeWidth={band}
397
+ strokeLinecap="round"
398
+ />
399
+ </mask>
400
+ </defs>
401
+ ) : null}
402
+
379
403
  {bands.length === 0 ? (
380
404
  <path
381
405
  d={arcLine(cx, cy, mid, a0 + capAngle, a1 - capAngle)}
@@ -385,23 +409,25 @@ function GaugePlot({
385
409
  style={{ stroke: "var(--chart-grid)" }}
386
410
  />
387
411
  ) : (
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
- })
412
+ <g mask={roundedCaps ? `url(#${uid}-track)` : undefined}>
413
+ {bandPaths.map((path, index) => {
414
+ const item = bands[index];
415
+ if (!item || !path) return null;
416
+ return (
417
+ <BandMark
418
+ // biome-ignore lint/suspicious/noArrayIndexKey: bands are positional and static.
419
+ key={index}
420
+ d={path}
421
+ width={band}
422
+ color={rangeColor(item)}
423
+ opacity={needle ? 0.92 : 0.26}
424
+ dimmed={focused != null && focused !== index}
425
+ onEnter={(event) => nav.hover(index, event)}
426
+ onLeave={nav.leave}
427
+ />
428
+ );
429
+ })}
430
+ </g>
405
431
  )}
406
432
 
407
433
  {!needle && fraction > 0 ? (
@@ -451,7 +477,7 @@ function GaugePlot({
451
477
  }}
452
478
  >
453
479
  <path
454
- d={`M${cx - 4} ${cy} L${cx} ${cy - (mid + band * 0.3)} L${cx + 4} ${cy} Z`}
480
+ d={`M${cx - 4} ${cy} L${cx} ${cy - layout.needleLength} L${cx + 4} ${cy} Z`}
455
481
  style={{ fill: "var(--ink)" }}
456
482
  />
457
483
  <circle cx={cx} cy={cy} r={8} style={{ fill: "var(--ink)" }} />
@@ -459,7 +485,7 @@ function GaugePlot({
459
485
  </g>
460
486
  ) : null}
461
487
 
462
- {minMaxLabels ? (
488
+ {endLabels ? (
463
489
  <g {...DECORATIVE} className="pointer-events-none">
464
490
  <text
465
491
  x={minPoint.x}
@@ -503,7 +529,7 @@ function GaugePlot({
503
529
  {active?.label && bands.length > 0 ? (
504
530
  <text
505
531
  x={cx}
506
- y={caption ? captionY + 16 : captionY}
532
+ y={labelY}
507
533
  className="font-bold text-[11px]"
508
534
  style={{ fill: valueColor }}
509
535
  >
@@ -519,13 +545,14 @@ function GaugePlot({
519
545
  className="pointer-events-none absolute flex flex-col items-center text-center"
520
546
  style={{
521
547
  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"
548
+ top: cy + layout.customDy,
549
+ width: layout.customWidth,
550
+ transform:
551
+ layout.customAnchor === "above"
527
552
  ? "translate(-50%, -100%)"
528
- : "translate(-50%, -50%)",
553
+ : layout.customAnchor === "middle"
554
+ ? "translate(-50%, -50%)"
555
+ : "translate(-50%, 0)",
529
556
  }}
530
557
  >
531
558
  {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
+ }