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

mozilla / blurts-server / #13117

pending completion
#13117

push

circleci

Vinnl
Pretend HTMLElement.shadowRoot is always set

We generally only tend to access it after having called
this.attachShadow({ mode: 'open' }), and the error message about it
potentially being undefined at every location we access
this.shadowRoot is more noisy than helpful.

See also
https://github.com/mozilla/blurts-server/pull/2959#discussion_r1154023113
and
https://github.com/mozilla/blurts-server/pull/2959#discussion_r1154960095

282 of 1629 branches covered (17.31%)

Branch coverage included in aggregate %.

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

959 of 4374 relevant lines covered (21.93%)

1.83 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-50);
92
    display: flex;
93
    gap: var(--padding-sm);
94
    position: relative;
95
  }
96

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

104
  .circle-chart svg {
105
    border-radius: 50%;
106
    height: var(--chart-diameter, 10vw);
107
    min-height: 100px;
108
    min-width: 100px;
109
    width: var(--chart-diameter, 10vw);
110
  }
111

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

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

129
/**
130
 * @param {number} total
131
 * @param {number} value
132
 * @returns number
133
 */
134
const calcPercentage = (total, value) => {
×
135
  if (!total) {
×
136
    return 0
×
137
  }
138

139
  return parseFloat((value / total).toFixed(3))
×
140
}
141

142
const html = () => `
×
143
  ${styles}
144
  <figure class='circle-chart'></figure>
145
`
146

147
customElements.define('circle-chart', class extends HTMLElement {
×
148
  /** @type {Array<{ key: string; name: string; color: string; count: number; }> | null} */
149
  data
150
  /** @type {Element | null | undefined} */
151
  chartElement
152
  /** @type {string} */
153
  showPercentFor
154
  /** @type {string} */
155
  title
156
  /** @type {SVGSVGElement | null} */
157
  svg
158

159
  static get observedAttributes () {
160
    return [
×
161
      'data',
162
      'show-percent-for',
163
      'title'
164
    ]
165
  }
166

167
  constructor () {
168
    super()
×
169
    this.attachShadow({ mode: 'open' })
×
170

171
    // Chart properties
172
    this.data = null
×
173
    this.showPercentFor = ''
×
174
    this.title = ''
×
175

176
    this.svg = null
×
177
    this.total = 0
×
178
    this.updateTimeout = null
×
179

180
    this.render()
×
181
  }
182

183
  /**
184
   * @param {string} name
185
   * @param {string} oldValue
186
   * @param {string} newValue
187
   */
188
  attributeChangedCallback (name, oldValue, newValue) {
189
    if (newValue === 'undefined' || newValue === oldValue) {
×
190
      return
×
191
    }
192

193
    switch (name) {
×
194
      case 'data':
195
        this.data = JSON.parse(newValue)
×
196
        this.createOrUpdateChart()
×
197
        break
×
198
      case 'show-percent-for':
199
        this.showPercentFor = newValue
×
200
        break
×
201
      case 'title':
202
        this.title = newValue
×
203
        break
×
204
      default:
205
        console.warn(`Unhandled attribute: ${name}`)
×
206
    }
207
  }
208

209
  composeCircles () {
210
    let sliceOffset = 0
×
211
    /** @type {string[]} */
212
    const init = []
×
213
    return `
×
214
      ${this.data?.reduce((acc, curr) => {
215
        const percentage = calcPercentage(this.total, curr.count)
×
216
        const innerRadius = this.showPercentFor !== '' ? 0.85 : 0
×
217
        const strokeLength = CHART_CIRCUMFERENCE * percentage
×
218

219
        const circle = `
×
220
          <circle
221
            stroke='${curr.color}'
222
            stroke-dasharray='${strokeLength} ${CHART_CIRCUMFERENCE}'
223
            stroke-dashoffset='${-1 * CHART_CIRCUMFERENCE * sliceOffset}'
224
            stroke-width='${CHART_DIAMETER * (1 - innerRadius)}%'
225
          ></circle>
226
        `
227
        acc.push(circle)
×
228
        sliceOffset += percentage
×
229

230
        return acc
×
231
      }, init).join('')}
232
    `
233
  }
234

235
  createChartLabels () {
236
    return `
×
237
      ${this.title !== ''
×
238
        ? `<strong class='circle-chart-title'>${this.title}</strong>`
239
        : ''}
240
      ${this.data?.map(({ name, color }) => (
241
        `<label class='circle-chart-label' style='--color: ${color}'>${name}</label>`
×
242
      )).join('')}
243
    `
244
  }
245

246
  createCircleLabel () {
247
    const relevantItem = this.data?.find(d => d.key === this.showPercentFor)
×
248
    if (!relevantItem) {
×
249
      return ''
×
250
    }
251
    const percentage = calcPercentage(this.total, relevantItem.count)
×
252
    return `
×
253
      <text
254
        dy='${CHART_RADIUS * 0.15}'
255
        fill='${relevantItem.color}'
256
        font-size='${CHART_RADIUS * 0.4}'
257
        font-size='50'
258
        x='${CHART_RADIUS}'
259
        y='${CHART_RADIUS}'
260
      >
261
          ${Math.round(percentage * 100)}%
262
      </text>
263
    `
264
  }
265

266
  createChart () {
267
    // Create SVG with circles
268
    const circles = this.composeCircles()
×
269
    this.svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
×
270
    this.svg.setAttribute('viewBox', `0 0 ${CHART_DIAMETER} ${CHART_DIAMETER}`)
×
271
    this.svg.innerHTML = `
×
272
      ${circles}
273
      ${this.createCircleLabel()}
274
    `
275

276
    // Create labels
277
    this.labels = document.createElement('figcaption')
×
278
    this.labels.innerHTML = this.createChartLabels()
×
279

280
    // Add chart elements to DOM
281
    this.chartElement?.append(this.svg)
×
282
    this.chartElement?.append(this.labels)
×
283
  }
284

285
  updateChart () {
286
    if (!this.svg || !this.labels) {
×
287
      return
×
288
    }
289

290
    this.svg.innerHTML = `
×
291
      ${this.composeCircles()}
292
      ${this.createCircleLabel()}
293
    `
294
    this.labels.innerHTML = this.createChartLabels()
×
295
  }
296

297
  createOrUpdateChart () {
298
    if (this.updateTimeout) {
×
299
      clearTimeout(this.updateTimeout)
×
300
    }
301

302
    if (!this.data) {
×
303
      return
×
304
    }
305

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

308
    this.chartElement?.classList.add('updating')
×
309
    this.updateTimeout = setTimeout(() => {
×
310
      if (!this.svg) {
×
311
        this.createChart()
×
312
      } else {
313
        this.updateChart()
×
314
      }
315

316
      this.chartElement?.classList.remove('updating')
×
317
    }, this.svg ? CHART_UPDATE_DURATION : 0)
×
318
  }
319

320
  render () {
321
    if (this.shadowRoot) {
×
322
      this.shadowRoot.innerHTML = html()
×
323
    }
324
    this.chartElement = this.shadowRoot.querySelector('.circle-chart')
×
325
  }
326
})
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