1/** 2 * Statistics - 2-Level Pie Chart using D3.js 3 */ 4(function ($) { 5 'use strict'; 6 7 class PillarsChart { 8 constructor(element) { 9 this.$element = $(element); 10 this.$container = this.$element.find('.pillars-chart-container'); 11 this.$svg = this.$element.find('.pillars-chart'); 12 this.$tooltip = this.$element.find('.chart-tooltip'); 13 14 // Parse data from JSON 15 const dataScript = this.$element.find('.pillars-chart-data'); 16 const jsonText = dataScript.text().trim(); 17 18 if (!jsonText) { 19 console.error('Statistics: No data found'); 20 return; 21 } 22 23 try { 24 this.data = JSON.parse(jsonText); 25 } catch (e) { 26 console.error('Statistics: Error parsing JSON', e); 27 return; 28 } 29 30 this.isMobile = window.innerWidth < 768; 31 this.init(); 32 } 33 34 init() { 35 if (!this.data || this.data.length === 0) { 36 console.error('Statistics: No data or empty data array'); 37 return; 38 } 39 40 this.setupDimensions(); 41 this.processData(); 42 this.renderChart(); 43 this.setupResponsive(); 44 } 45 46 setupDimensions() { 47 const containerWidth = this.$container.width(); 48 49 if (this.isMobile) { 50 this.radius = 112; 51 this.chartSize = this.radius * 2; 52 this.padding = 0; 53 this.verticalPadding = 100; 54 this.width = containerWidth; 55 this.height = this.chartSize + (this.verticalPadding * 2); 56 } else { 57 this.radius = 255; 58 this.chartSize = this.radius * 2; 59 this.padding = 300; 60 this.verticalPadding = 120; 61 this.width = this.chartSize + this.padding; 62 this.height = this.chartSize + (this.verticalPadding * 2); 63 } 64 65 // Ring dimensions 66 this.innerRingInnerRadius = this.radius * 0.04; 67 this.innerRingOuterRadius = this.radius * 0.58; 68 this.outerRingInnerRadius = this.radius * 0.60; 69 this.outerRingOuterRadius = this.radius * 0.98; 70 } 71 72 processData() { 73 // Prepare data structure for D3 74 this.pillarsData = []; 75 this.categoriesData = []; 76 77 this.data.forEach((pillar, pillarIndex) => { 78 // Calculate total percentage for this pillar 79 const totalPercentage = pillar.categories.reduce((sum, cat) => sum + cat.percentage, 0); 80 81 this.pillarsData.push({ 82 title: pillar.title, 83 link: pillar.link, 84 color: pillar.color, 85 value: totalPercentage, 86 index: pillarIndex 87 }); 88 89 // Add categories with reference to parent pillar 90 pillar.categories.forEach((category, categoryIndex) => { 91 this.categoriesData.push({ 92 title: category.title, 93 percentage: category.percentage, 94 value: category.percentage, 95 pillar: pillar.title, 96 pillarIndex: pillarIndex, 97 pillarColor: pillar.color, 98 categoriesColor: pillar.categoriesColor || pillar.color, 99 pillarLink: pillar.link, 100 categoryIndex: categoryIndex, 101 totalCategories: pillar.categories.length 102 }); 103 }); 104 }); 105 } 106 107 renderChart() { 108 const self = this; 109 110 this.$svg.empty(); 111 112 const svg = d3.select(this.$svg[0]) 113 .attr('width', this.width) 114 .attr('height', this.height) 115 .append('g') 116 .attr('transform', `translate(${this.width / 2}, ${this.height / 2})`); 117 118 const pie = d3.pie() 119 .value(d => d.value) 120 .sort(null) 121 .startAngle(-2 * Math.PI / 3) 122 .endAngle(4 * Math.PI / 3); 123 124 const innerArc = d3.arc() 125 .innerRadius(this.innerRingInnerRadius) 126 .outerRadius(this.innerRingOuterRadius) 127 .cornerRadius(6) 128 .padAngle(0.035); 129 130 const outerArc = d3.arc() 131 .innerRadius(this.outerRingInnerRadius) 132 .outerRadius(this.outerRingOuterRadius) 133 .cornerRadius(6) 134 .padAngle(0.008); 135 136 // Render inner ring (pillars) 137 const innerSlices = svg.selectAll('.inner-slice') 138 .data(pie(this.pillarsData)) 139 .enter() 140 .append('g') 141 .attr('class', 'inner-slice') 142 .style('cursor', d => d.data.link ? 'pointer' : 'default') 143 .on('click', function(event, d) { 144 if (d.data.link) { 145 window.location.href = d.data.link; 146 } 147 }); 148 149 innerSlices.append('path') 150 .attr('d', innerArc) 151 .attr('fill', d => d.data.color) 152 .attr('stroke', '#fff') 153 .attr('stroke-width', 2) 154 .style('opacity', 1); 155 156 innerSlices.append('path') 157 .attr('d', innerArc) 158 .attr('fill', 'transparent') 159 .style('pointer-events', 'all'); 160 161 innerSlices.append('text') 162 .attr('transform', d => { 163 const midAngle = (d.startAngle + d.endAngle) / 2; 164 const arcAngle = d.endAngle - d.startAngle; 165 const fontSize = this.isMobile ? 8 : 14; 166 const textWidth = d.data.title.length * fontSize * 0.7; 167 const outerPosition = this.innerRingOuterRadius * 0.7; 168 const arcLengthAtOuter = outerPosition * arcAngle; 169 170 let labelRadius; 171 if (textWidth > arcLengthAtOuter * 0.9) { 172 labelRadius = this.innerRingOuterRadius * 0.75; 173 } else if (textWidth > arcLengthAtOuter * 0.7) { 174 labelRadius = this.innerRingOuterRadius * 0.65; 175 } else { 176 labelRadius = this.innerRingOuterRadius * 0.55; 177 } 178
179 const x = Math.cos(midAngle - Math.PI / 2) * labelRadius; 180 const y = Math.sin(midAngle - Math.PI / 2) * labelRadius; 181 return `translate(${x}, ${y})`; 182 }) 183 .attr('text-anchor', 'middle') 184 .attr('dominant-baseline', 'middle') 185 .attr('class', 'pillar-label') 186 .style('fill', d => { 187 // Use categories color for text 188 const pillar = self.data[d.data.index]; 189 return pillar.categoriesColor || pillar.color; 190 }) 191 .style('font-size', this.isMobile ? '8px' : '14px') 192 .style('font-weight', '700') 193 .style('font-family', 'Mulish, sans-serif') 194 .style('text-transform', 'capitalize') 195 .style('letter-spacing', '0.5px') 196 .style('pointer-events', 'none') 197 .text(d => d.data.title); 198 199 // Render outer ring (categories) 200 const outerSlices = svg.selectAll('.outer-slice') 201 .data(pie(this.categoriesData)) 202 .enter() 203 .append('g') 204 .attr('class', 'outer-slice') 205 .style('cursor', d => d.data.pillarLink ? 'pointer' : 'default') 206 .on('click', function(event, d) { 207 if (d.data.pillarLink) { 208 window.location.href = d.data.pillarLink; 209 } 210 }); 211 212 outerSlices.append('path') 213 .attr('d', outerArc) 214 .attr('fill', d => d.data.categoriesColor) 215 .attr('stroke', '#fff') 216 .attr('stroke-width', 2); 217 218 // Add category labels and percentages 219 this.addCategoryLabels(svg, pie, outerArc, outerSlices); 220 } 221 222 addCategoryLabels(svg, pie, outerArc, outerSlices) { 223 const outerRadius = this.outerRingOuterRadius; 224 225 const labels = svg.selectAll('.category-label-group') 226 .data(pie(this.categoriesData)) 227 .enter() 228 .append('g') 229 .attr('class', 'category-label-group') 230 .style('opacity', 1); 231 232 labels.append('polyline') 233 .attr('class', 'label-line') 234 .attr('points', d => { 235 const midAngle = d.startAngle + (d.endAngle - d.startAngle) / 2; 236 const cosAngle = Math.cos(midAngle - Math.PI / 2); 237 const sinAngle = Math.sin(midAngle - Math.PI / 2); 238 const isTopOrBottom = Math.abs(sinAngle) > Math.abs(cosAngle); 239 240 let breakDistance, labelDistance; 241 if (this.isMobile) { 242 breakDistance = isTopOrBottom ? 45 : 20; 243 labelDistance = isTopOrBottom ? 65 : 45; 244 } else { 245 breakDistance = 35; 246 labelDistance = 120; 247 } 248 249 const x1 = cosAngle * outerRadius; 250 const y1 = sinAngle * outerRadius; 251 const x2 = cosAngle * (outerRadius + breakDistance); 252 const y2 = sinAngle * (outerRadius + breakDistance); 253 const isRightSide = cosAngle > 0; 254 const x3 = x2 + (isRightSide ? labelDistance : -labelDistance); 255 const y3 = y2; 256 257 return `${x1},${y1} ${x2},${y2} ${x3},${y3}`; 258 }) 259 .attr('stroke', '#02113A') 260 .attr('stroke-width', 1) 261 .attr('stroke-linecap', 'round') 262 .attr('stroke-linejoin', 'round') 263 .style('stroke-dasharray', 'none') 264 .attr('fill', 'none'); 265 266 labels.append('text') 267 .attr('transform', d => { 268 const midAngle = d.startAngle + (d.endAngle - d.startAngle) / 2; 269 const cosAngle = Math.cos(midAngle - Math.PI / 2); 270 const sinAngle = Math.sin(midAngle - Math.PI / 2); 271 const isTopOrBottom = Math.abs(sinAngle) > Math.abs(cosAngle); 272 273 let breakDistance, labelDistance; 274 if (this.isMobile) { 275 breakDistance = isTopOrBottom ? 45 : 20; 276 labelDistance = isTopOrBottom ? 65 : 45; 277 } else { 278 breakDistance = 35; 279 labelDistance = 120; 280 } 281 282 const x2 = cosAngle * (outerRadius + breakDistance); 283 const y2 = sinAngle * (outerRadius + breakDistance); 284 const isRightSide = cosAngle > 0; 285 const x = x2 + (isRightSide ? labelDistance : -labelDistance); 286 const y = y2 - 8; 287 return `translate(${x}, ${y})`; 288 }) 289 .attr('text-anchor', d => { 290 const midAngle = d.startAngle + (d.endAngle - d.startAngle) / 2;
291 const isRightSide = Math.cos(midAngle - Math.PI / 2) > 0; 292 return isRightSide ? 'end' : 'start'; 293 }) 294 .attr('dominant-baseline', 'auto') 295 .attr('class', 'category-percentage') 296 .style('font-family', 'Mulish, sans-serif') 297 .style('font-weight', '400') 298 .style('font-size', '16px') 299 .style('fill', '#27326F') 300 .text(d => d.data.percentage.toFixed(2) + '%'); 301 302 labels.each((d, i, nodes) => { 303 const midAngle = d.startAngle + (d.endAngle - d.startAngle) / 2; 304 const cosAngle = Math.cos(midAngle - Math.PI / 2); 305 const sinAngle = Math.sin(midAngle - Math.PI / 2); 306 const isTopOrBottom = Math.abs(sinAngle) > Math.abs(cosAngle); 307 308 let breakDistance, labelDistance; 309 if (this.isMobile) { 310 breakDistance = isTopOrBottom ? 45 : 20; 311 labelDistance = isTopOrBottom ? 65 : 45; 312 } else { 313 breakDistance = 35; 314 labelDistance = 120; 315 } 316 317 const x2 = cosAngle * (outerRadius + breakDistance); 318 const y2 = sinAngle * (outerRadius + breakDistance); 319 const isRightSide = cosAngle > 0; 320 const x = x2 + (isRightSide ? labelDistance : -labelDistance); 321 const y = y2 + (this.isMobile ? 5 : 4); 322 323 const textElement = d3.select(nodes[i]) 324 .append('text') 325 .attr('transform', `translate(${x}, ${y})`) 326 .attr('text-anchor', isRightSide ? 'end' : 'start') 327 .attr('dominant-baseline', 'hanging') 328 .attr('class', 'category-label') 329 .style('font-family', 'Mulish, sans-serif') 330 .style('text-transform', 'uppercase') 331 .style('font-size', '8px') 332 .style('font-weight', '400') 333 .style('letter-spacing', '0.5px') 334 .style('fill', '#02113A'); 335 336 if (this.isMobile) { 337 const maxCharsPerLine = 12; 338 const words = d.data.title.split(' '); 339 let line = ''; 340 let lineNumber = 0; 341 const lineHeight = 10; 342 343 words.forEach(word => { 344 const testLine = line + (line.length > 0 ? ' ' : '') + word; 345 if (testLine.length >
345 maxCharsPerLine && line.length > 0) { 346 textElement.append('tspan') 347 .attr('x', 0) 348 .attr('dy', lineNumber === 0 ? 0 : lineHeight) 349 .text(line); 350 line = word; 351 lineNumber++; 352 } else { 353 line = testLine; 354 } 355 }); 356 357 textElement.append('tspan') 358 .attr('x', 0) 359 .attr('dy', lineNumber === 0 ? 0 : lineHeight) 360 .text(line); 361 } else { 362 textElement.text(d.data.title); 363 } 364 }); 365 366 if (!this.isMobile) { 367 outerSlices.on('mouseover', function(event, d) { 368 const labelGroup = d3.selectAll('.category-label-group').filter((labelData, i) => i === d.index); 369 370 labelGroup.select('.category-percentage') 371 .transition() 372 .duration(200) 373 .style('font-size', '18px'); 374 375 labelGroup.select('.category-label') 376 .transition() 377 .duration(200) 378 .style('font-size', '10px'); 379 }) 380 .on('mouseout', function() { 381 d3.selectAll('.category-label-group .category-percentage') 382 .transition() 383 .duration(200) 384 .style('font-size', '16px'); 385 386 d3.selectAll('.category-label-group .category-label') 387 .transition() 388 .duration(200) 389 .style('font-size', '8px'); 390 }); 391 } 392 } 393 394 setupResponsive() { 395 let resizeTimer; 396 $(window).on('resize', () => { 397 clearTimeout(resizeTimer); 398 resizeTimer = setTimeout(() => { 399 const wasMobile = this.isMobile; 400 this.isMobile = window.innerWidth < 768; 401 402 if (wasMobile !== this.isMobile) { 403 this.setupDimensions(); 404 this.renderChart(); 405 } 406 }, 250); 407 }); 408 } 409 } 410 411 $(document).ready(function () { 412 if (typeof d3 === 'undefined') { 413 console.error('D3.js is not loaded. Statistics cannot be rendered.'); 414 return; 415 } 416 417 $('.nu-pillars-chart-section').each(function () { 418 new PillarsChart(this); 419 }); 420 }); 421 422})(jQuery);
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.