Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(charts): add segmented gauge #1614

Open
wants to merge 12 commits into
base: master
Choose a base branch
from
9 changes: 9 additions & 0 deletions packages/core/scss/graphs/_gauge-segmented.scss
@@ -0,0 +1,9 @@
@use '@carbon/styles/scss/theme' as *;

.#{$prefix}--#{$charts-prefix}--gauge-segmented {
overflow: visible;

g.pointer path {
fill: $background-inverse;
}
}
1 change: 1 addition & 0 deletions packages/core/scss/graphs/index.scss
Expand Up @@ -10,6 +10,7 @@
@import './tree';
@import './treemap';
@import './gauge';
@import './gauge-segmented';
@import './pie';
@import './lollipop';
@import './circle-pack';
Expand Down
33 changes: 33 additions & 0 deletions packages/core/src/charts/gauge-segmented.ts
@@ -0,0 +1,33 @@
import { Chart } from '@/chart'
import { options } from '@/configuration'
import { mergeDefaultChartOptions } from '@/tools'
import type { ChartConfig } from '@/interfaces/model'
import type { GaugeChartOptions } from '@/interfaces/charts'
import { PieChartModel } from '@/model/pie'
import type { Component } from '@/components/component'
import { EXPERIMENTAL_SegmentedGauge } from '@/components/graphs/gauge-segmented'

export class EXPERIMENTAL_SegmentedGaugeChart extends Chart {
model = new PieChartModel(this.services)
constructor(holder: HTMLDivElement, chartConfigs: ChartConfig<GaugeChartOptions>) {
super(holder, chartConfigs)

// Merge the default options for this chart
// With the user provided options
this.model.setOptions(mergeDefaultChartOptions(options.gaugeChart, chartConfigs.options))

// Initialize data, services, components etc.
this.init(holder, chartConfigs)
}

getComponents() {
// Specify what to render inside the graph-frame
const graphFrameComponents: Component[] = [
new EXPERIMENTAL_SegmentedGauge(this.model, this.services)
]

const components: Component[] = this.getChartComponents(graphFrameComponents)

return components
}
}
2 changes: 2 additions & 0 deletions packages/core/src/charts/index.ts
Expand Up @@ -8,6 +8,7 @@ import { CirclePackChart } from './circle-pack'
import { ComboChart } from './combo'
import { DonutChart } from './donut'
import { GaugeChart } from './gauge'
import { EXPERIMENTAL_SegmentedGaugeChart } from './gauge-segmented'
import { GroupedBarChart } from './bar-grouped'
import { HeatmapChart } from './heatmap'
import { HistogramChart } from './histogram'
Expand Down Expand Up @@ -35,6 +36,7 @@ export {
ComboChart,
DonutChart,
GaugeChart,
EXPERIMENTAL_SegmentedGaugeChart,
GroupedBarChart,
HeatmapChart,
HistogramChart,
Expand Down
183 changes: 183 additions & 0 deletions packages/core/src/components/graphs/gauge-segmented.ts
@@ -0,0 +1,183 @@
import { arc as d3Arc, line, scaleLinear } from 'd3'

import { Component } from '@/components/component'
import { DOMUtils } from '@/services/essentials/dom-utils'
import { RenderTypes } from '@/interfaces/enums'
import { segmentedGauge as segmentedGaugeConfigs } from '@/configuration'

export class EXPERIMENTAL_SegmentedGauge extends Component {
type = 'gauge-segmented'
renderType = RenderTypes.SVG

// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
// eslint-disable-next-line @typescript-eslint/no-unused-vars
render(animate = true) {
const self = this

const svg = this.getComponentContainer().attr('width', '100%').attr('height', '100%')

// Get width & height of main SVG
const { width, height } = DOMUtils.getSVGElementSize(this.parent as any, {
useAttrs: true
})

if (width === 0 || height === 0) {
return
}

/*
* Determine radius
*/
let radius = Math.min(width, height)
if (width < height) {
radius = width / 2
} else if (radius * 2 > Math.max(width, height)) {
radius = Math.max(width, height) / 2
}
// Have a fallback min-radius
radius = Math.max(radius, 65)

// Determine slice thickness based on available width
const sliceThickness = Math.min(
(width / 400) * segmentedGaugeConfigs.sliceThickness,
segmentedGaugeConfigs.sliceThickness
)
const arc = d3Arc()
.innerRadius(radius - segmentedGaugeConfigs.sliceMargin - sliceThickness)
.outerRadius(radius - segmentedGaugeConfigs.sliceMargin)
.startAngle(function (d: any, i: number) {
const ratio = d * i
return self.convertDegreeToRadian(segmentedGaugeConfigs.startAngle + ratio * 180)
})
.endAngle(function (d: any, i: number) {
const ratio = d * (i + 1)
return self.convertDegreeToRadian(segmentedGaugeConfigs.startAngle + ratio * 180)
})
.padAngle(0.02)

const arcs = DOMUtils.appendOrSelect(svg, 'g.arcs').attr(
'transform',
`translate(${radius}, ${radius})`
)

// Update data on all bars
const numberOfTicks = 3
const arcPaths = arcs.selectAll('path').data(Array(numberOfTicks).fill(1 / numberOfTicks))

// Remove bars that are no longer needed
arcPaths.exit().attr('opacity', 0).remove()

// Add the paths that need to be introduced
const arcPathsEnter = arcPaths.enter().append('path')

arcPathsEnter
.merge(arcPaths as any)
// .attr('class', () =>
// this.model.getColorClassName({
// classNameTypes: [ColorClassNameTypes.FILL],
// dataGroupName: Math.random()
// })
// )
// .style('fill', () => getProperty(this.getOptions(), 'color', 'scale', 'value'))
.attr('fill', function (_, i) {
if (i === 0) {
return 'var(--cds-support-success, #42be65)'
} else if (i === 1) {
return 'var(--cds-support-warning, #f1c21b)'
} else {
return 'var(--cds-support-error, #fa4d56)'
}
})
.attr('d', arc)

const lineData = [
[segmentedGaugeConfigs.pointerWidth / 2, 0],
[0, -(radius * 0.95)],
[-(segmentedGaugeConfigs.pointerWidth / 2), 0],
[0, segmentedGaugeConfigs.pointerEndDistance],
[segmentedGaugeConfigs.pointerWidth / 2, 0]
]

// Create the scale
const startValue = 10
const endValue = 20
const scale = scaleLinear().range([0, 1]).domain([startValue, endValue])

const ticksGroup = DOMUtils.appendOrSelect(svg, 'g.ticks').attr(
'transform',
`translate(${radius}, ${radius})`
)

const ticks = scale.ticks(numberOfTicks)

// Update data on all tick labels
const tickLabels = ticksGroup.selectAll('text.tick').data(ticks)

// Remove labels that are no longer needed
tickLabels.exit().remove()

// Add the paths that need to be introduced
const tickLabelsEnter = tickLabels.enter().append('text')

tickLabelsEnter
.merge(tickLabels as any)
.classed('tick', true)
.attr('transform', function (d) {
const ratio = scale(d)
const newAngle =
segmentedGaugeConfigs.startAngle +
ratio * (segmentedGaugeConfigs.endAngle - segmentedGaugeConfigs.startAngle)
return (
'rotate(' +
newAngle +
') translate(0,' +
(segmentedGaugeConfigs.ticksDistance - radius) +
')'
)
})
.attr('text-anchor', function (_, i) {
if (i === ticks.length - 1) {
return 'end'
} else if (ticks.length % 2 !== 0 && i + 1 === (ticks.length + 1) / 2) {
return 'middle'
}

return null
})
.text((d) => d)

const pointerLine = line()
const pointerGroup = DOMUtils.appendOrSelect(svg, 'g.pointer')
.data([lineData])
.attr('transform', `translate(${radius}, ${radius})`)

const ratio = scale(Math.max(startValue, 12))
const newAngle = segmentedGaugeConfigs.startAngle + ratio * 180

// Update the pointer
const pointerInitialRender = pointerGroup.selectAll('path').size() === 0
const pointerPath = DOMUtils.appendOrSelect(pointerGroup, 'path').attr('d', pointerLine as any)

// If first render, set initial value to beginning of gauge
if (pointerInitialRender) {
pointerPath.attr('transform', 'rotate(-90)')
}

// Add transition
pointerPath
.transition()
.call((t: any) =>
this.services.transitions.setupTransition({
transition: t,
name: 'pointer-update',
animate: pointerInitialRender ? true : animate
})
)
.attr('transform', 'rotate(' + newAngle + ')')
}

private convertDegreeToRadian(deg) {
return (deg * Math.PI) / 180
}
}
2 changes: 1 addition & 1 deletion packages/core/src/components/graphs/gauge.ts
Expand Up @@ -397,4 +397,4 @@ export class Gauge extends Component {

return radius
}
}
}