diff --git a/src/__stories__/Gauge/Gauge.stories.tsx b/src/__stories__/Gauge/Gauge.stories.tsx new file mode 100644 index 000000000..7c6bf63f6 --- /dev/null +++ b/src/__stories__/Gauge/Gauge.stories.tsx @@ -0,0 +1,51 @@ +import type {Meta, StoryObj} from '@storybook/react'; + +import {Chart} from '../../components'; +import {ChartStory} from '../ChartStory'; +import {gaugeBasicData, gaugeGradientData, gaugeNeedleData, gaugeSolidData} from '../__data__'; + +const meta: Meta = { + title: 'Gauge', + render: ChartStory, + component: Chart, + tags: ['autodocs'], + parameters: { + docs: { + description: { + component: `Gauge chart displays a single value on an arc with optional threshold zones, a pointer, and a target marker.`, + }, + }, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const GaugeBasic = { + name: 'Basic (marker, 3 thresholds, target)', + args: { + data: gaugeBasicData, + }, +} satisfies Story; + +export const GaugeNeedle = { + name: 'Needle pointer', + args: { + data: gaugeNeedleData, + }, +} satisfies Story; + +export const GaugeSolid = { + name: 'Solid pointer', + args: { + data: gaugeSolidData, + }, +} satisfies Story; + +export const GaugeGradient = { + name: 'Gradient track (continuous)', + args: { + data: gaugeGradientData, + }, +} satisfies Story; diff --git a/src/__stories__/__data__/gauge/basic.ts b/src/__stories__/__data__/gauge/basic.ts new file mode 100644 index 000000000..4da0fa369 --- /dev/null +++ b/src/__stories__/__data__/gauge/basic.ts @@ -0,0 +1,83 @@ +import type {ChartData} from '../../../types'; + +export const gaugeBasicData: ChartData = { + series: { + data: [ + { + type: 'gauge', + name: 'Performance', + value: 65, + min: 0, + max: 100, + unit: '%', + thresholds: [ + {value: 40, color: '#FF3D64', label: 'Critical'}, + {value: 70, color: '#FFC636', label: 'Warning'}, + {value: 100, color: '#8AD554', label: 'Good'}, + ], + target: 80, + pointer: {type: 'marker'}, + }, + ], + }, +}; + +export const gaugeNeedleData: ChartData = { + series: { + data: [ + { + type: 'gauge', + name: 'Temperature', + value: 72, + min: 0, + max: 120, + unit: '°C', + thresholds: [ + {value: 40, color: '#8AD554', label: 'Normal'}, + {value: 80, color: '#FFC636', label: 'Hot'}, + {value: 120, color: '#FF3D64', label: 'Critical'}, + ], + pointer: {type: 'needle'}, + }, + ], + }, +}; + +export const gaugeSolidData: ChartData = { + series: { + data: [ + { + type: 'gauge', + name: 'Progress', + value: 42, + min: 0, + max: 100, + unit: '%', + color: '#4DA2F1', + pointer: {type: 'solid'}, + }, + ], + }, +}; + +export const gaugeGradientData: ChartData = { + series: { + data: [ + { + type: 'gauge', + name: 'CPU Load', + value: 68, + min: 0, + max: 100, + unit: '%', + thresholds: [ + {value: 33, color: '#8AD554', label: 'Low'}, + {value: 66, color: '#FFC636', label: 'Medium'}, + {value: 100, color: '#FF3D64', label: 'High'}, + ], + pointer: {type: 'marker'}, + arc: {trackStyle: 'continuous'}, + }, + ], + }, +}; diff --git a/src/__stories__/__data__/gauge/index.ts b/src/__stories__/__data__/gauge/index.ts new file mode 100644 index 000000000..bd4afb0e5 --- /dev/null +++ b/src/__stories__/__data__/gauge/index.ts @@ -0,0 +1 @@ +export {gaugeBasicData, gaugeGradientData, gaugeNeedleData, gaugeSolidData} from './basic'; diff --git a/src/__stories__/__data__/index.ts b/src/__stories__/__data__/index.ts index 9cebaf08a..c27fba211 100644 --- a/src/__stories__/__data__/index.ts +++ b/src/__stories__/__data__/index.ts @@ -1,6 +1,7 @@ export * from './area'; export * from './bar-x'; export * from './bar-y'; +export * from './gauge'; export * from './other'; export * from './line'; export * from './pie'; diff --git a/src/__tests__/gauge-series.visual.test.tsx b/src/__tests__/gauge-series.visual.test.tsx new file mode 100644 index 000000000..364bc2458 --- /dev/null +++ b/src/__tests__/gauge-series.visual.test.tsx @@ -0,0 +1,33 @@ +import React from 'react'; + +import {expect, test} from '@playwright/experimental-ct-react'; + +import {ChartTestStory} from '../../playwright/components/ChartTestStory'; +import { + gaugeBasicData, + gaugeGradientData, + gaugeNeedleData, + gaugeSolidData, +} from '../__stories__/__data__'; + +test.describe('Gauge series', () => { + test('Basic gauge: value=65, marker pointer, 3 thresholds, target=80', async ({mount}) => { + const component = await mount(); + await expect(component.locator('svg')).toHaveScreenshot(); + }); + + test('Needle pointer', async ({mount}) => { + const component = await mount(); + await expect(component.locator('svg')).toHaveScreenshot(); + }); + + test('Solid pointer', async ({mount}) => { + const component = await mount(); + await expect(component.locator('svg')).toHaveScreenshot(); + }); + + test('Gradient track (continuous)', async ({mount}) => { + const component = await mount(); + await expect(component.locator('svg')).toHaveScreenshot(); + }); +}); diff --git a/src/__tests__/x-range.visual.test.tsx b/src/__tests__/x-range.visual.test.tsx index 0c46a54c0..ca1df07ef 100644 --- a/src/__tests__/x-range.visual.test.tsx +++ b/src/__tests__/x-range.visual.test.tsx @@ -5,7 +5,7 @@ import cloneDeep from 'lodash/cloneDeep'; import merge from 'lodash/merge'; import {xRangeBasicData, xRangeContinuousLegendData} from 'src/__stories__/__data__'; -import type {ChartData, DeepPartial, XRangeSeriesData} from 'src/types'; +import type {ChartData, DeepPartial, XRangeSeries, XRangeSeriesData} from 'src/types'; import {ChartTestStory} from '../../playwright/components/ChartTestStory'; @@ -119,9 +119,9 @@ test.describe('X-Range series', () => { ...xRangeBasicData, series: { data: xRangeBasicData.series.data.map((s) => ({ - ...s, + ...(s as XRangeSeries), dataLabels: {enabled: true, html: true}, - data: s.data.map((d) => ({ + data: (s as XRangeSeries).data.map((d) => ({ ...d, label: `
${String((d as XRangeSeriesData).label)}
`, })), diff --git a/src/components/ChartInner/utils/common.ts b/src/components/ChartInner/utils/common.ts index 3d35dc8b8..49292a544 100644 --- a/src/components/ChartInner/utils/common.ts +++ b/src/components/ChartInner/utils/common.ts @@ -33,7 +33,11 @@ export function hasAtLeastOneSeriesDataPerPlot( const plotIndex = yAxis?.plotIndex ?? 0; if (!hasDataMap.get(plotIndex)) { - if (Array.isArray(seriesDataChunk.data) && seriesDataChunk.data.length > 0) { + if ( + 'data' in seriesDataChunk && + Array.isArray(seriesDataChunk.data) && + seriesDataChunk.data.length > 0 + ) { hasDataMap.set(plotIndex, true); } } diff --git a/src/components/Tooltip/DefaultTooltipContent/index.tsx b/src/components/Tooltip/DefaultTooltipContent/index.tsx index f73578fb8..441c58d23 100644 --- a/src/components/Tooltip/DefaultTooltipContent/index.tsx +++ b/src/components/Tooltip/DefaultTooltipContent/index.tsx @@ -16,6 +16,7 @@ import type { ChartXAxis, ChartYAxis, TooltipDataChunk, + TooltipDataChunkGauge, TooltipDataChunkSankey, TooltipDataChunkWaterfall, TreemapSeriesData, @@ -315,6 +316,53 @@ export const DefaultTooltipContent = ({ series, }); } + case 'gauge': { + const gaugeData = (seriesItem as TooltipDataChunkGauge).data; + const zoneColor = gaugeData.zoneColor ?? get(series, 'color'); + const valueWithUnit = gaugeData.unit + ? `${gaugeData.value} ${gaugeData.unit}` + : String(gaugeData.value); + // Empty spacer keeps the color column consistent across all gauge rows + const emptyColorCell = ( + + ); + + return ( + + {renderRow({ + id, + color: zoneColor, + name: series.name, + value: gaugeData.value, + formattedValue: valueWithUnit, + series, + })} + {gaugeData.zoneLabel && ( + + )} + {gaugeData.distanceToTarget !== undefined && ( + 0 + ? `+${gaugeData.distanceToTarget}` + : String(gaugeData.distanceToTarget) + } + /> + )} + + ); + } default: { return null; } diff --git a/src/components/Tooltip/DefaultTooltipContent/utils.ts b/src/components/Tooltip/DefaultTooltipContent/utils.ts index 34a3ae4a8..0373d11d8 100644 --- a/src/components/Tooltip/DefaultTooltipContent/utils.ts +++ b/src/components/Tooltip/DefaultTooltipContent/utils.ts @@ -88,7 +88,7 @@ export const getMeasureValue = ({ }) => { if ( data.every((item) => - ['pie', 'treemap', 'waterfall', 'sankey', 'heatmap', 'funnel'].includes( + ['gauge', 'pie', 'treemap', 'waterfall', 'sankey', 'heatmap', 'funnel'].includes( item.series.type, ), ) @@ -141,6 +141,9 @@ export function getHoveredValues(args: { case 'bar-y': { return getXRowData(data, xAxis); } + case 'gauge': { + return (data as {value: number}).value; + } case 'pie': case 'radar': case 'heatmap': diff --git a/src/core/constants/chart-types.ts b/src/core/constants/chart-types.ts index 794fc38ed..ea3435733 100644 --- a/src/core/constants/chart-types.ts +++ b/src/core/constants/chart-types.ts @@ -2,6 +2,7 @@ export const SERIES_TYPE = { Area: 'area', BarX: 'bar-x', BarY: 'bar-y', + Gauge: 'gauge', Line: 'line', Pie: 'pie', Scatter: 'scatter', diff --git a/src/core/series/prepare-gauge.ts b/src/core/series/prepare-gauge.ts new file mode 100644 index 000000000..11bf1c962 --- /dev/null +++ b/src/core/series/prepare-gauge.ts @@ -0,0 +1,72 @@ +import type {ScaleOrdinal} from 'd3-scale'; + +import type {GaugeSeries} from '../../types'; +import type {PreparedGaugeSeries} from '../shapes/gauge/types'; +import {GAUGE_DEFAULTS} from '../types/chart/gauge'; +import {getUniqId} from '../utils'; + +import type {PreparedLegend, PreparedSeries} from './types'; +import {prepareLegendSymbol} from './utils'; + +type PrepareGaugeSeriesArgs = { + series: GaugeSeries; + legend: PreparedLegend; + colorScale: ScaleOrdinal; +}; + +export function prepareGaugeSeries({series, colorScale}: PrepareGaugeSeriesArgs): PreparedSeries[] { + const id = getUniqId(); + const name = series.name ?? 'Gauge'; + const color = series.color ?? colorScale(name); + + const arcConfig = { + sweepAngle: GAUGE_DEFAULTS.arc.sweepAngle, + trackStyle: GAUGE_DEFAULTS.arc.trackStyle, + thickness: GAUGE_DEFAULTS.arc.thickness as number | string, + cornerRadius: GAUGE_DEFAULTS.arc.cornerRadius, + ...series.arc, + } as Required<{ + sweepAngle: number; + trackStyle: 'discrete' | 'continuous'; + thickness: number | string; + cornerRadius: number; + }>; + + const pointerConfig = { + type: GAUGE_DEFAULTS.pointer.type, + size: GAUGE_DEFAULTS.pointer.size, + ...series.pointer, + } as { + type: 'marker' | 'needle' | 'solid'; + color?: string; + size: number; + }; + + const result: PreparedGaugeSeries = { + type: 'gauge', + id, + color, + name, + visible: series.visible !== false, + legend: { + enabled: series.legend?.enabled ?? false, + symbol: prepareLegendSymbol({}), + groupId: getUniqId(), + itemText: name, + }, + cursor: series.cursor ?? null, + tooltip: series.tooltip, + value: series.value, + min: series.min ?? GAUGE_DEFAULTS.min, + max: series.max ?? GAUGE_DEFAULTS.max, + thresholds: series.thresholds ?? [], + target: series.target, + unit: series.unit, + customContent: series.customContent, + arc: arcConfig, + pointer: pointerConfig, + overflow: series.overflow ?? GAUGE_DEFAULTS.overflow, + }; + + return [result]; +} diff --git a/src/core/series/prepareSeries.ts b/src/core/series/prepareSeries.ts index 64eb1a38d..38bdb3fe9 100644 --- a/src/core/series/prepareSeries.ts +++ b/src/core/series/prepareSeries.ts @@ -11,6 +11,7 @@ import type { ChartSeries, ChartSeriesOptions, FunnelSeries, + GaugeSeries, HeatmapSeries, LineSeries, PieSeries, @@ -27,6 +28,7 @@ import {prepareArea} from './prepare-area'; import {prepareBarXSeries} from './prepare-bar-x'; import {prepareBarYSeries} from './prepare-bar-y'; import {prepareFunnelSeries} from './prepare-funnel'; +import {prepareGaugeSeries} from './prepare-gauge'; import {prepareHeatmapSeries} from './prepare-heatmap'; import {prepareLineSeries} from './prepare-line'; import {preparePieSeries} from './prepare-pie'; @@ -95,6 +97,18 @@ export async function prepareSeries(args: { const {type, series, seriesOptions, legend, colors, colorScale} = args; switch (type) { + case 'gauge': { + return series.reduce((acc, singleSeries) => { + acc.push( + ...prepareGaugeSeries({ + series: singleSeries as GaugeSeries, + legend, + colorScale, + }), + ); + return acc; + }, []); + } case 'pie': { return series.reduce((acc, singleSeries) => { acc.push( diff --git a/src/core/series/types.ts b/src/core/series/types.ts index 94bf14888..fcf5986d4 100644 --- a/src/core/series/types.ts +++ b/src/core/series/types.ts @@ -46,6 +46,7 @@ import type { SeriesOptionsDefaults, SymbolType, } from '../constants'; +import type {PreparedGaugeSeries} from '../shapes/gauge/types'; export type PreparedAnnotation = { label: { @@ -454,10 +455,13 @@ export type PreparedXRangeSeries = { }; } & BasePreparedSeries; +export type {PreparedGaugeSeries}; + export type PreparedSeries = | PreparedScatterSeries | PreparedBarXSeries | PreparedBarYSeries + | PreparedGaugeSeries | PreparedPieSeries | PreparedLineSeries | PreparedAreaSeries diff --git a/src/core/shapes/gauge/__tests__/utils.test.ts b/src/core/shapes/gauge/__tests__/utils.test.ts new file mode 100644 index 000000000..ac2beb483 --- /dev/null +++ b/src/core/shapes/gauge/__tests__/utils.test.ts @@ -0,0 +1,103 @@ +import {buildThresholdArcs, valueToAngle} from '../utils'; + +describe('valueToAngle', () => { + it('returns startDeg for value === min', () => { + expect(valueToAngle(0, 0, 100, -120, 120)).toBe(-120); + }); + + it('returns endDeg for value === max', () => { + expect(valueToAngle(100, 0, 100, -120, 120)).toBe(120); + }); + + it('returns midpoint for value at 50% of range', () => { + expect(valueToAngle(50, 0, 100, -120, 120)).toBe(0); + }); + + it('clamps value below min to startDeg', () => { + expect(valueToAngle(-10, 0, 100, -120, 120)).toBe(-120); + }); + + it('clamps value above max to endDeg', () => { + expect(valueToAngle(110, 0, 100, -120, 120)).toBe(120); + }); + + it('handles non-zero min correctly', () => { + // value=15 in range [10,20] → ratio=0.5 → midpoint of [-60,60] = 0 + expect(valueToAngle(15, 10, 20, -60, 60)).toBe(0); + }); + + it('handles 180-degree sweep', () => { + expect(valueToAngle(25, 0, 100, -90, 90)).toBeCloseTo(-45); + }); +}); + +describe('buildThresholdArcs', () => { + const min = 0; + const max = 100; + const startDeg = -120; + const endDeg = 120; + const fallback = '#ccc'; + + it('produces correct number of zones', () => { + const stops = [ + {value: 30, color: 'green', label: 'Good'}, + {value: 70, color: 'yellow', label: 'Warning'}, + {value: 100, color: 'red', label: 'Critical'}, + ]; + const arcs = buildThresholdArcs(stops, min, max, startDeg, endDeg, fallback); + expect(arcs).toHaveLength(3); + }); + + it('first arc starts at startDeg', () => { + const stops = [{value: 100, color: 'green'}]; + const arcs = buildThresholdArcs(stops, min, max, startDeg, endDeg, fallback); + expect(arcs[0].startDeg).toBe(startDeg); + }); + + it('last arc ends at endDeg', () => { + const stops = [{value: 100, color: 'green'}]; + const arcs = buildThresholdArcs(stops, min, max, startDeg, endDeg, fallback); + expect(arcs[arcs.length - 1].endDeg).toBe(endDeg); + }); + + it('uses fallback color when stop has no color', () => { + const stops = [{value: 100}]; + const arcs = buildThresholdArcs(stops, min, max, startDeg, endDeg, fallback); + expect(arcs[0].color).toBe(fallback); + }); + + it('uses stop color when provided', () => { + const stops = [{value: 100, color: '#0f0'}]; + const arcs = buildThresholdArcs(stops, min, max, startDeg, endDeg, fallback); + expect(arcs[0].color).toBe('#0f0'); + }); + + it('correctly assigns zoneMin and zoneMax', () => { + const stops = [ + {value: 50, color: 'green'}, + {value: 100, color: 'red'}, + ]; + const arcs = buildThresholdArcs(stops, min, max, startDeg, endDeg, fallback); + expect(arcs[0].zoneMin).toBe(0); + expect(arcs[0].zoneMax).toBe(50); + expect(arcs[1].zoneMin).toBe(50); + expect(arcs[1].zoneMax).toBe(100); + }); + + it('carries the label from each stop', () => { + const stops = [{value: 100, label: 'Zone A'}]; + const arcs = buildThresholdArcs(stops, min, max, startDeg, endDeg, fallback); + expect(arcs[0].label).toBe('Zone A'); + }); + + it('arcs are contiguous (endDeg of one equals startDeg of next)', () => { + const stops = [ + {value: 33, color: 'green'}, + {value: 66, color: 'yellow'}, + {value: 100, color: 'red'}, + ]; + const arcs = buildThresholdArcs(stops, min, max, startDeg, endDeg, fallback); + expect(arcs[0].endDeg).toBeCloseTo(arcs[1].startDeg); + expect(arcs[1].endDeg).toBeCloseTo(arcs[2].startDeg); + }); +}); diff --git a/src/core/shapes/gauge/prepare-data.ts b/src/core/shapes/gauge/prepare-data.ts new file mode 100644 index 000000000..664dbc59c --- /dev/null +++ b/src/core/shapes/gauge/prepare-data.ts @@ -0,0 +1,99 @@ +import {DEFAULT_PALETTE} from '../../constants/palette'; + +import type {PreparedGaugeData, PreparedGaugeSeries} from './types'; +import {buildThresholdArcs, resolveThickness, valueToAngle} from './utils'; + +export function prepareGaugeData({ + series, + boundsWidth, + boundsHeight, +}: { + series: PreparedGaugeSeries[]; + boundsWidth: number; + boundsHeight: number; +}): PreparedGaugeData[] { + return series.map((s) => prepareOneSeries(s, boundsWidth, boundsHeight)); +} + +function prepareOneSeries( + series: PreparedGaugeSeries, + width: number, + height: number, +): PreparedGaugeData { + const {arc, pointer, value, min, max, thresholds, target, color} = series; + const sweepAngle = arc.sweepAngle; + + const cx = width / 2; + // For sweepAngle=180 (D-shape) the arc occupies only the top half, so push + // the center low to maximise arc size while leaving room for text below. + // Interpolate between 0.70 (180°) and 0.50 (360°) linearly. + const cy = height * Math.max(0.5, 0.7 - (sweepAngle - 180) / 900); + + const outerRadius = Math.min(cx, cy) * 0.9; + const thickness = resolveThickness(arc.thickness, outerRadius); + const innerRadius = outerRadius - thickness; + + const startAngleDeg = -(sweepAngle / 2); + const endAngleDeg = sweepAngle / 2; + + const clampedValue = series.overflow === 'clamp' ? Math.min(Math.max(value, min), max) : value; + const valueAngleDeg = valueToAngle(clampedValue, min, max, startAngleDeg, endAngleDeg); + + const targetAngleDeg = + target !== undefined + ? valueToAngle(target, min, max, startAngleDeg, endAngleDeg) + : undefined; + + const fallbackColor = thresholds.length > 0 ? (DEFAULT_PALETTE[0] ?? '#4DA2F1') : color; + + const thresholdArcs = + thresholds.length > 0 + ? buildThresholdArcs(thresholds, min, max, startAngleDeg, endAngleDeg, fallbackColor) + : [ + { + startDeg: startAngleDeg, + endDeg: endAngleDeg, + color, + label: undefined, + zoneMin: min, + zoneMax: max, + }, + ]; + + const needleLength = + pointer.type === 'needle' + ? innerRadius * + (typeof pointer.size === 'number' && pointer.size <= 1 && pointer.size > 0 + ? pointer.size + : 0.85) + : innerRadius; + + const tbWidth = innerRadius * 2 * 0.78; + const tbHeight = innerRadius * 0.6; + const tbCx = cx; + // Text sits at the center of the inner circle (works for all sweep angles). + const tbCy = cy; + + return { + id: series.id, + series, + cx, + cy, + outerRadius, + innerRadius, + startAngleDeg, + endAngleDeg, + valueAngleDeg, + targetAngleDeg, + thresholdArcs, + needleLength, + textBox: { + x: tbCx - tbWidth / 2, + y: tbCy - tbHeight / 2, + width: tbWidth, + height: tbHeight, + cx: tbCx, + cy: tbCy, + }, + }; +} diff --git a/src/core/shapes/gauge/renderer.ts b/src/core/shapes/gauge/renderer.ts new file mode 100644 index 000000000..00b5bb3a9 --- /dev/null +++ b/src/core/shapes/gauge/renderer.ts @@ -0,0 +1,303 @@ +import type {Dispatch} from 'd3-dispatch'; +import {select} from 'd3-selection'; + +import {block} from '../../../utils'; + +import type {PreparedGaugeData, ThresholdArc} from './types'; +import {buildArcPath, fitFontSize, pointOnArc} from './utils'; + +const b = block('gauge'); + +export function renderGauge( + elements: {plot: SVGGElement}, + preparedData: PreparedGaugeData[], + dispatcher?: Dispatch, +): () => void { + const svgElement = select(elements.plot); + svgElement.selectAll('*').remove(); + + preparedData.forEach((d) => renderOne(svgElement.append('g'), d)); + + return () => { + dispatcher?.on('hover-shape.gauge', null); + }; +} + +function renderOne(g: ReturnType>, d: PreparedGaugeData) { + const {cx, cy, outerRadius, innerRadius, startAngleDeg, endAngleDeg, series} = d; + const {arc, pointer} = series; + const cornerRadius = arc.cornerRadius; + + // 1. Background track — full arc, rounded caps + g.append('path') + .attr('class', b('track')) + .attr('transform', `translate(${cx},${cy})`) + .attr( + 'd', + buildArcPath({ + innerRadius, + outerRadius, + startDeg: startAngleDeg, + endDeg: endAngleDeg, + cornerRadius, + }), + ) + .attr('fill', 'currentColor') + .attr('opacity', 0.1); + + // 2. Threshold arcs / filled track — skipped for solid pointer (background track handles the "empty" visual) + if (pointer.type !== 'solid') { + if (arc.trackStyle === 'continuous') { + renderContinuousTrack(g, d); + } else { + renderDiscreteArcs(g, d); + } + } + + // 3. Pointer + const pointerColor = pointer.color ?? series.color; + switch (pointer.type) { + case 'needle': { + // Line from deep inside the center (20% of innerRadius) to just past the outer edge. + // Extending inward past innerRadius makes it visually distinct from the marker tick. + const overhang = 5; + const [x1, y1] = pointOnArc(cx, cy, innerRadius * 0.2, d.valueAngleDeg); + const [x2, y2] = pointOnArc(cx, cy, outerRadius + overhang, d.valueAngleDeg); + g.append('line') + .attr('class', b('needle')) + .attr('x1', x1) + .attr('y1', y1) + .attr('x2', x2) + .attr('y2', y2) + .attr('stroke', pointerColor) + .attr('stroke-width', 2) + .attr('stroke-linecap', 'round'); + break; + } + case 'solid': { + // Solid arc from startAngle to valueAngle, rounded caps + g.append('path') + .attr('class', b('solid')) + .attr('transform', `translate(${cx},${cy})`) + .attr( + 'd', + buildArcPath({ + innerRadius, + outerRadius, + startDeg: startAngleDeg, + endDeg: d.valueAngleDeg, + cornerRadius, + }), + ) + .attr('fill', pointerColor); + break; + } + default: { + // Marker: a tick line that extends slightly beyond arc edges + renderTickMarker(g, d, pointerColor); + break; + } + } + + // 4. Target line — a tick across the arc at the target value + if (d.targetAngleDeg !== undefined) { + renderTargetLine(g, d); + } + + // 5. Center text (value + unit) — only if no custom inner content + if (!series.customContent?.inner) { + renderCenterText(g, d); + } + + // 6. Overflow badge + if (series.overflow === 'clamp' && series.value > series.max) { + const [bx, by] = pointOnArc(cx, cy, outerRadius, 0); + const badgeG = g.append('g').attr('class', b('overflow-badge')); + badgeG + .append('rect') + .attr('x', bx - 20) + .attr('y', by + 4) + .attr('width', 40) + .attr('height', 16) + .attr('rx', 8) + .attr('fill', series.color) + .attr('opacity', 0.9); + badgeG + .append('text') + .attr('x', bx) + .attr('y', by + 15) + .attr('text-anchor', 'middle') + .attr('font-size', '10px') + .attr('fill', '#fff') + .text(String(series.value)); + } +} + +/** Tick-line marker: a line across the full arc thickness, protruding 3px on each side. */ +function renderTickMarker( + g: ReturnType>, + d: PreparedGaugeData, + color: string, +) { + const {cx, cy, outerRadius, innerRadius} = d; + const overhang = 3; + const [x1, y1] = pointOnArc(cx, cy, innerRadius - overhang, d.valueAngleDeg); + const [x2, y2] = pointOnArc(cx, cy, outerRadius + overhang, d.valueAngleDeg); + g.append('line') + .attr('class', b('marker')) + .attr('x1', x1) + .attr('y1', y1) + .attr('x2', x2) + .attr('y2', y2) + .attr('stroke', color) + .attr('stroke-width', 2.5) + .attr('stroke-linecap', 'round'); +} + +/** Target line: same tick-style but in white with a dark stroke, slightly thinner. */ +function renderTargetLine( + g: ReturnType>, + d: PreparedGaugeData, +) { + const {cx, cy, outerRadius, innerRadius} = d; + const overhang = 4; + const [x1, y1] = pointOnArc(cx, cy, innerRadius - overhang, d.targetAngleDeg!); + const [x2, y2] = pointOnArc(cx, cy, outerRadius + overhang, d.targetAngleDeg!); + g.append('line') + .attr('class', b('target')) + .attr('x1', x1) + .attr('y1', y1) + .attr('x2', x2) + .attr('y2', y2) + .attr('stroke', '#fff') + .attr('stroke-width', 3) + .attr('stroke-linecap', 'round'); + g.append('line') + .attr('class', b('target-outline')) + .attr('x1', x1) + .attr('y1', y1) + .attr('x2', x2) + .attr('y2', y2) + .attr('stroke', '#333') + .attr('stroke-width', 1) + .attr('stroke-linecap', 'round'); +} + +function renderDiscreteArcs( + g: ReturnType>, + d: PreparedGaugeData, +) { + const {cx, cy, outerRadius, innerRadius} = d; + + // No corner radius on individual segments — rounding only makes sense at + // the outer endpoints of the full arc, not at internal zone boundaries. + g.selectAll(`.${b('zone')}`) + .data(d.thresholdArcs) + .join('path') + .attr('class', b('zone')) + .attr('transform', `translate(${cx},${cy})`) + .attr('d', (zone) => + buildArcPath({ + innerRadius, + outerRadius, + startDeg: zone.startDeg, + endDeg: zone.endDeg, + cornerRadius: 0, + }), + ) + .attr('fill', (zone) => zone.color); +} + +function renderContinuousTrack( + g: ReturnType>, + d: PreparedGaugeData, +) { + const {cx, cy, outerRadius, innerRadius, series} = d; + const gradientId = `gauge-gradient-${series.id}`; + + const defs = g.append('defs'); + const gradient = defs + .append('linearGradient') + .attr('id', gradientId) + .attr('gradientUnits', 'userSpaceOnUse') + // Coordinates in the path's local space (path has transform="translate(cx,cy)") + .attr('x1', -outerRadius) + .attr('y1', 0) + .attr('x2', outerRadius) + .attr('y2', 0); + + d.thresholdArcs.forEach((zone, i) => { + const pct = i / (d.thresholdArcs.length - 1 || 1); + gradient + .append('stop') + .attr('offset', `${pct * 100}%`) + .attr('stop-color', zone.color); + }); + + // Single arc with rounded caps — cornerRadius applies here + g.append('path') + .attr('class', b('continuous-track')) + .attr('transform', `translate(${cx},${cy})`) + .attr( + 'd', + buildArcPath({ + innerRadius, + outerRadius, + startDeg: d.startAngleDeg, + endDeg: d.endAngleDeg, + cornerRadius: series.arc.cornerRadius, + }), + ) + .attr('fill', `url(#${gradientId})`); +} + +function renderCenterText( + g: ReturnType>, + d: PreparedGaugeData, +) { + const {cx, cy, innerRadius, series, textBox} = d; + const valueStr = String(series.value); + const unitStr = series.unit ?? ''; + + // textG transform is set after font size resolves + const textG = g.append('g').attr('class', b('center-text')); + + const doRender = async () => { + const maxFontSize = Math.min(innerRadius * 0.5, 64); + const fontSize = await fitFontSize({ + text: valueStr, + maxWidth: textBox.width * 0.9, + maxHeight: textBox.height * 0.45, + maxFontSize, + }); + + const unitFontSize = unitStr ? Math.max(Math.floor(fontSize * 0.4), 10) : 0; + const unitGap = 3; + + // Shift group up by half the unit block so value+unit is centered at cy + const unitBlockShift = unitStr ? -(unitFontSize / 2 + unitGap / 2) : 0; + textG.attr('transform', `translate(${cx},${cy + unitBlockShift})`); + + textG + .append('text') + .attr('class', b('value')) + .attr('text-anchor', 'middle') + .attr('dominant-baseline', 'middle') + .attr('font-size', `${fontSize}px`) + .attr('font-weight', 'bold') + .text(valueStr); + + if (unitStr) { + textG + .append('text') + .attr('class', b('unit')) + .attr('text-anchor', 'middle') + .attr('dominant-baseline', 'hanging') + .attr('y', fontSize / 2 + unitGap) + .attr('font-size', `${unitFontSize}px`) + .text(unitStr); + } + }; + + doRender(); +} diff --git a/src/core/shapes/gauge/types.ts b/src/core/shapes/gauge/types.ts new file mode 100644 index 000000000..3b973fa55 --- /dev/null +++ b/src/core/shapes/gauge/types.ts @@ -0,0 +1,73 @@ +import type {PreparedLegendSymbol} from '../../series/types'; +import type { + ArcConfig, + GaugeCustomContent, + OverflowBehavior, + ThresholdStop, +} from '../../types/chart/gauge'; +import type {ChartSeries} from '../../types/chart/series'; + +export type {ThresholdStop}; + +export type ThresholdArc = { + startDeg: number; + endDeg: number; + color: string; + label?: string; + zoneMin: number; + zoneMax: number; +}; + +export type PreparedGaugeSeries = { + type: 'gauge'; + id: string; + color: string; + name: string; + visible: boolean; + legend: { + groupId: string; + itemText: string; + enabled: boolean; + symbol: PreparedLegendSymbol; + }; + cursor: string | null; + tooltip: ChartSeries['tooltip']; + // gauge-specific: + value: number; + min: number; + max: number; + thresholds: ThresholdStop[]; + target?: number; + unit?: string; + customContent?: GaugeCustomContent; + arc: Required & {trackStyle: 'discrete' | 'continuous'}; + pointer: { + type: 'marker' | 'needle' | 'solid'; + color?: string; + size: number; + }; + overflow: OverflowBehavior; +}; + +export type PreparedGaugeData = { + id: string; + series: PreparedGaugeSeries; + cx: number; + cy: number; + outerRadius: number; + innerRadius: number; + startAngleDeg: number; + endAngleDeg: number; + valueAngleDeg: number; + targetAngleDeg?: number; + thresholdArcs: ThresholdArc[]; + needleLength: number; + textBox: { + x: number; + y: number; + width: number; + height: number; + cx: number; + cy: number; + }; +}; diff --git a/src/core/shapes/gauge/utils.ts b/src/core/shapes/gauge/utils.ts new file mode 100644 index 000000000..22ea84e13 --- /dev/null +++ b/src/core/shapes/gauge/utils.ts @@ -0,0 +1,135 @@ +import {arc} from 'd3-shape'; + +import {getTextSizeFn} from '../../utils/text'; + +import type {ThresholdArc, ThresholdStop} from './types'; + +export function valueToAngle( + value: number, + min: number, + max: number, + startDeg: number, + endDeg: number, +): number { + const ratio = Math.min(Math.max((value - min) / (max - min), 0), 1); + return startDeg + ratio * (endDeg - startDeg); +} + +/** Returns [x, y] for a point on an arc at the given angle. angleDeg=0 is 12-o'clock. */ +export function pointOnArc( + cx: number, + cy: number, + radius: number, + angleDeg: number, +): [number, number] { + const rad = (angleDeg - 90) * (Math.PI / 180); + return [cx + radius * Math.cos(rad), cy + radius * Math.sin(rad)]; +} + +/** Builds an SVG arc path string centered at origin (0,0). Apply translate in the renderer. */ +export function buildArcPath({ + innerRadius, + outerRadius, + startDeg, + endDeg, + cornerRadius, +}: { + innerRadius: number; + outerRadius: number; + startDeg: number; + endDeg: number; + cornerRadius: number; +}): string { + const arcGen = arc() + .innerRadius(innerRadius) + .outerRadius(outerRadius) + .startAngle((startDeg * Math.PI) / 180) + .endAngle((endDeg * Math.PI) / 180) + .cornerRadius(cornerRadius); + return arcGen(null) ?? ''; +} + +/** Builds ThresholdArc objects from threshold stops. */ +export function buildThresholdArcs( + thresholds: ThresholdStop[], + min: number, + max: number, + startDeg: number, + endDeg: number, + fallbackColor: string, +): ThresholdArc[] { + const stops = [{value: min}, ...thresholds, {value: max}]; + const result: ThresholdArc[] = []; + + for (let i = 0; i < stops.length - 1; i++) { + const from = stops[i]; + const to = stops[i + 1] as ThresholdStop & {value: number}; + const fromAngle = valueToAngle(from.value, min, max, startDeg, endDeg); + const toAngle = valueToAngle(to.value, min, max, startDeg, endDeg); + + result.push({ + startDeg: fromAngle, + endDeg: toAngle, + color: to.color ?? fallbackColor, + label: to.label, + zoneMin: from.value, + zoneMax: to.value, + }); + } + + return result.filter((zone) => zone.startDeg !== zone.endDeg); +} + +/** Binary-search the largest integer px font size that fits within maxWidth×maxHeight. */ +export async function fitFontSize({ + text, + maxWidth, + maxHeight, + maxFontSize, + minFontSize = 10, +}: { + text: string; + maxWidth: number; + maxHeight: number; + maxFontSize: number; + minFontSize?: number; +}): Promise { + let lo = minFontSize; + let hi = maxFontSize; + + while (lo < hi) { + const mid = Math.floor((lo + hi + 1) / 2); + const measure = getTextSizeFn({style: {fontSize: `${mid}px`}}); + const size = await measure(text); + if (size.width <= maxWidth && size.height <= maxHeight) { + lo = mid; + } else { + hi = mid - 1; + } + } + + return lo; +} + +/** Parses thickness: fractional (0–1) → fraction of outerRadius; ≥1 → absolute px; string with 'px' → absolute. */ +export function resolveThickness( + thickness: number | string | undefined, + outerRadius: number, +): number { + if (typeof thickness === 'string') { + const match = thickness.match(/^(\d+(?:\.\d+)?)px$/); + if (match) { + return parseFloat(match[1]); + } + return outerRadius * 0.12; + } + + if (typeof thickness === 'number') { + if (thickness > 0 && thickness < 1) { + return outerRadius * thickness; + } + return thickness; + } + + return outerRadius * 0.12; +} diff --git a/src/core/types/chart/gauge.ts b/src/core/types/chart/gauge.ts new file mode 100644 index 000000000..a85728d84 --- /dev/null +++ b/src/core/types/chart/gauge.ts @@ -0,0 +1,140 @@ +import type React from 'react'; + +import type {SERIES_TYPE} from '../../constants'; +import type {MeaningfulAny} from '../misc'; + +import type {BaseSeries} from './base'; + +/** One colored zone on the arc. The zone spans from the previous stop's value (or min) up to this stop's value. The last stop must equal max. */ +export interface ThresholdStop { + value: number; + /** Falls back to palette if omitted. */ + color?: string; + /** Shown in tooltip, e.g. "Critical". */ + label?: string; +} + +/** Data passed to custom content renderer callbacks. */ +export interface GaugeSeriesArg { + value: number; + min: number; + max: number; + unit?: string; + name?: string; + color: string; + id: string; +} + +/** + * Slots for injecting arbitrary React content into the gauge layout. + * 'inner' renders inside the inner circle (center area). + * 'below' renders below the arc, outside the SVG, in a full-width div. + */ +export interface GaugeCustomContent { + /** Rendered inside the SVG via a foreignObject in the inner circle. */ + inner?: (series: GaugeSeriesArg) => React.ReactNode; + /** Rendered in a block div beneath the SVG element. */ + below?: (series: GaugeSeriesArg) => React.ReactNode; +} + +export interface ArcConfig { + /** + * Total sweep in degrees. 180 = semicircle (default), 270 = three-quarter arc. + * @default 180 + */ + sweepAngle?: number; + /** + * 'discrete' = per-zone segments; 'continuous' = linear gradient. + * @default 'discrete' + */ + trackStyle?: 'discrete' | 'continuous'; + /** + * Track width as fraction of outerRadius (0–1) or absolute px. + * @default 0.12 + */ + thickness?: number | string; + /** + * Rounded caps on arc segments. + * @default 4 + */ + cornerRadius?: number; +} + +export interface PointerConfig { + /** + * marker — tick/dot on the arc circumference; frees the entire inner + * circle for text and custom content. DEFAULT. + * needle — classic rotating arrow anchored at center pivot. + * solid — filled arc from min to current value (progress-bar style). + * @default 'marker' + */ + type?: 'marker' | 'needle' | 'solid'; + /** Defaults to series color. */ + color?: string; + /** + * marker: dot diameter in px. @default 8 + * needle: length as fraction of inner radius (0–1). @default 0.85 + */ + size?: number; +} + +/** @default 'clamp' */ +export type OverflowBehavior = 'clamp' | 'wrap' | 'extend'; + +export interface GaugeSeries extends BaseSeries { + type: typeof SERIES_TYPE.Gauge; + + value: number; + /** @default 0 */ + min?: number; + /** @default 100 */ + max?: number; + + /** + * Colored arc zones. Each stop's value is the zone's upper boundary. + * Zones: [min, stops[0].value), [stops[0].value, stops[1].value), … + * The last stop's value should equal max. + */ + thresholds?: ThresholdStop[]; + + /** Renders a secondary marker on the arc at this value (target line). */ + target?: number; + + /** Unit string appended to the primary label, e.g. "°C" or "%". */ + unit?: string; + + /** Custom HTML slots — inside the inner circle and/or below the gauge. */ + customContent?: GaugeCustomContent; + + arc?: ArcConfig; + pointer?: PointerConfig; + + /** @default 'clamp' */ + overflow?: OverflowBehavior; + + name?: string; + color?: string; + + legend?: { + enabled?: boolean; + }; + + /** Unused generic param kept for API consistency. */ + _phantom?: T; +} + +export const GAUGE_DEFAULTS = { + min: 0, + max: 100, + arc: { + sweepAngle: 180, + trackStyle: 'discrete' as const, + thickness: 0.12, + cornerRadius: 4, + }, + pointer: { + type: 'marker' as const, + size: 8, + }, + overflow: 'clamp' as const, +}; diff --git a/src/core/types/chart/series.ts b/src/core/types/chart/series.ts index ec79b0fe4..f7f5f8cd3 100644 --- a/src/core/types/chart/series.ts +++ b/src/core/types/chart/series.ts @@ -8,6 +8,7 @@ import type {AreaSeries, AreaSeriesData} from './area'; import type {BarXSeries, BarXSeriesData} from './bar-x'; import type {BarYSeries, BarYSeriesData} from './bar-y'; import type {FunnelSeries, FunnelSeriesData} from './funnel'; +import type {GaugeSeries} from './gauge'; import type {Halo} from './halo'; import type {HeatmapSeries, HeatmapSeriesData} from './heatmap'; import type {LineSeries, LineSeriesData} from './line'; @@ -22,6 +23,7 @@ import type {XRangeSeries, XRangeSeriesData} from './x-range'; export type ChartSeries = | ScatterSeries + | GaugeSeries | PieSeries | BarXSeries | BarYSeries diff --git a/src/core/types/chart/tooltip.ts b/src/core/types/chart/tooltip.ts index 0ddbfedcb..bf0cb37ec 100644 --- a/src/core/types/chart/tooltip.ts +++ b/src/core/types/chart/tooltip.ts @@ -8,6 +8,7 @@ import type {BarXSeries, BarXSeriesData} from './bar-x'; import type {BarYSeries, BarYSeriesData} from './bar-y'; import type {ValueFormat} from './base'; import type {FunnelSeries, FunnelSeriesData} from './funnel'; +import type {GaugeSeries} from './gauge'; import type {HeatmapSeries, HeatmapSeriesData} from './heatmap'; import type {LineSeries, LineSeriesData} from './line'; import type {PieSeries, PieSeriesData} from './pie'; @@ -28,6 +29,24 @@ export interface TooltipDataChunkBarY { series: BarYSeries; } +export interface TooltipDataChunkGauge { + data: { + value: number; + unit?: string; + zoneColor?: string; + zoneLabel?: string; + zoneMin?: number; + zoneMax?: number; + distanceToTarget?: number; + }; + series: { + type: GaugeSeries['type']; + id: string; + name: string; + color: string; + }; +} + export interface TooltipDataChunkPie { data: PieSeriesData; series: { @@ -111,6 +130,7 @@ export interface TooltipDataChunkXRange { export type TooltipDataChunk = ( | TooltipDataChunkBarX | TooltipDataChunkBarY + | TooltipDataChunkGauge | TooltipDataChunkPie | TooltipDataChunkScatter | TooltipDataChunkLine diff --git a/src/core/types/index.ts b/src/core/types/index.ts index 1de0c888e..c179c5246 100644 --- a/src/core/types/index.ts +++ b/src/core/types/index.ts @@ -12,6 +12,7 @@ export * from './chart/annotation'; export * from './chart/axis'; export * from './chart/base'; export * from './chart/chart'; +export * from './chart/gauge'; export * from './chart/legend'; export * from './chart/pie'; export * from './chart/scatter'; diff --git a/src/core/utils/get-closest-data.ts b/src/core/utils/get-closest-data.ts index ca6b66f2b..9cf9db3d3 100644 --- a/src/core/utils/get-closest-data.ts +++ b/src/core/utils/get-closest-data.ts @@ -24,6 +24,7 @@ import type {PreparedAreaData} from '../shapes/area/types'; import type {PreparedBarXData} from '../shapes/bar-x/types'; import type {PreparedBarYData} from '../shapes/bar-y/types'; import type {PreparedFunnelData} from '../shapes/funnel/types'; +import type {PreparedGaugeData} from '../shapes/gauge/types'; import type {PreparedHeatmapData} from '../shapes/heatmap/types'; import type {PreparedLineData} from '../shapes/line/types'; import type {PreparedPieData} from '../shapes/pie/types'; @@ -302,6 +303,52 @@ export function getClosestPoints(args: GetClosestPointsArgs): TooltipDataChunk[] break; } + case 'gauge': { + const gaugeList = list as unknown as PreparedGaugeData[]; + for (const item of gaugeList) { + const dx = pointerX - item.cx; + const dy = pointerY - item.cy; + const polarRadius = Math.sqrt(dx * dx + dy * dy); + + // Small extra margin to catch the marker overhang + if (polarRadius < item.innerRadius || polarRadius > item.outerRadius + 8) { + continue; + } + + // 0° = 12-o'clock, clockwise positive — matches pointOnArc convention + const angleDeg = Math.atan2(dx, -dy) * (180 / Math.PI); + if (angleDeg < item.startAngleDeg || angleDeg > item.endAngleDeg) { + continue; + } + + const zone = item.thresholdArcs.find( + (z) => angleDeg >= z.startDeg && angleDeg <= z.endDeg, + ); + + result.push({ + series: { + type: 'gauge' as const, + id: item.series.id, + name: item.series.name, + color: item.series.color, + }, + data: { + value: item.series.value, + unit: item.series.unit, + zoneColor: zone?.color ?? item.series.color, + zoneLabel: zone?.label, + zoneMin: zone?.zoneMin, + zoneMax: zone?.zoneMax, + distanceToTarget: + item.series.target !== undefined + ? item.series.target - item.series.value + : undefined, + }, + closest: true, + } as TooltipDataChunk); + } + break; + } case 'treemap': { const data = list as unknown as PreparedTreemapData[]; const closestPoint = data[0]?.leaves.find((l) => { diff --git a/src/core/utils/series-type-guards.ts b/src/core/utils/series-type-guards.ts index 33a554425..fa5f8b330 100644 --- a/src/core/utils/series-type-guards.ts +++ b/src/core/utils/series-type-guards.ts @@ -1,8 +1,15 @@ import type {ChartSeries} from '../../types'; -const CHARTS_WITHOUT_AXIS: ChartSeries['type'][] = ['pie', 'treemap', 'sankey', 'radar', 'funnel']; +const CHARTS_WITHOUT_AXIS: ChartSeries['type'][] = [ + 'gauge', + 'pie', + 'treemap', + 'sankey', + 'radar', + 'funnel', +]; -export type UnknownSeries = {type: ChartSeries['type']; data: unknown}; +export type UnknownSeries = {type: ChartSeries['type']; data?: unknown}; /** * Checks whether the series should be drawn with axes. diff --git a/src/core/utils/series/sorting.ts b/src/core/utils/series/sorting.ts index 4837911e5..8558ef25e 100644 --- a/src/core/utils/series/sorting.ts +++ b/src/core/utils/series/sorting.ts @@ -24,7 +24,11 @@ function applyAxisCategoriesOrder({ const axisCategories = getAxisCategories(axis) ?? []; const order = Object.fromEntries(axisCategories.map((value, index) => [value, index])); - const newSeriesData = series.data.reduce((acc, d) => { + const seriesWithData = series as T & {data: ChartSeriesData[]}; + if (!Array.isArray(seriesWithData.data)) { + return series; + } + const newSeriesData = seriesWithData.data.reduce((acc, d) => { const value = get(d, key); let newData: ChartSeriesData | undefined; @@ -51,7 +55,7 @@ function applyAxisCategoriesOrder({ return { ...series, data: newSeriesData, - }; + } as T; } export function getSortedSeriesData({ diff --git a/src/core/validation/index.ts b/src/core/validation/index.ts index ea3c67f5f..e6ee21bf8 100644 --- a/src/core/validation/index.ts +++ b/src/core/validation/index.ts @@ -463,7 +463,9 @@ export function validateData(data?: ChartData) { isEmpty(data) || isEmpty(data.series) || isEmpty(data.series.data) || - data.series.data.every((s) => isEmpty(s.data)) + data.series.data.every((s) => + s.type === 'gauge' ? false : isEmpty((s as {data?: unknown}).data), + ) ) { throw new ChartError({ code: CHART_ERROR_CODE.NO_DATA, @@ -475,7 +477,7 @@ export function validateData(data?: ChartData) { validateTooltip({tooltip: data.tooltip}); validateStackingAreaNullMode({series: data.series.data}); - if (data.series.data.some((s) => isEmpty(s.data))) { + if (data.series.data.some((s) => s.type !== 'gauge' && isEmpty((s as {data?: unknown}).data))) { throw new ChartError({ code: CHART_ERROR_CODE.INVALID_DATA, message: 'You should specify data for all series', diff --git a/src/core/zoom/zoom.ts b/src/core/zoom/zoom.ts index ea76be4f2..3a27cb6d2 100644 --- a/src/core/zoom/zoom.ts +++ b/src/core/zoom/zoom.ts @@ -66,7 +66,7 @@ function isValueInRange(args: { } function isPreparedZoomableSeries(series: PreparedSeries): series is PreparedZoomableSeries { - return Array.isArray(series.data); + return 'data' in series && Array.isArray((series as {data?: unknown}).data); } export function getZoomedSeriesData(args: { diff --git a/src/hooks/useShapes/gauge/index.tsx b/src/hooks/useShapes/gauge/index.tsx new file mode 100644 index 000000000..19a45d4aa --- /dev/null +++ b/src/hooks/useShapes/gauge/index.tsx @@ -0,0 +1,91 @@ +import React from 'react'; + +import type {Dispatch} from 'd3-dispatch'; + +import {renderGauge} from '~core/shapes/gauge/renderer'; +import type {PreparedGaugeData} from '~core/shapes/gauge/types'; + +import {block} from '../../../utils'; + +const b = block('gauge'); + +interface GaugeSeriesShapesProps { + preparedData: PreparedGaugeData[]; + dispatcher?: Dispatch; +} + +export function GaugeSeriesShapes({preparedData, dispatcher}: GaugeSeriesShapesProps) { + const ref = React.useRef(null); + + React.useEffect(() => { + if (!ref.current) { + return () => {}; + } + + return renderGauge({plot: ref.current}, preparedData, dispatcher); + }, [dispatcher, preparedData]); + + return ( + + + {preparedData.map((d) => { + const {textBox, series} = d; + if (!series.customContent?.inner) { + return null; + } + const seriesArg = { + value: series.value, + min: series.min, + max: series.max, + unit: series.unit, + name: series.name, + color: series.color, + id: series.id, + }; + return ( + + {series.customContent.inner(seriesArg)} + + ); + })} + + ); +} + +interface GaugeBelowContentProps { + preparedData: PreparedGaugeData[]; +} + +export function GaugeBelowContent({preparedData}: GaugeBelowContentProps) { + const items = preparedData.filter((d) => d.series.customContent?.below); + if (!items.length) { + return null; + } + + return ( + + {items.map((d) => { + const seriesArg = { + value: d.series.value, + min: d.series.min, + max: d.series.max, + unit: d.series.unit, + name: d.series.name, + color: d.series.color, + id: d.series.id, + }; + return ( +
+ {d.series.customContent!.below!(seriesArg)} +
+ ); + })} +
+ ); +} diff --git a/src/hooks/useShapes/index.tsx b/src/hooks/useShapes/index.tsx index 8fff22cb2..b096f209a 100644 --- a/src/hooks/useShapes/index.tsx +++ b/src/hooks/useShapes/index.tsx @@ -12,6 +12,7 @@ import type { PreparedBarXSeries, PreparedBarYSeries, PreparedFunnelSeries, + PreparedGaugeSeries, PreparedHeatmapSeries, PreparedLineSeries, PreparedPieSeries, @@ -29,6 +30,8 @@ import type {PreparedAreaData} from '~core/shapes/area/types'; import type {PreparedBarXData} from '~core/shapes/bar-x/types'; import type {PreparedBarYData} from '~core/shapes/bar-y/types'; import type {PreparedFunnelData} from '~core/shapes/funnel/types'; +import {prepareGaugeData} from '~core/shapes/gauge/prepare-data'; +import type {PreparedGaugeData} from '~core/shapes/gauge/types'; import type {PreparedHeatmapData} from '~core/shapes/heatmap/types'; import {prepareLineData} from '~core/shapes/line/prepare-data'; import type {PreparedLineData} from '~core/shapes/line/types'; @@ -53,6 +56,7 @@ import {AreaSeriesShapes} from './area'; import {BarXSeriesShapes, prepareBarXData} from './bar-x'; import {BarYSeriesShapes, prepareBarYData} from './bar-y'; import {FunnelSeriesShapes, prepareFunnelData} from './funnel'; +import {GaugeSeriesShapes} from './gauge'; import {HeatmapSeriesShapes, prepareHeatmapData} from './heatmap'; import {LineSeriesShapes} from './line'; import {PieSeriesShapes} from './pie'; @@ -69,6 +73,7 @@ import './styles.scss'; export type ShapeData = | PreparedBarXData | PreparedBarYData + | PreparedGaugeData | PreparedScatterData | PreparedLineData | PreparedPieData @@ -324,6 +329,22 @@ export async function getShapes(args: Args) { } break; } + case SERIES_TYPE.Gauge: { + const preparedData = prepareGaugeData({ + series: chartSeries as PreparedGaugeSeries[], + boundsWidth, + boundsHeight, + }); + shapes[index] = ( + + ); + shapesData.splice(index, 0, ...preparedData); + break; + } case SERIES_TYPE.Pie: { const preparedData = await preparePieData({ series: chartSeries as PreparedPieSeries[],