PageSourceSearch

https://novaukraine.org/wp-content/themes/novaukraine/assets/js/pillars-chart.js?ver=0.1.2

js novaukraine.org collected 2026-10-02 05:49:05 UTC 17,088 bytes, 422 lines download raw bytes

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.