• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

mozilla / blurts-server / 534b3dde-f65c-4835-9320-5cff56868614

pending completion
534b3dde-f65c-4835-9320-5cff56868614

push

circleci

GitHub
Merge pull request #2764 from mozilla/MNTOR-1045-Circle-graph-component

282 of 1229 branches covered (22.95%)

Branch coverage included in aggregate %.

71 of 71 new or added lines in 1 file covered. (100.0%)

959 of 3292 relevant lines covered (29.13%)

4.73 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

0.0
/src/client/js/components/circle-chart.js
1
/* This Source Code Form is subject to the terms of the Mozilla Public
2
 * License, v. 2.0. If a copy of the MPL was not distributed with this
3
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4

5
/**
6
 * Circle chart
7
 *
8
 * Example usage:
9
 * ```
10
 * <circle-chart
11
 *   title='Circle chart'
12
 *   data=`${JSON.stringify([
13
 *     {
14
 *       key: 'resolved',
15
 *       name: 'Resolved',
16
 *       count: 0,
17
 *       color: '#9059ff'
18
 *     },
19
 *     {
20
 *       key: 'unresolved',
21
 *       name: 'Unresolved',
22
 *       count: 10,
23
 *       color: '#321c64'
24
 *     }
25
 *   ])}`
26
 *   show-percent-for='resolved'
27
 * >
28
 * </circle-chart>
29
 * ```
30
 *
31
 * Circle chart JSON schema:
32
 * ```
33
 * {
34
 *   "title": {
35
 *     "type": "string",
36
 *     "required": "false"
37
 *   },
38
 *   "data": {
39
 *     "type": "array",
40
 *     "items": {
41
 *       "type": "object",
42
 *       "properties": {
43
 *         "key": "string",
44
 *         "name": "string",
45
 *         "count": "number",
46
 *         "color": "hexcolor"
47
 *       },
48
 *       "required": [
49
 *         "key",
50
 *         "name",
51
 *         "count"
52
 *       ],
53
 *       "additionalProperties": false
54
 *     }
55
 *   },
56
 *   "show-percent-for": {
57
 *     "type": "string", // has to match key of an item in `data.items`
58
 *     "required": false,
59
 *   }
60
 * }
61
 * ```
62
 */
63

64
const CHART_RADIUS = 50
×
65
const CHART_DIAMETER = CHART_RADIUS * 2
×
66
const CHART_CIRCUMFERENCE = Math.PI * CHART_DIAMETER
×
67
const CHART_UPDATE_DURATION = 250
×
68

69
const styles = `
×
70
<style>
71
  .circle-chart {
72
    align-items: center;
73
    display: flex;
74
    gap: var(--padding-md);
75
    margin: 0;
76
    transition: opacity ${CHART_UPDATE_DURATION * 0.5}ms ease;
77
  }
78

79
  .circle-chart.updating {
80
    opacity: 0
81
  }
82

83
  .circle-chart-title {
84
    display: block;
85
    font-family: Inter, Inter-fallback, sans-serif;
86
    margin-bottom: var(--padding-xs);
87
  }
88

89
  .circle-chart-label {
90
    align-items: center;
91
    color: var(--gray-40);
92
    display: flex;
93
    font-size: 0.875rem;
94
    gap: var(--padding-sm);
95
    position: relative;
96
  }
97

98
  .circle-chart-label::before {
99
    color: inherit;
100
    content: '\\2B24'; /* Black Large Circle */
101
    font-size: 0.65em;
102
    padding-bottom: 0.175em;
103
  }
104

105
  .circle-chart svg {
106
    border-radius: 50%;
107
    height: 10vw;
108
    min-height: 100px;
109
    min-width: 100px;
110
    width: 10vw;
111
  }
112

113
  .circle-chart circle {
114
    cx: 50%;
115
    cy: 50%;
116
    fill: none;
117
    r: 50%;
118
    transform: rotate(-90deg);
119
    transform-origin: center;
120
  }
121

122
  .circle-chart text {
123
    font-family: metropolis, sans-serif;
124
    font-weight: 700;
125
    text-anchor: middle;
126
  }
127
</style>
128
`
129

130
const calcPercentage = (total, value) => {
×
131
  if (!total) {
×
132
    return 0
×
133
  }
134

135
  return parseFloat((value / total).toFixed(3, 10))
×
136
}
137

138
const html = () => `
×
139
  ${styles}
140
  <figure class='circle-chart'></figure>
141
`
142

143
customElements.define('circle-chart', class extends HTMLElement {
×
144
  static get observedAttributes () {
145
    return [
×
146
      'data',
147
      'is-donut',
148
      'show-percent-for',
149
      'title'
150
    ]
151
  }
152

153
  constructor () {
154
    super()
×
155
    this.attachShadow({ mode: 'open' })
×
156

157
    // Chart properties
158
    this.data = null
×
159
    this.showPercentFor = ''
×
160
    this.title = ''
×
161

162
    this.svg = null
×
163
    this.total = 0
×
164
    this.updateTimeout = null
×
165

166
    this.render()
×
167
  }
168

169
  attributeChangedCallback (name, oldValue, newValue) {
170
    if (newValue === 'undefined' || newValue === oldValue) {
×
171
      return
×
172
    }
173

174
    switch (name) {
×
175
      case 'data':
176
        this.data = JSON.parse(newValue)
×
177
        this.createOrUpdateChart()
×
178
        break
×
179
      case 'show-percent-for':
180
        this.showPercentFor = newValue
×
181
        break
×
182
      case 'title':
183
        this.title = newValue
×
184
        break
×
185
      default:
186
        console.warning(`Unhandled attribute: ${name}`)
×
187
    }
188
  }
189

190
  composeCircles () {
191
    let sliceOffset = 0
×
192
    return `
×
193
      ${this.data.reduce((acc, curr) => {
194
        const percentage = calcPercentage(this.total, curr.count)
×
195
        const innerRadius = this.showPercentFor !== '' ? 0.85 : 0
×
196
        const strokeLength = CHART_CIRCUMFERENCE * percentage
×
197

198
        const circle = `
×
199
          <circle
200
            stroke='${curr.color}'
201
            stroke-dasharray='${strokeLength} ${CHART_CIRCUMFERENCE}'
202
            stroke-dashoffset='${-1 * CHART_CIRCUMFERENCE * sliceOffset}'
203
            stroke-width='${CHART_DIAMETER * (1 - innerRadius)}%'
204
          ></circle>
205
        `
206
        acc.push(circle)
×
207
        sliceOffset += percentage
×
208

209
        return acc
×
210
      }, []).join('')}
211
    `
212
  }
213

214
  createChartLabels () {
215
    return `
×
216
      ${this.title !== ''
×
217
        ? `<strong class='circle-chart-title'>${this.title}</strong>`
218
        : ''}
219
      ${this.data.map(({ name, color }) => (
220
        `<label class='circle-chart-label' style='color: ${color}'>${name}</label>`
×
221
      )).join('')}
222
    `
223
  }
224

225
  createCircleLabel () {
226
    const relevantItem = this.data.find(d => d.key === this.showPercentFor)
×
227
    if (!relevantItem) {
×
228
      return ''
×
229
    }
230
    const percentage = calcPercentage(this.total, relevantItem.count)
×
231
    return `
×
232
      <text
233
        dy='${CHART_RADIUS * 0.15}'
234
        fill='${relevantItem.color}'
235
        font-size='${CHART_RADIUS * 0.4}'
236
        font-size='50'
237
        x='${CHART_RADIUS}'
238
        y='${CHART_RADIUS}'
239
      >
240
          ${Math.round(percentage * 100)}%
241
      </text>
242
    `
243
  }
244

245
  createChart () {
246
    // Create SVG with circles
247
    const circles = this.composeCircles()
×
248
    this.svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
×
249
    this.svg.setAttribute('viewBox', `0 0 ${CHART_DIAMETER} ${CHART_DIAMETER}`)
×
250
    this.svg.innerHTML = `
×
251
      ${circles}
252
      ${this.createCircleLabel()}
253
    `
254

255
    // Create labels
256
    this.labels = document.createElement('figcaption')
×
257
    this.labels.innerHTML = this.createChartLabels()
×
258

259
    // Add chart elements to DOM
260
    this.chartElement.append(this.svg)
×
261
    this.chartElement.append(this.labels)
×
262
  }
263

264
  updateChart () {
265
    this.svg.innerHTML = `
×
266
      ${this.composeCircles()}
267
      ${this.createCircleLabel()}
268
    `
269
    this.labels.innerHTML = this.createChartLabels()
×
270
  }
271

272
  createOrUpdateChart () {
273
    if (this.updateTimeout) {
×
274
      clearTimeout(this.updateTimeout)
×
275
    }
276

277
    if (!this.data) {
×
278
      return
×
279
    }
280

281
    this.total = this.data.reduce((acc, curr) => acc + curr.count, 0)
×
282

283
    this.chartElement.classList.add('updating')
×
284
    this.updateTimeout = setTimeout(() => {
×
285
      if (!this.svg) {
×
286
        this.createChart()
×
287
      } else {
288
        this.updateChart()
×
289
      }
290

291
      this.chartElement.classList.remove('updating')
×
292
    }, this.svg ? CHART_UPDATE_DURATION : 0)
×
293
  }
294

295
  render () {
296
    this.shadowRoot.innerHTML = html()
×
297
    this.chartElement = this.shadowRoot.querySelector('.circle-chart')
×
298
  }
299
})
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc