PageSourceSearch

https://solarintelligence.ai/chart-utils.js?v=11

js solarintelligence.ai collected 2026-10-05 12:50:33 UTC 116,912 bytes, 2,735 lines download raw bytes

1function getChartTheme(isDark) {
2    if (isDark) {
3        return {
4            title: '#e0e4ea',
5            text: 'rgba(255, 255, 255, 0.72)',
6            textMuted: 'rgba(255, 255, 255, 0.58)',
7            textStrong: '#e0e4ea',
8            axisLine: 'rgba(255, 255, 255, 0.15)',
9            gridLine: 'rgba(255, 255, 255, 0.1)',
10            crosshair: 'rgba(255, 255, 255, 0.35)',
11            btnBg: 'rgba(255, 255, 255, 0.08)',
12            btnBgHover: 'rgba(255, 255, 255, 0.14)',
13            btnStroke: 'rgba(255, 255, 255, 0.15)',
14            btnText: '#e0e4ea',
15            resetBg: 'rgba(251, 191, 36, 0.12)',
16            resetBgHover: 'rgba(251, 191, 36, 0.2)',
17            resetStroke: '#FFB74D',
18            resetText: '#fcd34d',
19            sliceStroke: '#161f33',
20            dotStroke: '#161f33',
21            hoverStroke: 'rgba(255, 255, 255, 0.45)',
22            barLabelShadow: '1px 1px 2px rgba(0,0,0,0.7)',
23            stackTotalShadow: '1px 1px 2px rgba(0,0,0,0.5)',
24            exportBg: '#161f33',
25            inputBg: '#161f33',
26            inputBorder: '#3b82f6',
27            inputText: '#e0e4ea',
28            notesBg: 'rgba(255, 255, 255, 0.06)',
29            notesBorder: '#93c5fd',
30            notesText: 'rgba(255, 255, 255, 0.58)',
31            notesTitle: '#93c5fd',
32        };
33    }
34    return {
35        title: '#1e293b',
36        text: '#374151',
37        textMuted: '#6b7280',
38        textStrong: '#1f2937',
39        axisLine: '#d1d5db',
40        gridLine: '#d1d5db',
41        crosshair: '#666',
42        btnBg: '#f3f4f6',
43        btnBgHover: '#e5e7eb',
44        btnStroke: '#d1d5db',
45        btnText: '#374151',
46        resetBg: '#fef3c7',
47        resetBgHover: '#fde68a',
48        resetStroke: '#fbbf24',
49        resetText: '#92400e',
50        sliceStroke: '#fff',
51        dotStroke: '#fff',
52        hoverStroke: '#333',
53        barLabelShadow: '1px 1px 2px rgba(0,0,0,0.7)',
54        stackTotalShadow: '1px 1px 2px rgba(255,255,255,0.8)',
55        exportBg: '#ffffff',
56        inputBg: 'white',
57        inputBorder: '#3b82f6',
58        inputText: '#1e293b',
59        notesBg: '#f8f9fa',
60        notesBorder: '#6366f1',
61        notesText: '#6b7280',
62        notesTitle: '#6366f1',
63    };
64}
65
66function resolveChartTheme(containerId, plotData) {
67    const el = document.getElementById(containerId);
68    const mode = (plotData && plotData.chartTheme)
69        || (el && el.getAttribute('data-chart-theme'))
70        || 'light';
71    return getChartTheme(mode === 'dark');
72}
73
74function styleAxisChrome(selection, theme) {
75    selection.selectAll('.axis text')
76        .style('fill', theme.text)
77        .style('font-family', "'Inter', 'Segoe UI', 'Roboto', sans-serif");
78    selection.selectAll('.axis .domain, .axis line')
79        .style('stroke', theme.axisLine);
80    selection.selectAll('.grid line')
81        .style('stroke', theme.gridLine);
82}
83
84function makeEditableTitle(titleElement, plotData, containerId) {
85    const currentTitle = plotData.title;
86    const titleSVG = d3.select(titleElement);
87    const svgElement = titleSVG.node().ownerSVGElement;
88    const containerElement = svgElement.parentElement;
89    
90    // Get the title's position attributes
91    const titleX = parseFloat(titleSVG.attr('x'));
92    const titleY = parseFloat(titleSVG.attr('y'));
93    
94    // Get SVG and container positions
95    const svgRect = svgElement.getBoundingClientRect();
96    const containerRect = containerElement.getBoundingClientRect();
97    
98    // Calculate the input position relative to the container
99    const inputLeft = svgRect.left - containerRect.left + titleX;
100    const inputTop = svgRect.top - containerRect.top + titleY;
101    
102    // Create an input field positioned over the title
103    const input = document.createElement('input');
104    input.type = 'text';
105    input.value = currentTitle;
106    input.style.position = 'absolute';
107    // Calculate width based on text length, with a minimum width
108    const estimatedWidth = Math.max(200, currentTitle.length * 12 + 40);
109    
110    input.style.left = (inputLeft - estimatedWidth/2) + 'px'; // Center the input
111    input.style.top = (inputTop - 12) + 'px'; // Center vertically (24px height / 2)
112    input.style.width = estimatedWidth + 'px';
113    input.style.height = '24px';
114    input.style.fontSize = '18px';
115    input.style.fontFamily = "'Inter', 'Segoe UI', 'Roboto', 'Helvetica Neue', Arial, sans-serif";
116    input.style.fontWeight = '300';
117    input.style.textAlign = 'center';
118    const chartTheme = resolveChartTheme(containerId, plotData);
119    input.style.border = `2px solid ${chartTheme.inputBorder}`;
120    input.style.borderRadius = '4px';
121    input.style.background = chartTheme.inputBg;
122    input.style.color = chartTheme.inputText;
123    input.style.zIndex = '1000';
124    input.style.outline = 'none';
125    
126    // Hide the original title
127    titleSVG.style('opacity', '0');
128    
129    // Add input to container
130    containerElement.style.position = 'relative';
131    containerElement.appendChild(input);
132    
133    // Focus and select the text
134    input.focus();
135    input.select();
136    
137    // Handle saving the new title
138    function saveTitle() {
139        const newTitle = input.value.trim();
140        if (newTitle && newTitle !== currentTitle) {
141            // Update the plot data
142            plotData.title = newTitle;
143            
144            // Update the data attribute if it exists
145            const messageEl = containerElement.closest('[data-plot-json]');
146            if (messageEl) {
147                const updatedData = JSON.stringify(plotData);
148                messageEl.setAttribute('data-plot-json', updatedData);
149            }
150            
151            console.log(`📝 Title updated from "${currentTitle}" to "${newTitle}"`);
152        }
153        
154        // Remove input and show updated title
155        containerElement.removeChild(input);
156        titleSVG.style('opacity', '0.9').text(plotData.title);
157    }
158    
159    // Handle canceling
160    function cancelEdit() {
161        containerElement.removeChild(input);
162        titleSVG.style('opacity', '0.9');
163    }
164    
165    // Event listeners
166    input.addEventListener('blur', saveTitle);
167    input.addEventListener('keydown', function(e) {
168        if (e.key === 'Enter') {
169            e.preventDefault();
170            saveTitle();
171        } else if (e.key === 'Escape') {
172            e.preventDefault();
173            cancelEdit();
174        }
175    });
176}
177
178// Enhanced tooltip generation function
179function createEnhancedTooltip(data, seriesName, plotData, event) {
180    const date = data.date instanceof Date ? data.date.toLocaleDateString('en-US', {
181        year: 'numeric',
182        month: 'short',
183        day: 'numeric'
184    }) : data.date;
185    
186    const value = typeof data.value === 'number' ? data.value.toLocaleString() : data.value;
187    const unit = plotData.unit || '';
188    
189    // Calculate growth if we have previous value (simplified example)
190    let growthHtml = '';
191    if (data.previousValue && typeof data.value === 'number' && typeof data.previousValue === 'number') {
192        const growth = ((data.value - data.previousValue) / data.previousValue * 100).toFixed(1);
193        const trendClass = growth >= 0 ? 'positive' : 'negative';
194        const trendSymbol = growth >= 0 ? '↗' : '↘';
195        growthHtml = `<span class="tooltip-trend ${trendClass}">${trendSymbol} ${Math.abs(growth)}%</span>`;
196    }
197    
198    return `
199        <div class="tooltip-header">${seriesName}</div>
200        <div class="tooltip-body">
201            <div class="tooltip-row">
202                <span class="tooltip-label">Date:</span>
203                <span class="tooltip-value">${date}</span>
204            </div>
205            <div class="tooltip-row">
206                <span class="tooltip-label">Value:</span>
207                <span class="tooltip-value">${value} ${unit}</span>
208                ${growthHtml}
209            </div>
210            ${data.category ? `<div class="tooltip-row">
211                <span class="tooltip-label">Category:</span>
212                <span class="tooltip-value">${data.category}</span>
213            </div>` : ''}
214        </div>
215    `;
216}
217
218function positionTooltip(tooltip, event) {
219    tooltip
220        .style('left', (event.clientX + 10) + 'px')
221        .style('top', Math.max(8, event.clientY - 28) + 'px');
222}
223
224// Animation utility functions
225function animateChartEntry(container) {
226    container.classed('chart-enter', true);
227    setTimeout(() => {
228        container.classed('chart-enter', false)
229                .classed('chart-enter-active', true);
230        setTimeout(() => {
231            container.classed('chart-enter-active', false);
232        }, 300);
233    }, 10);
234}
235
236function animateElementUpdate(selection, updateFn) {
237    selection.classed('chart-update', true);
238    updateFn();
239    setTimeout(() => {
240        selection.classed('chart-update', false);
241    }, 500);
242}
243
244// Global chart state management
245window.chartStates = new Map();
246window.chartSyncGroups = new Map();
247
248// Advanced Chart Controls System
249class ChartController {
250    constructor(containerId, plotData) {
251        this.containerId = containerId;
252        this.plotData = plotData;
253        this.currentTimeRange = null;
254        this.activeFilters = new Map();
255        this.syncGroup = null;
256        this.setupControls();
257    }
258
259    setupControls() {
260        const plotType = (this.plotData.plot_type || 'line').toLowerCase();
261        const controlsHTML = this.createControlsHTML();
262
263        // Only create and insert controls div if there's actual content
264        if (!controlsHTML || controlsHTML.trim() === '') {
265            return; // Skip creating empty controls div
266        }
267
268        const container = document.getElementById(this.containerId);
269        const controlsDiv = document.createElement('div');
270        controlsDiv.className = 'chart-controls';
271        controlsDiv.innerHTML = controlsHTML;
272
273        // Insert controls before the chart
274        container.parentNode.insertBefore(controlsDiv, container);
275        this.bindControlEvents();
276    }
277
278    createControlsHTML() {
279        const plotType = (this.plotData.plot_type || 'line').toLowerCase();
280        const hasTimeData = this.plotData.data && this.plotData.data.some(d => d.date);
281
282        // Debug logging
283        console.log('🔍 Chart Controls Debug:', {
284            hasData: !!this.plotData.data,
285            dataLength: this.plotData.data?.length,
286            firstDataPoint: this.plotData.data?.[0],
287            hasTimeData: hasTimeData,
288            plotType: plotType
289        });
290
291        // Don't show any top controls for cleaner interface
292        return '';
293    }
294
295    bindControlEvents() {
296        const container = document.getElementById(this.containerId);
297        const controlsDiv = container.parentNode.querySelector('.chart-controls');
298        
299        // If no controls div (like for line plots), skip binding
300        if (!controlsDiv) {
301            return;
302        }
303        
304        // Time range presets
305        controlsDiv.querySelectorAll('.time-preset-btn').forEach(btn => {
306            btn.addEventListener('click', (e) => this.setTimeRange(e.target.dataset.range));
307        });
308        
309        // Control buttons
310        controlsDiv.querySelectorAll('[data-action]').forEach(btn => {
311            btn.addEventListener('click', (e) => this.handleAction(e.target.dataset.action));
312        });
313        
314        // Export options
315        controlsDiv.querySelectorAll('.export-option').forEach(option => {
316            option.addEventListener('click', (e) => this.exportChart(e.target.dataset.format || e.target.closest('[data-format]').dataset.format));
317        });
318        
319        // Setup time brush if applicable
320        this.setupTimeBrush();
321        this.setupFilterPanel();
322    }
323
324    setupTimeBrush() {
325        const hasTimeData = this.plotData.data && this.plotData.data.some(d => d.date);
326        if (!hasTimeData) return;
327
328        const container = document.getElementById(this.containerId);
329        const brushSvg = container.parentNode.querySelector('.time-brush-area');
330        if (!brushSvg) return;
331
332        const parseDate = d3.timeParse('%Y-%m-%d');
333        const dates = this.plotData.data.map(d => d.date instanceof Date ? d.date : parseDate(d.date)).filter(d => d);
334        const timeScale = d3.scaleTime()
335            .domain(d3.extent(dates))
336            .range([10, 180]);
337
338        const brush = d3.brushX()
339            .extent([[10, 5], [180, 35]])
340            .on('end', (event) => {
341                if (event.selection) {
342                    const [x0, x1] = event.selection.map(timeScale.invert);
343                    this.applyTimeFilter(x0, x1);
344                }
345            });
346
347        d3.select(brushSvg)
348            .attr('width', 200)
349            .attr('height', 40)
350            .call(brush);
351    }
352
353    setupFilterPanel() {
354        const container = document.getElementById(this.containerId);
355        const filterPanel = container.parentNode.querySelector('.filter-panel');
356        if (!filterPanel) return;
357
358        // Get unique series and categories
359        const series = [...new Set(this.plotData.data.map(d => d.series).filter(Boolean))];
360        const categories = [...new Set(this.plotData.data.map(d => d.category).filter(Boolean))];
361        
362        filterPanel.innerHTML = `
363            <div class="filter-panel-header">Chart Filters</div>
364            ${series.length > 0 ? `
365            <div class="filter-section">
366                <div class="filter-section-title">Series</div>
367                <div class="filter-checkboxes">
368                    ${series.map(s => `
369                        <div class="filter-checkbox">
370                            <input type="checkbox" id="series-${s}" checked data-type="series" data-value="${s}">
371                            <label for="series-${s}">${s}</label>
372                        </div>
373                    `).join('')}
374                </div>
375            </div>
376            ` : ''}
377            ${this.plotData.data.some(d => typeof d.value === 'number') ? `
378            <div class="filter-section">
379                <div class="filter-section-title">Value Range</div>
380                <div class="filter-range-slider">
381                    <input type="range" id="value-min" min="${d3.min(this.plotData.data, d => d.value)}" max="${d3.max(this.plotData.data, d => d.value)}" value="${d3.min(this.plotData.data, d => d.value)}">
382                    <input type="range" id="value-max" min="${d3.min(this.plotData.data, d => d.value)}" max="${d3.max(this.plotData.data, d => d.value)}" value="${d3.max(this.plotData.data, d => d.value)}">
383                    <div class="filter-range-values">
384                        <span>${d3.min(this.plotData.data, d => d.value).toLocaleString()}</span>
385                        <span>${d3.max(this.plotData.data, d => d.value).toLocaleString()}</span>
386                    </div>
387                </div>
388            </div>
389            ` : ''}
390        `;
391
392        // Bind filter events
393        filterPanel.querySelectorAll('input[type="checkbox"]').forEach(checkbox => {
394            checkbox.addEventListener('change', () => this.applyFilters());
395        });
396        
397        filterPanel.querySelectorAll('input[type="range"]').forEach(slider => {
398            slider.addEventListener('input', () => this.applyFilters());
399        });
400    }
401
402    setTimeRange(range) {
403        // Update button states
404        const container = document.getElementById(this.containerId);
405        const controlsDiv = container.parentNode.querySelector('.chart-controls');
406        
407        if (controlsDiv) {
408            controlsDiv.querySelectorAll('.time-preset-btn').forEach(btn => {
409                btn.classList.toggle('active', btn.dataset.range === range);
410            });
411        }
412
413        // Apply time filter
414        if (range === 'All') {
415            this.currentTimeRange = null;
416        } else {
417            // Get the latest date from the data
418            const parseDate = d3.timeParse('%Y-%m-%d');
419            const dates = this.plotData.data.map(d => d.date instanceof Date ? d.date : parseDate(d.date)).filter(d => d);
420            const maxDate = d3.max(dates);
421
422            const years = parseInt(range.replace('Y', ''));
423            const startDate = new Date(maxDate.getFullYear() - years, maxDate.getMonth(), maxDate.getDate());
424            this.currentTimeRange = [startDate, maxDate];
425        }
426        
427        this.updateChart();
428    }
429
430    handleAction(action) {
431        switch (action) {
432            case 'toggle-filters':
433                this.toggleFilterPanel();
434                break;
435            case 'toggle-export':
436                this.toggleExportMenu();
437                break;
438            case 'toggle-sync':
439                this.toggleSync();
440                break;
441        }
442    }
443
444
445    toggleFilterPanel() {
446        const container = document.getElementById(this.containerId);
447        const filterPanel = container.parentNode.querySelector('.filter-panel');
448        if (filterPanel) {
449            const isVisible = filterPanel.style.display !== 'none';
450            filterPanel.style.display = isVisible ? 'none' : 'block';
451        }
452    }
453
454    toggleExportMenu() {
455        const container = document.getElementById(this.containerId);
456        const exportMenu = container.parentNode.querySelector('.export-menu');
457        if (exportMenu) {
458            const isVisible = exportMenu.style.display !== 'none';
459            exportMenu.style.display = isVisible ? 'none' : 'block';
460        }
461    }
462
463    applyTimeFilter(startDate, endDate) {
464        this.currentTimeRange = [startDate, endDate];
465        this.updateChart();
466    }
467
468    applyFilters() {
469        const container = document.getElementById(this.containerId);
470        const filterPanel = container.parentNode.querySelector('.filter-panel');
471        
472        // Collect active filters
473        this.activeFilters.clear();
474        
475        // Series filters
476        const seriesCheckboxes = filterPanel.querySelectorAll('input[data-type="series"]:checked');
477        const activeSeries = Array.from(seriesCheckboxes).map(cb => cb.dataset.value);
478        if (activeSeries.length > 0) {
479            this.activeFilters.set('series', activeSeries);
480        }
481        
482        // Value range filters
483        const minSlider = filterPanel.querySelector('#value-min');
484        const maxSlider = filterPanel.querySelector('#value-max');
485        if (minSlider && maxSlider) {
486            this.activeFilters.set('valueRange', [+minSlider.value, +maxSlider.value]);
487        }
488        
489        this.updateChart();
490    }
491
492    getFilteredData() {
493        let filteredData = [...this.plotData.data];
494        
495        // Apply time range filter
496        if (this.currentTimeRange) {
497            const [startDate, endDate] = this.currentTimeRange;
498            const parseDate = d3.timeParse('%Y-%m-%d');
499            filteredData = filteredData.filter(d => {
500                if (!d.date) return true;
501                const date = d.date instanceof Date ? d.date : parseDate(d.date);
502                return date && date >= startDate && date <= endDate;
503            });
504        }
505        
506        // Apply series filter
507        if (this.activeFilters.has('series')) {
508            const activeSeries = this.activeFilters.get('series');
509            filteredData = filteredData.filter(d => !d.series || activeSeries.includes(d.series));
510        }
511        
512        // Apply value range filter
513        if (this.activeFilters.has('valueRange')) {
514            const [min, max] = this.activeFilters.get('valueRange');
515            filteredData = filteredData.filter(d => d.value >= min && d.value <= max);
516        }
517        
518        return filteredData;
519    }
520
521    updateChart() {
522        const filteredData = this.getFilteredData();
523        const updatedPlotData = { ...this.plotData, data: filteredData };
524        renderD3Chart(this.containerId, updatedPlotData);
525        
526        // Notify sync group if active
527        if (this.syncGroup) {
528            this.notifySyncGroup();
529        }
530    }
531
532    exportChart(format) {
533        switch (format) {
534            case 'png':
535                window.downloadD3Chart(this.containerId, `chart_${Date.now()}.png`);
536                break;
537            case 'svg':
538                this.exportSVG();
539                break;
540            case 'csv':
541                this.exportCSV();
542                break;
543            case 'interactive':
544                this.exportInteractiveHTML();
545                break;
546        }
547    }
548
549    exportCSV() {
550        const data = this.getFilteredData();
551        const csv = d3.csvFormat(data);
552        const blob = new Blob([csv], { type: 'text/csv' });
553        const url = URL.createObjectURL(blob);
554        const a = document.createElement('a');
555        a.href = url;
556        a.download = `chart_data_${Date.now()}.csv`;
557        a.click();
558        URL.revokeObjectURL(url);
559    }
560
561    exportSVG() {
562        const container = document.getElementById(this.containerId);
563        const svg = container.querySelector('svg');
564        if (svg) {
565            const serializer = new XMLSerializer();
566            const svgString = serializer.serializeToString(svg);
567            const blob = new Blob([svgString], { type: 'image/svg+xml' });
568            const url = URL.createObjectURL(blob);
569            const a = document.createElement('a');
570            a.href = url;
571            a.download = `chart_${Date.now()}.svg`;
572            a.click();
573            URL.revokeObjectURL(url);
574        }
575    }
576
577    toggleSync() {
578        // Implementation for chart synchronization
579        if (this.syncGroup) {
580            this.leaveSyncGroup();
581        } else {
582            this.joinSyncGroup('default');
583        }
584    }
585
586    joinSyncGroup(groupName) {
587        this.syncGroup = groupName;
588        if (!window.chartSyncGroups.has(groupName)) {
589            window.chartSyncGroups.set(groupName, new Set());
590        }
591        window.chartSyncGroups.get(groupName).add(this);
592        this.updateSyncIndicator(true);
593    }
594
595    leaveSyncGroup() {
596        if (this.syncGroup) {
597            const group = window.chartSyncGroups.get(this.syncGroup);
598            if (group) {
599                group.delete(this);
600            }
601            this.syncGroup = null;
602            this.updateSyncIndicator(false);
603        }
604    }
605
606    updateSyncIndicator(active) {
607        const container = document.getElementById(this.containerId);
608        let indicator = container.querySelector('.chart-sync-indicator');
609        if (!indicator) {
610            indicator = document.createElement('div');
611            indicator.className = 'chart-sync-indicator';
612            container.appendChild(indicator);
613        }
614        indicator.classList.toggle('active', active);
615    }
616
617    notifySyncGroup() {
618        if (!this.syncGroup) return;
619        
620        const group = window.chartSyncGroups.get(this.syncGroup);
621        if (group) {
622            group.forEach(chart => {
623                if (chart !== this) {
624                    // Sync time range
625                    chart.currentTimeRange = this.currentTimeRange;
626                    chart.updateChart();
627                }
628            });
629        }
630    }
631}
632
633// Data brushing functionality
634function addDataBrushing(svg, g, width, height, plotType, data, xScale, yScale, containerId) {
635    // Select Mode button and brushing feature removed for cleaner interface
636}
637
638// D3 Chart Rendering Function
639function renderD3Chart(containerId, plotData, preselectedVisible) {
640    // Check if D3 is available
641    if (typeof d3 === 'undefined') {
642        console.error('D3.js is not loaded');
643        const container = document.getElementById(containerId);
644        container.innerHTML = '<div style="padding: 2rem; text-align: center; color: #ef4444;">D3.js library is required for interactive charts</div>';
645        return;
646    }
647    
648    const container = d3.select(`#${containerId}`);
649    const containerNode = container.node();
650    const chartTheme = resolveChartTheme(containerId, plotData);
651    // Persist the original, full dataset on the container so we can re-render
652    // categorical charts without spacing gaps after legend toggles
653    if (!containerNode.__originalPlotData) {
654        try {
655            containerNode.__originalPlotData = JSON.parse(JSON.stringify(plotData));
656        } catch (e) {
657            // Fallback shallow copy
658            containerNode.__originalPlotData = { ...plotData, data: Array.isArray(plotData.data) ? [...plotData.data] : plotData.data };
659        }
660    }
661    
662    // Always use fresh dimensions from container
663    // Don't cache dimensions as they're lost on page reload and can cause sizing issues
664    const rect = containerNode.getBoundingClientRect();
665
666    // Ensure container has valid dimensions before rendering
667    if (rect.width < 100) {
668        console.warn('⚠️ Chart container too small, skipping render. Width:', rect.width);
669        return;
670    }
671
672    // CRITICAL: Check if reset button should be shown BEFORE clearing the SVG
673    // This flag is set by legend click handlers and PERSISTS across re-renders
674    // until the reset button is clicked (which clears it)
675    const shouldShowResetButton = containerNode.__showResetButton || false;
676    console.log('🔍 [Debug] Captured showResetButton flag at start of render:', shouldShowResetButton);
677
678    // IMPORTANT: If flag is false but we have preselectedVisible, it means chart was filtered
679    // This handles the case where React re-renders after D3 filtering
680    const hasFiltering = preselectedVisible && preselectedVisible.length > 0;
681    console.log('🔍 [Debug] Has preselectedVisible filtering:', hasFiltering, preselectedVisible);
682
683    // Get plot type early for margin calculations
684    const plotType = (plotData.plot_type || 'line').toLowerCase();
685    
686    // Chart dimensions
687    // Keep right margin small; place legend below to maximize plot width
688    const margin = { top: 20, right: 20, bottom: 25, left: 80 };
689    
690    // Adjust top margin if there's a title
691    if (plotData.title) {
692        margin.top = 45;
693    }
694    
695    // Adjust margins based on chart type
696    if ((plotType === 'bar' || plotType === 'stacked') && plotData.series_info && plotData.series_info.length > 1) {
697        margin.top = plotData.title ? 110 : 90; // Extra space for centered legend to prevent overlap
698    } else if (plotType === 'box') {
699        // Box plots with no X-axis labels need minimal bottom margin
700        margin.bottom = 35; // Just enough space for the axis line and some padding
701    }
702    
703    const width = rect.width - margin.left - margin.right;
704    // Use a larger plot height for better visibility, especially for stacked charts
705    let basePlotHeight = Math.max(300, Math.min(400, rect.height * 0.6)); // At least 300px, max 400px for container fit
706    if (plotType === 'stacked') {
707        basePlotHeight = Math.max(350, Math.min(450, rect.height * 0.7)); // Taller for stacked charts
708    }
709    let height = basePlotHeight;
710    
711    // Clear any existing chart
712    container.selectAll('*').remove();
713    
714    // Inject tooltip styles once
715    if (!document.getElementById('d3-tooltip-styles')) {
716        const styleTag = document.createElement('style');
717        styleTag.id = 'd3-tooltip-styles';
718        styleTag.textContent = `
719            .d3-tooltip {
720                background: rgba(10, 17, 60, 0.95);
721                color: #fff;
722                padding: 10px 14px;
723                border-radius: 8px;
724                font-size: 13px;
725                font-family: inherit;
726                box-shadow: 0 4px 16px rgba(0,0,0,0.4);
727                max-width: 240px;
728                line-height: 1.6;
729                border: 1px solid rgba(255,255,255,0.12);
730                backdrop-filter: blur(4px);
731            }
732            .tooltip-header {
733                font-weight: 700;
734                font-size: 14px;
735                color: #EB8F47;
736                margin-bottom: 6px;
737                padding-bottom: 5px;
738                border-bottom: 1px solid rgba(255,255,255,0.15);
739            }
740            .tooltip-row {
741                display: flex;
742                justify-content: space-between;
743                gap: 12px;
744                margin-top: 3px;
745            }
746            .tooltip-label { color: rgba(255,255,255,0.6); font-size: 12px; }
747            .tooltip-value { font-weight: 600; color: #fff; }
748            .tooltip-trend.positive { color: #4ade80; margin-left: 6px; }
749            .tooltip-trend.negative { color: #f87171; margin-left: 6px; }
750        `;
751        document.head.appendChild(styleTag);
752    }
753
754    // Create tooltip div if it doesn't exist
755    let tooltip = d3.select('body').select('.d3-tooltip');
756    if (tooltip.empty()) {
757        tooltip = d3.select('body').append('div')
758            .attr('class', 'd3-tooltip');
759    }
760
761    tooltip
762        .style('position', 'fixed')
763        .style('left', '0px')
764        .style('top', '0px')
765        .style('pointer-events', 'none')
766        .style('opacity', 0);
767    
768    // Create SVG with initial height (will be adjusted later for legend)
769    const buttonSpace = 40; // Space for download/reset buttons
770    const totalNeededHeight = basePlotHeight + margin.top + margin.bottom + buttonSpace;
771    const svgHeight = Math.min(totalNeededHeight, rect.height - 20); // Ensure it fits in container with some padding
772    
773    const svg = container
774        .append('svg')
775        .attr('width', rect.width)
776        .attr('height', svgHeight);
777    
778    // Add entry animation
779    animateChartEntry(container);
780    
781    // Set initial container height
782    containerNode.style.height = `${svgHeight + 20}px`;
783    
784    // Initialize advanced chart controller if not already done
785    if (!window.chartStates.has(containerId)) {
786        const controller = new ChartController(containerId, plotData);
787        window.chartStates.set(containerId, controller);
788    }
789    
790    // Add download button for ALL chart types (always visible)
791    const downloadBtn = svg.append('g')
792        .attr('class', 'simple-download-btn')
793        .attr('transform', `translate(${rect.width - 40}, 10)`)
794        .style('cursor', 'pointer')
795        .style('opacity', '0.9')
796        .on('click', () => {
797            window.downloadD3Chart(containerId, `${plotData.title || 'chart'}.png`);
798        })
799        .on('mouseenter', function() {
800            d3.select(this).style('opacity', '1');
801            d3.select(this).select('rect').attr('fill', chartTheme.btnBgHover);
802        })
803        .on('mouseleave', function() {
804            d3.select(this).style('opacity', '0.9');
805            d3.select(this).select('rect').attr('fill', chartTheme.btnBg);
806        });
807
808    downloadBtn.append('rect')
809        .attr('width', 30)
810        .attr('height', 24)
811        .attr('rx', 4)
812        .attr('fill', chartTheme.btnBg)
813        .attr('stroke', chartTheme.btnStroke)
814        .attr('stroke-width', '1.5');
815
816    downloadBtn.append('text')
817        .attr('x', 15)
818        .attr('y', 15)
819        .attr('text-anchor', 'middle')
820        .style('font-size', '11px')
821        .style('font-weight', '600')
822        .style('fill', chartTheme.btnText)
823        .text('⬇');
824
825    // Add reset button for ALL chart types (to restore filtered series)
826    // Position it to the left of the download button (download is at rect.width - 40)
827    const resetBtn = svg.append('g')
828        .attr('class', 'chart-reset-btn')
829        .attr('transform', `translate(${rect.width - 110}, 10)`)
830        .style('cursor', 'pointer')
831        .style('display', 'none') // Hidden by default, shown when filtering occurs
832        .style('opacity', '0.95')
833        .on('click', function() {
834            // CRITICAL FIX: Always get a FRESH deep copy of original data
835            // This prevents corruption from multiple reset clicks
836            const base = containerNode.__originalPlotData;
837
838            if (!base) {
839                console.error('❌ No original plot data found!');
840                return;
841            }
842
843            // Deep clone to prevent any mutations
844            let freshData;
845            try {
846                freshData = JSON.parse(JSON.stringify(base));
847            } catch (e) {
848                console.error('❌ Failed to clone original data:', e);
849                freshData = base; // Fallback to direct reference
850            }
851
852            console.log('🔄 Resetting chart to show all series');
853            console.log('📊 Original data has', freshData.data ? freshData.data.length : 0, 'data points');
854
855            // Clear the flag so button will be hidden after reset
856            containerNode.__showResetButton = false;
857
858            // Re-render with fresh copy of original dataset (no preselectedVisible)
859            // This ensures ALL series are visible
860            renderD3Chart(containerId, freshData);
861        })
862        .on('mouseenter', function() {
863            d3.select(this).style('opacity', '1');
864            d3.select(this).select('rect').attr('fill', chartTheme.resetBgHover);
865        })
866        .on('mouseleave', function() {
867            d3.select(this).style('opacity', '0.95');
868            d3.select(this).select('rect').attr('fill', chartTheme.resetBg);
869        });
870
871    resetBtn.append('rect')
872        .attr('width', 50)
873        .attr('height', 24)
874        .attr('rx', 4)
875        .attr('fill', chartTheme.resetBg)
876        .attr('stroke', chartTheme.resetStroke)
877        .attr('stroke-width', '1.5');
878
879    resetBtn.append('text')
880        .attr('x', 25)
881        .attr('y', 15)
882        .attr('text-anchor', 'middle')
883        .style('font-size', '11px')
884        .style('font-weight', '600')
885        .style('fill', chartTheme.resetText)
886        .text('↻ Reset');
887
888    // Check if reset button should be shown (for categorical charts after re-render with filtered data)
889    // Show button if EITHER: (1) flag is set, OR (2) we have preselected filtering
890    console.log('🔍 [Debug] Checking captured showResetButton flag:', shouldShowResetButton, 'for plot:', plotData.title);
891    console.log('🔍 [Debug] Has filtering:', hasFiltering, 'preselectedVisible:', preselectedVisible);
892    console.log('🔍 [Debug] SVG width:', rect.width, 'Reset button position:', rect.width - 110);
893
894    if (shouldShowResetButton || hasFiltering) {
895        // Set the flag to persist across future renders
896        containerNode.__showResetButton = true;
897        resetBtn.style('display', null); // Show the button
898        console.log('🔄 [Reset Button] Showing reset button (flag:', shouldShowResetButton, 'hasFiltering:', hasFiltering, ')');
899    }
900
901    // No zoom functionality - removed for cleaner interface
902    
903    // Add editable title to the chart
904    if (plotData.title) {
905        const titleElement = svg.append('text')
906            .attr('x', rect.width / 2)
907            .attr('y', 20)
908            .attr('text-anchor', 'middle')
909            .style('font-family', "'Inter', 'Segoe UI', 'Roboto', 'Helvetica Neue', Arial, sans-serif")
910            .style('font-size', '18px')
911            .style('font-weight', '300')
912            .style('letter-spacing', '0.5px')
913            .style('fill', chartTheme.title)
914            .style('opacity', '0.9')
915            .style('cursor', 'pointer')
916            .text(plotData.title);
917        
918        // Add editing functionality
919        titleElement.on('dblclick', function(event) {
920            event.stopPropagation();
921            makeEditableTitle(this, plotData, containerId);
922        });
923        
924        // Add hover effect to indicate it's editable
925        titleElement.on('mouseover', function() {
926            d3.select(this)
927                .style('opacity', '0.7')
928                .style('text-decoration', 'underline');
929        });
930        
931        titleElement.on('mouseout', function() {
932            d3.select(this)
933                .style('opacity', '0.9')
934                .style('text-decoration', 'none');
935        });
936    }
937    
938    const g = svg.append('g')
939        .attr('transform', `translate(${margin.left},${margin.top})`);
940    
941    // Process data
942    const data = plotData.data;
943    // Data validation
944    
945    if (!data || data.length === 0) {
946        g.append('text')
947            .attr('x', width / 2)
948            .attr('y', height / 2)
949            .attr('text-anchor', 'middle')
950            .style('fill', chartTheme.text)
951            .text('No data available');
952        return;
953    }
954    
955    // Parse dates and prepare data (only when date exists)
956    // Guard: skip if already a Date object (prevents NaN on re-render with same data)
957    const parseDate = d3.timeParse('%Y-%m-%d');
958    data.forEach(d => {
959        if (d.date && !(d.date instanceof Date)) {
960            d.date = parseDate(d.date);
961        }
962        if (d.value !== undefined) {
963            d.value = +d.value;
964        }
965    });
966    
967    // Group data by series
968    const series = d3.group(data.filter(d => d.series !== undefined), d => d.series);
969    
970    // Scales (time for line, band for bar/box)
971    let xScale;
972    let yScale;
973    let categories = [];
974    if (plotType === 'line') {
975        xScale = d3.scaleTime()
976            .domain(d3.extent(data, d => d.date))
977            .range([0, width]);
978        yScale = d3.scaleLinear()
979            .domain(d3.extent(data, d => d.value))
980            .nice()
981            .range([height, 0]);
982    } else if (plotType === 'bar' || plotType === 'box' || plotType === 'stacked' || plotType === 'stacked_bar') {
983        categories = Array.from(new Set(data.map(d => d.category || d.series)));
984        // Sort categories numerically for proper year ordering
985        if (plotType === 'stacked' || plotType === 'bar' || plotType === 'stacked_bar') {
986            categories.sort((a, b) => +a - +b);
987        }
988        xScale = d3.scaleBand().domain(categories).range([0, width]).padding(0.2);
989
990        if (plotType === 'stacked' || plotType === 'stacked_bar') {
991            // For stacked charts, calculate the maximum stacked height per category using MW values
992            const stackTotals = d3.rollup(data,
993                values => d3.sum(values, d => d.value), // Use MW values for scale
994                d => d.category
995            );
996            const yMax = d3.max(Array.from(stackTotals.values()));
997            yScale = d3.scaleLinear().domain([0, yMax]).nice().range([height, 0]);
998        } else {
999            const yMax = plotType === 'box'
1000                ? d3.max(data, d => Math.max(d.max, d.q3, d.q2, d.q1, d.min))
1001                : d3.max(data, d => d.value);
1002            const yMin = plotType === 'box'
1003                ? d3.min(data, d => Math.min(d.min, d.q1, d.q2, d.q3))
1004                : 0; // Always start from 0 for bar charts to show small values properly
1005            yScale = d3.scaleLinear().domain([yMin, yMax]).nice().range([height, 0]);
1006        }
1007    } else if (plotType === 'pie') {
1008        // For pies, no axes, but we still compute categories for legends
1009        categories = Array.from(new Set(data.map(d => d.category || d.series)));
1010    }
1011    
1012    // Number formatter for clean Y-axis labels
1013    const formatYAxis = (value) => {
1014        // GW unit: values stored in MW, convert to GW for axis display
1015        if (plotData.unit === 'GW') {
1016            if (value === 0) return '0';
1017            const gw = value / 1000.0;
1018            if (gw >= 1) return gw.toFixed(1).replace(/\.0$/, '') + ' GW';
1019            return gw.toFixed(3).replace(/\.?0+$/, '') + ' GW';
1020        }
1021        const absValue = Math.abs(value);
1022        if (absValue >= 1e9) {
1023            return (value / 1e9).toFixed(1).replace(/\.0$/, '') + 'B';
1024        } else if (absValue >= 1e6) {
1025            return (value / 1e6).toFixed(1).replace(/\.0$/, '') + 'M';
1026        } else if (absValue >= 1e3) {
1027            return (value / 1e3).toFixed(1).replace(/\.0$/, '') + 'K';
1028        }
1029        return value.toString();
1030    };
1031
1032    // Color scale - Distinct colors for better differentiation
1033    const becquerelColors = [
1034        '#EB8F47', // Persian orange (primary)
1035        '#000A55', // Federal blue
1036        '#949CFF', // Vista Blue
1037        '#C5C5C5', // Silver
1038        '#E5A342', // Hunyadi yellow
1039        '#f97316', // Dark orange (fallback)
1040        '#22c55e', // Green (fallback)
1041        '#e11d48', // Red (fallback)
1042        '#8b5cf6', // Purple (fallback)
1043        '#06b6d4', // Cyan (fallback)
1044        '#84cc16', // Lime green (fallback)
1045        '#ec4899'  // Pink (fallback)
1046    ];
1047    const colorScale = d3.scaleOrdinal(becquerelColors);
1048    
1049    // Line generator
1050    const line = d3.line()
1051        .x(d => xScale(d.date))
1052        .y(d => yScale(d.value))
1053        .curve(d3.curveMonotoneX);
1054    
1055    // Add axes with enhanced styling for line charts
1056    if (plotType === 'line') {
1057        // Add grid lines for better readability
1058        const xGridLines = g.append('g')
1059            .attr('class', 'grid x-grid')
1060            .attr('transform', `translate(0,${height})`);
1061        
1062        const yGridLines = g.append('g')
1063            .attr('class', 'grid y-grid');
1064        
1065        // Adaptive ticks based on timespan for better readability
1066        const minDate = d3.min(data, d => d.date);
1067        const maxDate = d3.max(data, d => d.date);
1068        const spanDays = (maxDate - minDate) / (1000 * 60 * 60 * 24);
1069        let interval, formatter, rotation = 0;
1070        
1071        if (spanDays <= 90) { // ~3 months → weekly
1072            interval = d3.timeWeek.every(1);
1073            formatter = d3.timeFormat('%b %d');
1074            rotation = -45;
1075        } else if (spanDays <= 365) { // ≤ 1 year → monthly
1076            interval = d3.timeMonth.every(1);
1077            formatter = d3.timeFormat('%Y-%m');
1078            rotation = -45;
1079        } else if (spanDays <= 1095) { // ≤ 3 years → quarterly
1080            interval = d3.timeMonth.every(6);
1081            formatter = d3.timeFormat('%Y-%m');
1082            rotation = -45;
1083        } else if (spanDays <= 2190) { // ≤ 6 years → yearly
1084            interval = d3.timeYear.every(1);
1085            formatter = d3.timeFormat('%Y');
1086            rotation = 0;
1087        } else { // very long → every 2 years
1088            interval = d3.timeYear.every(2);
1089            formatter = d3.timeFormat('%Y');
1090            rotation = 0;
1091        }
1092        
1093        // Create axes with enhanced styling and clean number formatting
1094        const xAxis = d3.axisBottom(xScale).ticks(interval).tickFormat(formatter);
1095        const yAxis = d3.axisLeft(yScale).ticks(6).tickFormat(formatYAxis);
1096        
1097        // Add grid lines first (so they appear behind the data)
1098        xGridLines.call(d3.axisBottom(xScale)
1099            .ticks(interval)
1100            .tickSize(-height)
1101            .tickFormat('')
1102        ).style('opacity', 0.3);
1103        
1104        yGridLines.call(d3.axisLeft(yScale)
1105            .ticks(6)
1106            .tickSize(-width)
1107            .tickFormat('')
1108        ).style('opacity', 0.1);
1109        
1110        // Add main axes
1111        g.append('g')
1112            .attr('class', 'x-axis axis')
1113            .attr('transform', `translate(0,${height})`)
1114            .call(xAxis)
1115            .selectAll('text')
1116            .attr('transform', rotation !== 0 ? `rotate(${rotation})` : 'rotate(0)')
1117            .style('text-anchor', rotation !== 0 ? 'end' : 'middle');
1118        styleAxisChrome(g, chartTheme);
1119    } else if (plotType === 'box') {
1120        // For box charts, show only axis line without labels (legend provides all info)
1121        g.append('g')
1122            .attr('class', 'x-axis')
1123            .attr('transform', `translate(0,${height})`)
1124            .call(d3.axisBottom(xScale).tickFormat('').tickSize(0))
1125            .select('.domain')
1126            .style('stroke', chartTheme.axisLine)
1127            .style('stroke-width', 1);
1128    } else if (plotType === 'bar' || plotType === 'stacked' || plotType === 'stacked_bar') {
1129        // For bar/stacked/stacked_bar charts, add year labels directly below bars (no axis line)
1130        const step = Math.max(1, Math.ceil(categories.length / 12));
1131        const tickVals = categories.filter((_, i) => i % step === 0);
1132        
1133        // Check if segment labels are hidden to make x-axis more prominent
1134        const shouldShowSegmentLabels = data.length > 0 ? data[0].show_segment_labels !== false : true;
1135        const xAxisFontSize = shouldShowSegmentLabels ? '12px' : '13px'; // Slightly larger when segment labels are hidden
1136        const xAxisFontWeight = shouldShowSegmentLabels ? '500' : '600'; // Bolder when segment labels are hidden
1137
1138        // Determine if labels should be rotated based on average label length and bar width
1139        const avgLabelLength = tickVals.reduce((sum, d) => sum + String(d).length, 0) / tickVals.length;
1140        const barWidth = xScale.bandwidth();
1141        const shouldRotateLabels = avgLabelLength > 8 || barWidth < 60; // Rotate if labels are long or bars are narrow
1142
1143        g.selectAll('text.bar-year-label')
1144            .data(tickVals)
1145            .enter()
1146            .append('text')
1147            .attr('class', 'bar-year-label')
1148            .attr('x', d => xScale(d) + xScale.bandwidth() / 2)
1149            .attr('y', height + (shouldRotateLabels ? 10 : 20)) // Position closer to bars when rotated
1150            .attr('text-anchor', shouldRotateLabels ? 'end' : 'middle')
1151            .attr('transform', shouldRotateLabels ?
1152                function(d) {
1153                    const x = xScale(d) + xScale.bandwidth() / 2;
1154                    const y = height + 10;
1155                    return `rotate(-45, ${x}, ${y})`;
1156                } : null)
1157            .style('font-family', "'Inter', 'Segoe UI', 'Roboto', sans-serif")
1158            .style('font-size', shouldRotateLabels ? '11px' : xAxisFontSize) // Slightly smaller when rotated
1159            .style('font-weight', xAxisFontWeight)
1160            .style('fill', chartTheme.text)
1161            .text(d => d);
1162    }
1163
1164    // Determine if bar chart should show Y-axis based on number of bars
1165    const barCount = plotType === 'bar' ? categories.length : 0;
1166    const showBarYAxis = plotType === 'bar' && barCount > 8; // Show Y-axis if more than 8 bars
1167    const showBarValueLabels = plotType === 'bar' && barCount <= 8; // Only show value labels if 8 or fewer bars
1168
1169    // Add Y axis for charts that need it
1170    if (plotType !== 'pie' && plotType !== 'stacked' && plotType !== 'stacked_bar') {
1171        if (plotType === 'line') {
1172            // For line charts, Y-axis with enhanced styling and clean number formatting
1173            g.append('g')
1174                .attr('class', 'y-axis axis')
1175                .call(d3.axisLeft(yScale).ticks(6).tickFormat(formatYAxis));
1176        } else if (plotType === 'bar' && showBarYAxis) {
1177            // For bar charts with many bars, show Y-axis instead of labels
1178            g.append('g')
1179                .attr('class', 'y-axis axis')
1180                .call(d3.axisLeft(yScale).ticks(6).tickFormat(formatYAxis));
1181        }
1182        // For bar charts with few bars, no Y-axis (value labels are shown instead)
1183        styleAxisChrome(g, chartTheme);
1184    }
1185
1186    // Add axis labels for charts that have y-axis displayed
1187    const shouldShowYAxisLabel = (() => {
1188        if (plotType === 'pie' || plotType === 'stacked' || plotType === 'stacked_bar') return false; // Never show for stacked charts
1189        if (plotType === 'bar') return showBarYAxis; // Only show Y-axis label for bar charts with many bars
1190        return true; // For line and other charts
1191    })();
1192
1193    const shouldShowXAxisLabel = (() => {
1194        // Don't show X-axis label for line charts (ticks are self-explanatory with dates)
1195        if (plotType === 'line') return false;
1196        // Don't show X-axis label for box plots since legend provides all info
1197        if (plotType === 'box') return false;
1198        // Don't show X-axis label for stacked/stacked_bar charts (year labels below bars are sufficient)
1199        if (plotType === 'stacked' || plotType === 'stacked_bar') return false;
1200        return shouldShowYAxisLabel; // Use same logic as Y-axis for other charts
1201    })();
1202    
1203    if (shouldShowYAxisLabel) {
1204        // Enhanced Y-axis label with better typography
1205        g.append('text')
1206            .attr('transform', 'rotate(-90)')
1207            .attr('y', 0 - margin.left + 15)
1208            .attr('x', 0 - (height / 2))
1209            .attr('dy', '0.35em')
1210            .style('text-anchor', 'middle')
1211            .style('font-size', plotType === 'line' ? '14px' : '12px')
1212            .style('font-weight', plotType === 'line' ? '600' : '500')
1213            .style('font-family', "'Inter', 'Segoe UI', 'Roboto', sans-serif")
1214            .style('fill', chartTheme.text)
1215            .style('letter-spacing', '0.025em')
1216            .text(plotData.y_axis_label);
1217        
1218        // Enhanced X-axis label with better typography and positioning (if enabled)
1219        if (shouldShowXAxisLabel && plotData.x_axis_label) {
1220            const xLabelYOffset = plotType === 'line' ? 5 : 10;
1221            g.append('text')
1222                .attr('transform', `translate(${width / 2}, ${height + margin.bottom - xLabelYOffset})`)
1223                .style('text-anchor', 'middle')
1224                .style('font-size', plotType === 'line' ? '14px' : '12px')
1225                .style('font-weight', plotType === 'line' ? '600' : '500')
1226                .style('font-family', "'Inter', 'Segoe UI', 'Roboto', sans-serif")
1227                .style('fill', chartTheme.text)
1228                .style('letter-spacing', '0.025em')
1229                .text(plotData.x_axis_label);
1230        }
1231    }
1232    
1233    // Draw series by plot type
1234    if (plotType === 'line') {
1235        // Create gradient definitions for enhanced visuals
1236        const defs = svg.append('defs');
1237        
1238        // Create drop shadow filter
1239        const filter = defs.append('filter')
1240            .attr('id', 'line-shadow')
1241            .attr('x', '-20%')
1242            .attr('y', '-20%')
1243            .attr('width', '140%')
1244            .attr('height', '140%');
1245        
1246        filter.append('feDropShadow')
1247            .attr('dx', 0)
1248            .attr('dy', 2)
1249            .attr('stdDeviation', 2)
1250            .attr('flood-opacity', 0.15);
1251        
1252        series.forEach((values, seriesName) => {
1253            const sortedValues = values.sort((a, b) => a.date - b.date);
1254            const safe = cssSafe(seriesName);
1255            const baseColor = colorScale(seriesName);
1256            
1257            // Create gradient for area fill
1258            const gradient = defs.append('linearGradient')
1259                .attr('id', `area-gradient-${safe}`)
1260                .attr('x1', '0%')
1261                .attr('y1', '0%')
1262                .attr('x2', '0%')
1263                .attr('y2', '100%');
1264            
1265            gradient.append('stop')
1266                .attr('offset', '0%')
1267                .attr('stop-color', baseColor)
1268                .attr('stop-opacity', 0.4);
1269            
1270            gradient.append('stop')
1271                .attr('offset', '100%')
1272                .attr('stop-color', baseColor)
1273                .attr('stop-opacity', 0.05);
1274            
1275            // Create area fill under the line
1276            const area = d3.area()
1277                .x(d => xScale(d.date))
1278                .y0(yScale.range()[0]) // Bottom of chart
1279                .y1(d => yScale(d.value))
1280                .curve(d3.curveMonotoneX);
1281            
1282            // Add the area fill
1283            const areaPath = g.append('path')
1284                .datum(sortedValues)
1285                .attr('class', `series-area series-${safe}`)
1286                .attr('fill', `url(#area-gradient-${safe})`)
1287                .attr('d', area)
1288                .style('opacity', 0);
1289            
1290            // Enhanced line with improved styling
1291            const path = g.append('path')
1292                .datum(sortedValues)
1293                .attr('class', `series-line series-${safe}`)
1294                .attr('fill', 'none')
1295                .attr('stroke', baseColor)
1296                .attr('stroke-width', 3)
1297                .style('stroke-linejoin', 'round')
1298                .style('stroke-linecap', 'round')
1299                .style('vector-effect', 'non-scaling-stroke')
1300                .style('filter', 'url(#line-shadow)')
1301                .attr('d', line);
1302            
1303            // Animate line drawing
1304            const totalLength = path.node().getTotalLength();
1305            path
1306                .attr('stroke-dasharray', totalLength + ' ' + totalLength)
1307                .attr('stroke-dashoffset', totalLength)
1308                .transition()
1309                .duration(1500)
1310                .ease(d3.easeQuadInOut)
1311                .attr('stroke-dashoffset', 0)
1312                .on('end', function() {
1313                    // Remove dash array after animation
1314                    d3.select(this).attr('stroke-dasharray', null);
1315                });
1316            
1317            // Animate area fill after line animation starts
1318            areaPath.transition()
1319                .delay(300)
1320                .duration(1200)
1321                .ease(d3.easeQuadInOut)
1322                .style('opacity', 0.7);
1323
1324            // Show fewer points when dense; hide completely if extremely dense
1325            const maxDots = 40; // target number of dots to display per series
1326            const total = sortedValues.length;
1327            const showDots = total <= 200; // hide dots if extremely dense
1328            const stride = Math.max(1, Math.ceil(total / maxDots));
1329            const dotsData = showDots ? sortedValues.filter((_, i) => i % stride === 0 || i === total - 1) : [];
1330
1331            const circles = g.selectAll(`circle.series-${safe}`)
1332                .data(dotsData)
1333                .enter().append('circle')
1334                .attr('class', `series-dot series-${safe}`)
1335                .attr('cx', d => xScale(d.date))
1336                .attr('cy', d => yScale(d.value))
1337                .attr('r', 0) // Start with 0 radius
1338                .style('stroke', chartTheme.dotStroke)
1339                .style('stroke-width', 0.5)
1340                .attr('fill', colorScale(seriesName))
1341                .style('cursor', 'pointer')
1342                .style('opacity', 0); // Start invisible
1343            
1344            // Animate the circles
1345            circles.transition()
1346                .delay((d, i) => 1500 + i * 50) // Start after line animation, staggered
1347                .duration(300)
1348                .ease(d3.easeBackOut)
1349                .attr('r', 2.5)
1350                .style('opacity', 1);
1351            
1352            // Add event handlers after creating the circles
1353            circles.on('mouseover', function(event, d) {
1354                    // Highlight the point
1355                    d3.select(this)
1356                        .transition()
1357                        .duration(100)
1358                        .attr('r', 4)
1359                        .style('stroke-width', 2);
1360                    
1361                    // Use enhanced tooltip
1362                    tooltip.transition()
1363                        .duration(200)
1364                        .style('opacity', 0.9);
1365                    
1366                    positionTooltip(tooltip.html(createEnhancedTooltip(d, seriesName, plotData, event)), event);
1367                })
1368                .on('mouseout', function(event, d) {
1369                    // Reset the point
1370                    d3.select(this)
1371                        .transition()
1372                        .duration(100)
1373                        .attr('r', 2.5)
1374                        .style('stroke-width', 0.5);
1375                    
1376                    // Hide tooltip
1377                    tooltip.transition()
1378                        .duration(500)
1379                        .style('opacity', 0);
1380                });
1381        });
1382        
1383        // Add crosshair functionality for line charts
1384        const crosshair = g.append('g')
1385            .attr('class', 'crosshair')
1386            .style('display', 'none');
1387            
1388        // Vertical line
1389        crosshair.append('line')
1390            .attr('class', 'crosshair-x')
1391            .attr('y1', 0)
1392            .attr('y2', height)
1393            .style('stroke', chartTheme.crosshair)
1394            .style('stroke-width', 1)
1395            .style('stroke-dasharray', '3,3')
1396            .style('opacity', 0.7);
1397            
1398        // Horizontal line
1399        crosshair.append('line')
1400            .attr('class', 'crosshair-y')
1401            .attr('x1', 0)
1402            .attr('x2', width)
1403            .style('stroke', chartTheme.crosshair)
1404            .style('stroke-width', 1)
1405            .style('stroke-dasharray', '3,3')
1406            .style('opacity', 0.7);
1407            
1408        // Invisible overlay for mouse tracking
1409        const overlay = svg.append('rect')
1410            .attr('class', 'overlay')
1411            .attr('x', margin.left)
1412            .attr('y', margin.top)
1413            .attr('width', width)
1414            .attr('height', height)
1415            .style('fill', 'none')
1416            .style('pointer-events', 'all')
1417            .on('mouseover', () => crosshair.style('display', null))
1418            .on('mouseout', () => {
1419                crosshair.style('display', 'none');
1420                tooltip.transition().duration(500).style('opacity', 0);
1421            })
1422            .on('mousemove', function(event) {
1423                const [mouseX, mouseY] = d3.pointer(event, this);
1424                
1425                // Update crosshair position
1426                crosshair.select('.crosshair-x')
1427                    .attr('x1', mouseX)
1428                    .attr('x2', mouseX);
1429                    
1430                crosshair.select('.crosshair-y')
1431                    .attr('y1', mouseY)
1432                    .attr('y2', mouseY);
1433                    
1434                // Find closest data point for tooltip
1435                const xDate = xScale.invert(mouseX);
1436                let closestPoint = null;
1437                let minDistance = Infinity;
1438                let closestSeries = '';
1439                
1440                series.forEach((values, seriesName) => {
1441                    values.forEach(d => {
1442                        const distance = Math.abs(d.date - xDate);
1443                        if (distance < minDistance) {
1444                            minDistance = distance;
1445                            closestPoint = d;
1446                            closestSeries = seriesName;
1447                        }
1448                    });
1449                });
1450                
1451                // Show tooltip for closest point
1452                if (closestPoint) {
1453                    tooltip.transition()
1454                        .duration(100)
1455                        .style('opacity', 0.9);
1456                    
1457                    positionTooltip(tooltip.html(createEnhancedTooltip(closestPoint, closestSeries, plotData, event)), event);
1458                }
1459            });
1460
1461      } else if (plotType === 'stacked_bar') {
1462          // Stacked bar chart - segments stack on top of each other using MW values
1463          // Group data by category (year) and calculate cumulative positions in MW
1464          const stackedData = Array.from(d3.group(data, d => d.category), ([key, values]) => {
1465              values.sort((a, b) => a.stack.localeCompare(b.stack)); // Consistent ordering
1466              let cumulative = 0;
1467              return values.map(d => {
1468                  const result = {
1469                      ...d,
1470                      series: d.stack,  // Map stack to series for compatibility
1471                      y0: cumulative,
1472                      y1: cumulative + d.value  // Use MW values for stacking
1473                  };
1474                  cumulative += d.value;
1475                  return result;
1476              });
1477          }).flat();
1478
1479          const stackedBars = g.selectAll('rect.stacked-bar')
1480              .data(stackedData)
1481              .enter()
1482              .append('rect')
1483              .attr('class', d => `stacked-bar series-${cssSafe(d.series)}`)
1484              .attr('x', d => xScale(d.category))
1485              .attr('width', xScale.bandwidth())
1486              .attr('y', d => yScale(d.y0)) // Start from bottom position
1487              .attr('height', 0) // Start with 0 height
1488              .attr('fill', d => {
1489                  // Use colors from stack_info if available
1490                  if (plotData.stack_info) {
1491                      const stackInfo = plotData.stack_info.find(s => s.name === d.stack);
1492                      if (stackInfo) return stackInfo.color;
1493                  }
1494                  return colorScale(d.series);
1495              })
1496              .style('cursor', 'pointer');
1497
1498          // Animate stacked bars
1499          stackedBars.transition()
1500              .delay((d, i) => i * 50) // Staggered animation
1501              .duration(800)
1502              .ease(d3.easeQuadOut)
1503              .attr('y', d => yScale(d.y1))
1504              .attr('height', d => yScale(d.y0) - yScale(d.y1));
1505
1506          // Add hover effects and tooltips to stacked bars
1507          stackedBars.on('mouseover', function(event, d) {
1508              // Highlight the segment
1509              d3.select(this)
1510                  .transition()
1511                  .duration(200)
1512                  .style('opacity', 0.8)
1513                  .style('stroke', chartTheme.hoverStroke)
1514                  .style('stroke-width', 2);
1515
1516              // Show enhanced tooltip
1517              tooltip.transition()
1518                  .duration(200)
1519                  .style('opacity', 0.9);
1520
1521              positionTooltip(tooltip.html(createEnhancedTooltip(d, d.series, plotData, event)), event);
1522          })
1523          .on('mouseout', function(event, d) {
1524              // Reset the segment
1525              d3.select(this)
1526                  .transition()
1527                  .duration(200)
1528                  .style('opacity', 1)
1529                  .style('stroke', 'none');
1530
1531              // Hide tooltip
1532              tooltip.transition()
1533                  .duration(500)
1534                  .style('opacity', 0);
1535          });
1536
1537          // Calculate totals for each category and add sum labels on top
1538          const categoryTotals = Array.from(d3.group(data, d => d.category), ([category, values]) => {
1539              const total = d3.sum(values, d => d.value);
1540              // Look for a formatted total from backend (if available in the first value of this category)
1541              const formattedTotal = values[0]?.formatted_total || null;
1542              return { category, total, formattedTotal };
1543          });
1544
1545          // Add total sum labels on top of stacked bars
1546          const barWidth = xScale.bandwidth();
1547          const useSmallFont = barWidth < 60; // Use smaller font if bars are narrow
1548
1549          g.selectAll('text.stack-total-label')
1550              .data(categoryTotals)
1551              .enter()
1552              .append('text')
1553              .attr('class', 'stack-total-label')
1554              .attr('x', d => xScale(d.category) + xScale.bandwidth() / 2)
1555              .attr('y', d => yScale(d.total) - 8) // 8px above the top of the stack
1556              .attr('text-anchor', 'middle')
1557              .style('font-family', "'Inter', 'Segoe UI', 'Roboto', sans-serif")
1558              .style('font-size', useSmallFont ? '9px' : '13px')
1559              .style('font-weight', useSmallFont ? '500' : '700')
1560              .style('fill', useSmallFont ? chartTheme.textMuted : chartTheme.textStrong)
1561              .style('text-shadow', chartTheme.stackTotalShadow)
1562              .text(d => {
1563                  // Use backend's formatted total if available
1564                  if (d.formattedTotal) {
1565                      return d.formattedTotal;
1566                  }
1567
1568                  // Otherwise format the total value in GW
1569                  const valueMW = d.total;
1570                  const valueGW = valueMW / 1000.0; // Convert MW to GW
1571
1572                  if (valueMW === 0 || valueMW === 0.0) {
1573                      return "0 GW";
1574                  } else if (valueGW < 1.0) {
1575                      // Values < 1 GW: use 3 decimal places
1576                      return `${valueGW.toFixed(3)} GW`;
1577                  } else {
1578                      // Values >= 1 GW: use 1 decimal place
1579                      return `${valueGW.toFixed(1)} GW`;
1580                  }
1581              });
1582
1583          // Add individual segment values for larger segments (with smart visibility control)
1584          const shouldShowSegmentLabels = data.length > 0 ? data[0].show_segment_labels !== false : true;
1585          const filteredStackedData = shouldShowSegmentLabels
1586              ? stackedData.filter(d => {
1587                  const segmentHeight = yScale(d.y0) - yScale(d.y1);
1588                  // Only show labels for segments > 5% of total height AND bar is wide enough
1589                  return (d.y1 - d.y0) > (yScale.domain()[1] * 0.05) && barWidth >= 40 && segmentHeight >= 30;
1590              })
1591              : []; // Hide all segment labels when there are too many bars
1592
1593          g.selectAll('text.segment-value-label')
1594              .data(filteredStackedData)
1595              .enter()
1596              .append('text')
1597              .attr('class', 'segment-value-label')
1598              .attr('x', d => xScale(d.category) + xScale.bandwidth() / 2)
1599              .attr('y', d => yScale((d.y0 + d.y1) / 2)) // Middle of the segment
1600              .attr('text-anchor', 'middle')
1601              .attr('dominant-baseline', 'middle')
1602              .style('font-family', "'Inter', 'Segoe UI', 'Roboto', sans-serif")
1603              .style('font-size', '10px')
1604              .style('font-weight', '600')
1605              .style('fill', '#ffffff')
1606              .style('text-shadow', '1px 1px 2px rgba(0,0,0,0.7)')
1607              .style('pointer-events', 'none')
1608              .text(d => {
1609                  // Use backend's formatted value if available
1610                  if (d.formatted_value) {
1611                      return d.formatted_value;
1612                  }
1613                  // Fallback formatting - convert MW to GW
1614                  const valueMW = d.value;
1615                  const valueGW = valueMW / 1000.0; // Convert MW to GW
1616
1617                  if (valueMW === 0 || valueMW === 0.0) {
1618                      return "0 GW";
1619                  } else if (valueGW < 1.0) {
1620                      // Values < 1 GW: use 3 decimal places
1621                      return `${valueGW.toFixed(3)} GW`;
1622                  } else {
1623                      // Values >= 1 GW: use 1 decimal place
1624                      return `${valueGW.toFixed(1)} GW`;
1625                  }
1626              })
1627              .each(function() {
1628                  // Clip text to fit within bar width
1629                  const text = d3.select(this);
1630                  const textWidth = this.getComputedTextLength();
1631                  if (textWidth > barWidth - 4) {
1632                      // Hide text if it's too wide for the bar
1633                      text.style('display', 'none');
1634                  }
1635              });
1636
1637      } else if (plotType === 'bar') {
1638          const bars = g.selectAll('rect.series-bar')
1639              .data(data)
1640              .enter()
1641              .append('rect')
1642              .attr('class', d => `series-bar series-${cssSafe(d.series)}`)
1643              .attr('x', d => xScale(d.category || d.series))
1644              .attr('width', xScale.bandwidth())
1645              .attr('y', height) // Start from bottom
1646              .attr('height', 0) // Start with 0 height
1647              .attr('fill', d => colorScale(d.series))
1648              .style('cursor', 'pointer');
1649          
1650          // Animate the bars
1651          bars.transition()
1652              .delay((d, i) => i * 100) // Staggered animation
1653              .duration(800)
1654              .ease(d3.easeQuadOut)
1655              .attr('y', d => yScale(d.value))
1656              .attr('height', d => height - yScale(d.value));
1657          
1658          // Add event handlers after creating bars
1659          bars.on('mouseover', function(event, d) {
1660                  // Highlight the bar
1661                  d3.select(this)
1662                      .transition()
1663                      .duration(100)
1664                      .style('opacity', 0.8)
1665                      .style('stroke', chartTheme.hoverStroke)
1666                      .style('stroke-width', 2);
1667                  
1668                  // Use enhanced tooltip
1669                  tooltip.transition()
1670                      .duration(200)
1671                      .style('opacity', 0.9);
1672                  
1673                  positionTooltip(tooltip.html(createEnhancedTooltip(d, d.series || '', plotData, event)), event);
1674              })
1675              .on('mouseout', function(event, d) {
1676                  // Reset the bar
1677                  d3.select(this)
1678                      .transition()
1679                      .duration(100)
1680                      .style('opacity', 1)
1681                      .style('stroke', 'none');
1682                  
1683                  // Hide tooltip
1684                  tooltip.transition()
1685                      .duration(500)
1686                      .style('opacity', 0);
1687              });
1688
1689          // Add value labels on bars (only if there are 8 or fewer bars)
1690          if (showBarValueLabels) {
1691              g.selectAll('text.bar-value-label')
1692                  .data(data)
1693                  .enter()
1694                  .append('text')
1695                  .attr('class', 'bar-value-label')
1696                  .attr('x', d => xScale(d.category || d.series) + xScale.bandwidth() / 2)
1697                  .attr('y', d => {
1698                      // Always position label above the bar for better visibility
1699                      return yScale(d.value) - 5;
1700                  })
1701                  .attr('text-anchor', 'middle')
1702                  .attr('dominant-baseline', 'auto')
1703                  .style('font-family', "'Inter', 'Segoe UI', 'Roboto', sans-serif")
1704                  .style('font-size', '11px')
1705                  .style('font-weight', '600')
1706                  .style('fill', chartTheme.text)
1707                  .style('text-shadow', chartTheme.stackTotalShadow) // Contrast shadow for labels above bars
1708                  .text(d => {
1709                      // Use backend's formatted value if available, otherwise format here
1710                      if (d.formatted_value) {
1711                          return d.formatted_value;
1712                      }
1713                      // Fallback formatting
1714                      const value = d.value;
1715                      // Handle zero explicitly to avoid formatting issues
1716                      if (value === 0 || value === 0.0) {
1717                          return "0";
1718                      } else if (value >= 1000) {
1719                          return (value / 1000).toFixed(0) + 'k';
1720                      }
1721                      return Math.round(value).toLocaleString();
1722                  });
1723          }
1724    } else if (plotType === 'stacked') {
1725        // Stacked bar chart - segments stack on top of each other using MW values
1726        // Group data by category (year) and calculate cumulative positions in MW
1727        const stackedData = Array.from(d3.group(data, d => d.category), ([key, values]) => {
1728            values.sort((a, b) => a.series.localeCompare(b.series)); // Consistent ordering
1729            let cumulative = 0;
1730            return values.map(d => {
1731                const result = {
1732                    ...d,
1733                    y0: cumulative,
1734                    y1: cumulative + d.value  // Use MW values for stacking
1735                };
1736                cumulative += d.value;
1737                return result;
1738            });
1739        }).flat();
1740
1741                  const stackedBars = g.selectAll('rect.stacked-bar')
1742              .data(stackedData)
1743              .enter()
1744              .append('rect')
1745              .attr('class', d => `stacked-bar series-${cssSafe(d.series)}`)
1746              .attr('x', d => xScale(d.category))
1747              .attr('width', xScale.bandwidth())
1748              .attr('y', d => yScale(d.y0)) // Start from bottom position
1749              .attr('height', 0) // Start with 0 height
1750              .attr('fill', d => colorScale(d.series))
1751              .style('cursor', 'pointer');
1752          
1753          // Animate stacked bars
1754          stackedBars.transition()
1755              .delay((d, i) => i * 50) // Staggered animation
1756              .duration(800)
1757              .ease(d3.easeQuadOut)
1758              .attr('y', d => yScale(d.y1))
1759              .attr('height', d => yScale(d.y0) - yScale(d.y1));
1760          
1761          // Add hover effects and tooltips to stacked bars
1762          stackedBars.on('mouseover', function(event, d) {
1763              // Highlight the segment
1764              d3.select(this)
1765                  .transition()
1766                  .duration(200)
1767                  .style('opacity', 0.8)
1768                  .style('stroke', chartTheme.hoverStroke)
1769                  .style('stroke-width', 2);
1770              
1771              // Show enhanced tooltip
1772              tooltip.transition()
1773                  .duration(200)
1774                  .style('opacity', 0.9);
1775              
1776              positionTooltip(tooltip.html(createEnhancedTooltip(d, d.series, plotData, event)), event);
1777          })
1778          .on('mouseout', function(event, d) {
1779              // Reset the segment
1780              d3.select(this)
1781                  .transition()
1782                  .duration(200)
1783                  .style('opacity', 1)
1784                  .style('stroke', 'none');
1785              
1786              // Hide tooltip
1787              tooltip.transition()
1788                  .duration(500)
1789                  .style('opacity', 0);
1790          });
1791
1792          // Calculate totals for each category and add sum labels on top
1793          const categoryTotals = Array.from(d3.group(data, d => d.category), ([category, values]) => {
1794              const total = d3.sum(values, d => d.value);
1795              // Look for a formatted total from backend (if available in the first value of this category)
1796              const formattedTotal = values[0]?.formatted_total || null;
1797              return { category, total, formattedTotal };
1798          });
1799
1800          // Add total sum labels on top of stacked bars
1801          const barWidth = xScale.bandwidth();
1802          const useSmallFont = barWidth < 60; // Use smaller font if bars are narrow
1803
1804          g.selectAll('text.stack-total-label')
1805              .data(categoryTotals)
1806              .enter()
1807              .append('text')
1808              .attr('class', 'stack-total-label')
1809              .attr('x', d => xScale(d.category) + xScale.bandwidth() / 2)
1810              .attr('y', d => yScale(d.total) - 8) // 8px above the top of the stack
1811              .attr('text-anchor', 'middle')
1812              .style('font-family', "'Inter', 'Segoe UI', 'Roboto', sans-serif")
1813              .style('font-size', useSmallFont ? '9px' : '13px')
1814              .style('font-weight', useSmallFont ? '500' : '700')
1815              .style('fill', useSmallFont ? chartTheme.textMuted : chartTheme.textStrong)
1816              .style('text-shadow', chartTheme.stackTotalShadow)
1817              .text(d => {
1818                  // Use backend's formatted total if available
1819                  if (d.formattedTotal) {
1820                      return d.formattedTotal;
1821                  }
1822
1823                  // Otherwise format the total value in GW
1824                  const valueMW = d.total;
1825                  const valueGW = valueMW / 1000.0; // Convert MW to GW
1826
1827                  if (valueMW === 0 || valueMW === 0.0) {
1828                      return "0 GW";
1829                  } else if (valueGW < 1.0) {
1830                      // Values < 1 GW: use 3 decimal places
1831                      return `${valueGW.toFixed(3)} GW`;
1832                  } else {
1833                      // Values >= 1 GW: use 1 decimal place
1834                      return `${valueGW.toFixed(1)} GW`;
1835                  }
1836              });
1837
1838          // Add individual segment values for larger segments (with smart visibility control)
1839          const shouldShowSegmentLabels = data.length > 0 ? data[0].show_segment_labels !== false : true; // Default to true if not specified
1840          const filteredStackedData = shouldShowSegmentLabels
1841              ? stackedData.filter(d => {
1842                  // Calculate actual pixel height of the segment
1843                  const segmentHeight = yScale(d.y0) - yScale(d.y1);
1844                  // Only show label if segment is tall enough (at least 30 pixels) AND bar is wide enough (at least 40 pixels)
1845                  return segmentHeight >= 30 && barWidth >= 40;
1846              })
1847              : []; // Hide all segment labels when there are too many bars
1848
1849          g.selectAll('text.segment-value-label')
1850              .data(filteredStackedData)
1851              .enter()
1852              .append('text')
1853              .attr('class', 'segment-value-label')
1854              .attr('x', d => xScale(d.category) + xScale.bandwidth() / 2)
1855              .attr('y', d => yScale((d.y0 + d.y1) / 2)) // Middle of the segment
1856              .attr('text-anchor', 'middle')
1857              .attr('dominant-baseline', 'middle')
1858              .style('font-family', "'Inter', 'Segoe UI', 'Roboto', sans-serif")
1859              .style('font-size', '10px')
1860              .style('font-weight', '600')
1861              .style('fill', '#ffffff')
1862              .style('text-shadow', '1px 1px 2px rgba(0,0,0,0.7)')
1863              .style('pointer-events', 'none')
1864              .text(d => {
1865                  // Use backend's formatted value if available
1866                  if (d.formatted_value) {
1867                      return d.formatted_value;
1868                  }
1869                  // Fallback formatting - convert MW to GW
1870                  const valueMW = d.value;
1871                  const valueGW = valueMW / 1000.0; // Convert MW to GW
1872
1873                  if (valueMW === 0 || valueMW === 0.0) {
1874                      return "0 GW";
1875                  } else if (valueGW < 1.0) {
1876                      // Values < 1 GW: use 3 decimal places
1877                      return `${valueGW.toFixed(3)} GW`;
1878                  } else {
1879                      // Values >= 1 GW: use 1 decimal place
1880                      return `${valueGW.toFixed(1)} GW`;
1881                  }
1882              })
1883              .each(function() {
1884                  // Clip text to fit within bar width
1885                  const text = d3.select(this);
1886                  const textWidth = this.getComputedTextLength();
1887                  if (textWidth > barWidth - 4) {
1888                      // Hide text if it's too wide for the bar
1889                      text.style('display', 'none');
1890                  }
1891              });
1892    } else if (plotType === 'box') {
1893        const boxWidth = Math.max(10, xScale.bandwidth() * 0.6);
1894        const half = boxWidth / 2;
1895        const groups = d3.group(data, d => d.series);
1896        
1897        groups.forEach((values, key, index) => {
1898            const d = values[0];
1899            const cx = xScale(d.category || d.series) + xScale.bandwidth() / 2;
1900            const color = colorScale(d.series);
1901            const safe = cssSafe(d.series);
1902            const groupIndex = Array.from(groups.keys()).indexOf(key);
1903            
1904            // Whisker with animation
1905            const whisker = g.append('line')
1906                .attr('class', `series-${safe}`)
1907                .attr('x1', cx)
1908                .attr('x2', cx)
1909                .attr('y1', yScale(d.min))
1910                .attr('y2', yScale(d.min)) // Start collapsed
1911                .attr('stroke', color)
1912                .attr('stroke-width', 2)
1913                .style('opacity', 0);
1914            
1915            whisker.transition()
1916                .delay(groupIndex * 200)
1917                .duration(600)
1918                .ease(d3.easeQuadOut)
1919                .attr('y2', yScale(d.max))
1920                .style('opacity', 1);
1921            
1922            // Box with animation
1923            const box = g.append('rect')
1924                .attr('class', `series-${safe}`)
1925                .attr('x', cx - half)
1926                .attr('width', boxWidth)
1927                .attr('y', yScale(d.q2)) // Start as a line at median
1928                .attr('height', 0) // Start with 0 height
1929                .attr('fill', color)
1930                .attr('fill-opacity', 0.3)
1931                .attr('stroke', color)
1932                .attr('stroke-width', 2)
1933                .style('cursor', 'pointer')
1934                .style('opacity', 0);
1935            
1936            box.transition()
1937                .delay(groupIndex * 200 + 200)
1938                .duration(400)
1939                .ease(d3.easeBackOut)
1940                .attr('y', yScale(d.q3))
1941                .attr('height', Math.max(1, yScale(d.q1) - yScale(d.q3)))
1942                .style('opacity', 1);
1943            
1944            // Median line with animation
1945            const median = g.append('line')
1946                .attr('class', `series-${safe}`)
1947                .attr('x1', cx)
1948                .attr('x2', cx) // Start as a point
1949                .attr('y1', yScale(d.q2))
1950                .attr('y2', yScale(d.q2))
1951                .attr('stroke', color)
1952                .attr('stroke-width', 3)
1953                .style('opacity', 0);
1954            
1955            median.transition()
1956                .delay(groupIndex * 200 + 400)
1957                .duration(300)
1958                .ease(d3.easeElasticOut)
1959                .attr('x1', cx - half)
1960                .attr('x2', cx + half)
1961                .style('opacity', 1);
1962            
1963            // Add hover effects and tooltips to box
1964            box.on('mouseover', function(event, boxData) {
1965                // Highlight the box
1966                d3.select(this)
1967                    .transition()
1968                    .duration(200)
1969                    .style('opacity', 0.8)
1970                    .attr('stroke-width', 3);
1971                
1972                // Create custom tooltip data for box plot
1973                const boxTooltipData = {
1974                    series: d.series,
1975                    category: d.category,
1976                    min: d.min,
1977                    q1: d.q1,
1978                    q2: d.q2,
1979                    q3: d.q3,
1980                    max: d.max
1981                };
1982                
1983                // Show enhanced tooltip with box plot specific content
1984                tooltip.transition()
1985                    .duration(200)
1986                    .style('opacity', 0.9);
1987                
1988                const unit = plotData.unit || '';
1989                positionTooltip(tooltip.html(`
1990                    <div class="tooltip-header">${d.series}</div>
1991                    <div class="tooltip-body">
1992                        <div class="tooltip-row">
1993                            <span class="tooltip-label">Category:</span>
1994                            <span class="tooltip-value">${d.category || d.series}</span>
1995                        </div>
1996                        <div class="tooltip-row">
1997                            <span class="tooltip-label">Max:</span>
1998                            <span class="tooltip-value">${d.max.toLocaleString()} ${unit}</span>
1999                        </div>
2000                        <div class="tooltip-row">
2001                            <span class="tooltip-label">Q3:</span>
2002                            <span class="tooltip-value">${d.q3.toLocaleString()} ${unit}</span>
2003                        </div>
2004                        <div class="tooltip-row">
2005                            <span class="tooltip-label">Median:</span>
2006                            <span class="tooltip-value">${d.q2.toLocaleString()} ${unit}</span>
2007                        </div>
2008                        <div class="tooltip-row">
2009                            <span class="tooltip-label">Q1:</span>
2010                            <span class="tooltip-value">${d.q1.toLocaleString()} ${unit}</span>
2011                        </div>
2012                        <div class="tooltip-row">
2013                            <span class="tooltip-label">Min:</span>
2014                            <span class="tooltip-value">${d.min.toLocaleString()} ${unit}</span>
2015                        </div>
2016                    </div>
2017                `), event);
2018            })
2019            .on('mouseout', function(event, boxData) {
2020                // Reset the box
2021                d3.select(this)
2022                    .transition()
2023                    .duration(200)
2024                    .style('opacity', 1)
2025                    .attr('stroke-width', 2);
2026                
2027                // Hide tooltip
2028                tooltip.transition()
2029                    .duration(500)
2030                    .style('opacity', 0);
2031            });
2032        });
2033    } else if (plotType === 'pie') {
2034        // Pie/donut chart using category as slice name and value as ratio (0..1)
2035        const radius = Math.min(width, height) / 2;
2036        // Center the pie in the middle of the entire container
2037        const centerX = rect.width / 2; // Use full container width
2038        const centerY = margin.top + height / 2;
2039        const pieG = svg.append('g')
2040            .attr('transform', `translate(${centerX}, ${centerY})`);
2041        // Exclude the synthetic 'Total' slice from actual wedges; keep for total text
2042        const slices = data.filter(d => (d.category || d.series) !== 'Total');
2043        
2044        const pie = d3.pie().value(d => d.value).sort(null);
2045        const arc = d3.arc().innerRadius(radius * 0.45).outerRadius(radius);
2046        const arcs = pieG.selectAll('path')
2047            .data(pie(slices))
2048            .enter().append('path')
2049            .attr('class', d => `series-${cssSafe(d.data.category || d.data.series)}`)
2050            .attr('fill', d => colorScale(d.data.category || d.data.series))
2051            .attr('stroke', chartTheme.sliceStroke)
2052            .attr('stroke-width', 2)
2053            .style('cursor', 'pointer')
2054            .style('opacity', 0); // Start invisible
2055        
2056        // Animate pie slices
2057        arcs.transition()
2058            .delay((d, i) => i * 200) // Staggered animation
2059            .duration(800)
2060            .ease(d3.easeBackOut)
2061            .style('opacity', 1)
2062            .attrTween('d', function(d) {
2063                const i = d3.interpolate({startAngle: 0, endAngle: 0}, d);
2064                return function(t) {
2065                    return arc(i(t));
2066                };
2067            });
2068        
2069        // Add hover effects and tooltips to pie slices
2070        arcs.on('mouseover', function(event, d) {
2071            // Highlight the slice
2072            d3.select(this)
2073                .transition()
2074                .duration(200)
2075                .style('opacity', 0.8)
2076                .attr('transform', 'scale(1.05)');
2077            
2078            // Show enhanced tooltip
2079            tooltip.transition()
2080                .duration(200)
2081                .style('opacity', 0.9);
2082            
2083            positionTooltip(tooltip.html(createEnhancedTooltip(d.data, d.data.category || d.data.series, plotData, event)), event);
2084        })
2085        .on('mouseout', function(event, d) {
2086            // Reset the slice
2087            d3.select(this)
2088                .transition()
2089                .duration(200)
2090                .style('opacity', 1)
2091                .attr('transform', 'scale(1)');
2092            
2093            // Hide tooltip
2094            tooltip.transition()
2095                .duration(500)
2096                .style('opacity', 0);
2097        });
2098        // Smart labeling: only show labels for slices larger than threshold
2099        const minSliceThreshold = 0.08; // Only label slices >= 8% (to avoid overlap)
2100        const largeSlices = pie(slices).filter(d => d.data.value >= minSliceThreshold);
2101        
2102        // Add percentage labels only for larger slices
2103        pieG.selectAll('text.percentage')
2104            .data(largeSlices)
2105            .enter().append('text')
2106            .attr('class', 'percentage')
2107            .attr('transform', d => {
2108                const centroid = arc.centroid(d);
2109                return `translate(${centroid[0]}, ${centroid[1] - 8})`;
2110            })
2111            .attr('text-anchor', 'middle')
2112            .style('font-size', '14px')
2113            .style('font-weight', 'bold')
2114            .style('fill', 'white')
2115            .style('text-shadow', '1px 1px 2px rgba(0,0,0,0.7)')
2116            .text(d => `${(d.data.value * 100).toFixed(1)}%`);
2117
2118        // Add MW value labels below percentages only for larger slices
2119        pieG.selectAll('text.mw-value')
2120            .data(largeSlices)
2121            .enter().append('text')
2122            .attr('class', 'mw-value')
2123            .attr('transform', d => {
2124                const centroid = arc.centroid(d);
2125                return `translate(${centroid[0]}, ${centroid[1] + 8})`;
2126            })
2127            .attr('text-anchor', 'middle')
2128            .style('font-size', '12px')
2129            .style('font-weight', '600')
2130            .style('fill', 'white')
2131            .style('text-shadow', '1px 1px 2px rgba(0,0,0,0.7)')
2132            .text(d => {
2133                const totalInfo = (plotData.series_info && plotData.series_info[0] && plotData.series_info[0].total_mw) || null;
2134                const mwRaw = (totalInfo ? d.data.value * totalInfo : d.data.mw);
2135                const mw = (mwRaw || 0).toLocaleString(undefined, { maximumFractionDigits: 0 });
2136                return `${mw} MW`;
2137            });
2138        // Show total somewhere below the chart if provided
2139        const totalInfo = (plotData.series_info && plotData.series_info[0] && plotData.series_info[0].total_mw) || (data.find(d => (d.category||d.series)==='Total')?.mw) || null;
2140        if (totalInfo !== null && !isNaN(totalInfo)) {
2141            svg.append('text')
2142                .attr('x', centerX)
2143                .attr('y', centerY + radius + 24)
2144                .attr('text-anchor', 'middle')
2145                .style('font-size', '12px')
2146                .style('fill', chartTheme.text)
2147                .text(`Total: ${totalInfo.toLocaleString(undefined, { maximumFractionDigits: 0 })} MW`);
2148        }
2149        
2150        // Add note about small slices if some slices don't have labels
2151        const unlabeledSlices = pie(slices).filter(d => d.data.value < minSliceThreshold);
2152        if (unlabeledSlices.length > 0) {
2153            svg.append('text')
2154                .attr('x', centerX)
2155                .attr('y', centerY + radius + (totalInfo ? 40 : 24))
2156                .attr('text-anchor', 'middle')
2157                .style('font-size', '10px')
2158                .style('fill', chartTheme.textMuted)
2159                .style('font-style', 'italic')
2160                .text(`Small slices (< 8%) shown in legend only`);
2161        }
2162    }
2163    
2164    // Legend positioning - top for bar/stacked charts, bottom for others
2165    let legendOffsetY;
2166    let legendOffsetX;
2167    
2168    if ((plotType === 'bar' || plotType === 'stacked') && plotData.series_info && plotData.series_info.length > 1) {
2169        // Top legend for bar/stacked charts
2170        legendOffsetY = plotData.title ? 50 : 25; // Below title or at top
2171        legendOffsetX = 0; // Will be centered after legend width calculation
2172    } else if (plotType === 'pie') {
2173        // Center legend for pie charts - will be adjusted after width calculation
2174        legendOffsetY = margin.top + height + margin.bottom + 20; // after axis label
2175        legendOffsetX = width / 2; // Start at center, will be adjusted for actual legend width
2176    } else {
2177        // Bottom legend for other charts - ensure it's within SVG bounds
2178        if (plotType === 'line') {
2179            // For line charts, position legend with better spacing
2180            legendOffsetY = margin.top + height + margin.bottom + 40; // More space from axis
2181            legendOffsetX = width / 2; // Center horizontally initially
2182        } else {
2183            // For box plots and other charts, add extra spacing from X-axis
2184            const extraSpacing = plotType === 'box' ? 40 : 20; // More space for box plots
2185            legendOffsetY = margin.top + height + margin.bottom + extraSpacing;
2186            legendOffsetX = margin.left;
2187        }
2188    }
2189    
2190    // Ensure legend doesn't start outside SVG bounds (except for charts that will be centered)
2191    if (plotType !== 'pie' && plotType !== 'stacked' && plotType !== 'line') {
2192        legendOffsetX = Math.max(legendOffsetX, 10); // Minimum 10px from left edge
2193    }
2194    
2195    const legend = svg.append('g')
2196        .attr('class', 'legend')
2197        .attr('transform', `translate(${legendOffsetX}, ${legendOffsetY})`);
2198
2199    // For bar/stacked charts, legend should represent series, not categories (years)
2200    // For pie charts, exclude 'Total' from legend
2201    // For stacked_bar charts, use stack names from data
2202    const legendItems = (plotType === 'line' || plotType === 'bar' || plotType === 'stacked')
2203      ? Array.from(series.keys())
2204      : plotType === 'stacked_bar'
2205        ? Array.from(new Set(data.map(d => d.stack)))
2206      : plotType === 'pie'
2207        ? categories.filter(cat => cat !== 'Total')
2208        : categories;
2209    let xCursor = 0;
2210    let yCursor = 0;
2211    const rowHeight = 20;
2212    // Calculate available width for legend, ensuring it doesn't exceed container bounds
2213    const availableWidth = rect.width - margin.left - margin.right - 80; // More padding for safety
2214    const maxLegendWidth = Math.min(Math.max(200, availableWidth), rect.width - 100); // Ensure it never exceeds container
2215    
2216    // For very long legends, use a more compact layout
2217    const isLongLegend = legendItems.length > 15;
2218    const compactSpacing = isLongLegend ? 15 : 20;
2219    
2220    // Debug legend width calculation
2221    if (legendItems.length > 10) {
2222        console.log('📏 Legend Debug:', {
2223            containerWidth: rect.width,
2224            availableWidth,
2225            maxLegendWidth,
2226            legendItemsCount: legendItems.length,
2227            margin: { left: margin.left, right: margin.right }
2228        });
2229    }
2230    
2231    // For bar/stacked charts, use horizontal layout with better spacing
2232    // Calculate item width to determine optimal spacing
2233    const sampleItemWidth = Math.max(...legendItems.map(name => String(name).length)) * 8 + 40; // Estimate width based on longest name
2234    const itemSpacing = (plotType === 'bar' || plotType === 'stacked') ? Math.max(sampleItemWidth, 120) : compactSpacing;
2235
2236    // Track visibility state
2237    const visibility = new Map(legendItems.map(n => [n, true]));
2238    if (Array.isArray(preselectedVisible) && preselectedVisible.length > 0) {
2239        legendItems.forEach(n => {
2240            visibility.set(n, preselectedVisible.includes(n));
2241        });
2242    }
2243
2244    legendItems.forEach((name) => {
2245        const group = legend.append('g')
2246            .attr('class', 'legend-item')
2247            .attr('transform', `translate(${xCursor}, ${yCursor})`);
2248
2249        let text; // Declare text variable in the outer scope
2250
2251        // Enhanced styling for bar/stacked charts
2252        if (plotType === 'bar' || plotType === 'stacked' || plotType === 'stacked_bar') {
2253            // Rounded rectangle for modern look
2254            // For stacked_bar, get color from stack_info if available
2255            let fillColor = colorScale(name);
2256            if (plotType === 'stacked_bar' && plotData.stack_info) {
2257                const stackInfo = plotData.stack_info.find(s => s.name === name);
2258                if (stackInfo) fillColor = stackInfo.color;
2259            }
2260
2261            const swatch = group.append('rect')
2262                .attr('width', 12)
2263                .attr('height', 12)
2264                .attr('rx', 2) // Rounded corners
2265                .attr('ry', 2)
2266                .attr('fill', fillColor)
2267                .style('stroke', 'rgba(255,255,255,0.3)')
2268                .style('stroke-width', '1px');
2269
2270            text = group.append('text')
2271                .attr('x', 18)
2272                .attr('y', 6)
2273                .attr('dy', '0.35em')
2274                .style('font-family', "'Inter', 'Segoe UI', 'Roboto', sans-serif")
2275                .style('font-size', '11px')
2276                .style('font-weight', '500')
2277                .style('fill', chartTheme.text)
2278                .text(name);
2279        } else {
2280            // Default styling for other charts
2281            const swatch = group.append('rect')
2282                .attr('width', 14)
2283                .attr('height', 14)
2284                .attr('fill', colorScale(name));
2285
2286            text = group.append('text')
2287                .attr('x', 20)
2288                .attr('y', 7)
2289                .attr('dy', '0.35em')
2290                .style('font-size', '12px')
2291                .style('fill', chartTheme.text)
2292                .text(name);
2293        }
2294
2295        // Add hover highlighting functionality
2296        group.style('cursor', 'pointer')
2297            .on('mouseover', function() {
2298                const safe = cssSafe(name);
2299                // Highlight current series
2300                d3.select(this).style('opacity', 1);
2301                g.selectAll(`.series-${safe}`)
2302                    .style('opacity', 1)
2303                    .style('stroke-width', function() {
2304                        const currentWidth = d3.select(this).style('stroke-width');
2305                        return plotType === 'line' ? '3px' : currentWidth;
2306                    });
2307                
2308                // Dim other series
2309                legendItems.forEach(otherName => {
2310                    if (otherName !== name) {
2311                        const otherSafe = cssSafe(otherName);
2312                        g.selectAll(`.series-${otherSafe}`)
2313                            .style('opacity', 0.3);
2314                    }
2315                });
2316            })
2317            .on('mouseout', function() {
2318                // Reset all series to normal opacity and stroke width
2319                legendItems.forEach(otherName => {
2320                    const otherSafe = cssSafe(otherName);
2321                    g.selectAll(`.series-${otherSafe}`)
2322                        .style('opacity', visibility.get(otherName) ? 1 : 0)
2323                        .style('stroke-width', function() {
2324                            return plotType === 'line' ? '2px' : d3.select(this).style('stroke-width');
2325                        });
2326                });
2327            })
2328            .on('click', () => {
2329            const isVisible = visibility.get(name);
2330            visibility.set(name, !isVisible);
2331            const safe = cssSafe(name);
2332
2333            if (plotType === 'line') {
2334              // For line charts, keep simple toggle without re-render
2335              // Show/hide reset button based on whether any series are filtered
2336              const anyFiltered = Array.from(visibility.values()).some(v => !v);
2337              svg.select('.chart-reset-btn').style('display', anyFiltered ? null : 'none');
2338
2339              group.style('display', isVisible ? 'none' : null);
2340              g.selectAll(`.series-${safe}`).style('display', isVisible ? 'none' : null);
2341              reflowLegend();
2342            } else {
2343              // For categorical charts, rebuild the chart with only visible series to remove gaps
2344              const base = containerNode.__originalPlotData || plotData;
2345              const visibleList = legendItems.filter(n => visibility.get(n));
2346              const filtered = (base.data || []).filter(d => {
2347                const key = (plotType === 'bar' || plotType === 'stacked') ? d.series
2348                          : plotType === 'stacked_bar' ? d.stack
2349                          : (d.category || d.series);
2350                return visibleList.includes(key);
2351              });
2352
2353              // CRITICAL: Store that reset button should be shown after re-render
2354              // because some series are now filtered
2355              const shouldShowReset = visibleList.length < legendItems.length;
2356              console.log('🔍 [Debug] Setting showResetButton flag:', shouldShowReset, 'visibleList:', visibleList.length, 'total:', legendItems.length);
2357              containerNode.__showResetButton = shouldShowReset;
2358
2359              // Re-render with filtered data and keep current visibility
2360              return renderD3Chart(containerId, { ...base, data: filtered }, visibleList);
2361            }
2362        });
2363
2364        // Estimate width for wrapping
2365        const textWidth = text.node() ? text.node().getComputedTextLength() : name.length * 8; // Fallback if text node not ready
2366        const itemWidth = (plotType === 'bar' || plotType === 'stacked' || plotType === 'stacked_bar')
2367            ? 18 + textWidth + itemSpacing  // Smaller swatch + spacing for bar charts
2368            : 20 + textWidth + 16;           // Default for other charts
2369        
2370        // Check if this item would exceed the max width
2371        if (xCursor + itemWidth > maxLegendWidth) {
2372            xCursor = 0;
2373            yCursor += rowHeight;
2374        }
2375        
2376        // Ensure item doesn't exceed container bounds
2377        const finalX = Math.min(xCursor, maxLegendWidth - itemWidth);
2378        
2379        // Position the item
2380        group.attr('transform', `translate(${finalX}, ${yCursor})`);
2381        xCursor += itemWidth;
2382    });
2383
2384    // After initial layout, compute proper container height
2385    // Also apply preselected visibility (for categorical re-renders)
2386    if (Array.isArray(preselectedVisible) && preselectedVisible.length > 0) {
2387        legend.selectAll('g.legend-item').each(function(_, i){
2388            const name = legendItems[i];
2389            const on = visibility.get(name);
2390            const safe = cssSafe(name);
2391            d3.select(this).style('display', on ? null : 'none');
2392            g.selectAll(`.series-${safe}`).style('display', on ? null : 'none');
2393        });
2394
2395        // Show reset button if any series are filtered
2396        const anyFiltered = Array.from(visibility.values()).some(v => !v);
2397        svg.select('.chart-reset-btn').style('display', anyFiltered ? null : 'none');
2398    }
2399    reflowLegend();
2400    
2401    // Add data brushing functionality only for bar and stacked charts (not line charts due to zoom conflict)
2402    if (plotType === 'bar' || plotType === 'stacked' || plotType === 'stacked_bar') {
2403        addDataBrushing(svg, g, width, height, plotType, data, xScale, yScale, containerId);
2404    }
2405
2406    // Center the entire legend for pie charts, stacked bar charts, and line charts after layout is complete
2407    if (plotType === 'pie' || plotType === 'stacked' || plotType === 'stacked_bar' || plotType === 'line') {
2408        setTimeout(() => {
2409            try {
2410                const legendBBox = legend.node().getBBox();
2411                const containerWidth = rect.width;
2412                const legendWidth = legendBBox.width;
2413                const centerOffset = (containerWidth - legendWidth) / 2;
2414                
2415                // Update the legend transform to center it
2416                legend.attr('transform', `translate(${Math.max(20, centerOffset)}, ${legendOffsetY})`);
2417            } catch (e) {
2418                console.log('Legend centering failed:', e);
2419            }
2420        }, 50); // Small delay to ensure layout is complete
2421    }
2422
2423    // Zoom disabled for now (can be re-enabled by uncommenting below)
2424    const gx = g.append('g').attr('transform', `translate(0,${height})`);
2425    // const zoom = d3.zoom()
2426    //   .scaleExtent([0.8, 10])
2427    //   .translateExtent([[0, 0], [width, height]])
2428    //   .extent([[0, 0], [width, height]])
2429    //   .on('zoom', (event) => {
2430    //     const t = event.transform;
2431    //     const zx = t.rescaleX(xScale);
2432    //     gx.call(d3.axisBottom(zx).ticks(d3.timeMonth.every(2)).tickFormat(d3.timeFormat('%Y-%m')));
2433    //     g.selectAll('path.series-line').attr('d', d => line.x(d2 => zx(d2.date))(d));
2434    //     g.selectAll('circle.series-dot').attr('cx', d => zx(d.date));
2435    //   });
2436    // svg.call(zoom);
2437
2438    // Helpers to tag elements for toggling & zoom redraw
2439    function cssSafe(name) {
2440      return name.replace(/[^a-zA-Z0-9]/g, '_');
2441    }
2442
2443    // Reflow legend items horizontally with wrapping and adjust SVG/container height
2444    function reflowLegend() {
2445      let x = 0;
2446      let y = 0;
2447      // Only consider visible legend items
2448      const visible = legend.selectAll('g.legend-item').filter(function() {
2449        const disp = this.style.display;
2450        return disp !== 'none';
2451      });
2452      
2453      // Note: Pie chart centering is now handled after reflowLegend() completes
2454      
2455      // Calculate legend layout with proper bounds checking
2456      visible.each(function() {
2457        const node = d3.select(this);
2458        const bbox = this.getBBox();
2459        const itemWidth = Math.ceil(bbox.width) + 16; // swatch+text + spacing
2460        
2461        // Check if this item would exceed the max width
2462        if (x + itemWidth > maxLegendWidth) {
2463          x = 0;
2464          y += rowHeight;
2465        }
2466        
2467        // Ensure item doesn't exceed container bounds
2468        const finalX = Math.min(x, maxLegendWidth - itemWidth);
2469        
2470        // Position the item (pie chart centering handled separately)
2471        node.attr('transform', `translate(${finalX}, ${y})`);
2472        x += itemWidth;
2473      });
2474      const legendHeight = y + rowHeight;
2475      // Add extra space for download/reset buttons at the bottom
2476      const buttonSpace = 80; // Space for the RESET LEGEND and DOWNLOAD PNG buttons
2477      
2478      // Calculate total height with proper components
2479      
2480      // Ensure total height includes the actual chart height, not just legend position
2481      const chartHeight = height + margin.top + margin.bottom; // Actual chart content height
2482      
2483      let totalHeight;
2484      if ((plotType === 'bar' || plotType === 'stacked') && legendOffsetY < 100) {
2485        // For top legend (bar/stacked charts), height = chart + legend + buttons
2486        totalHeight = chartHeight + legendHeight + buttonSpace + 10; // Extra padding for legend
2487      } else {
2488        // For bottom legend, ensure proper spacing
2489        totalHeight = Math.max(chartHeight + legendHeight + buttonSpace + 10, legendOffsetY + legendHeight + 10 + buttonSpace);
2490      }
2491      
2492      // Ensure minimum total height for readability
2493      totalHeight = Math.max(totalHeight, 450);
2494      
2495      svg.attr('height', totalHeight);
2496      containerNode.style.height = `${totalHeight}px`;
2497      
2498      // Debug logging for complex legends
2499      if (legendItems.length > 6) {
2500        console.log('📏 Legend Layout:', {
2501          legendItems: legendItems.length,
2502          legendHeight,
2503          legendRows: Math.ceil(y / rowHeight) + 1,
2504          totalHeight,
2505          chartHeight
2506        });
2507      }
2508      
2509      // Also ensure the container has proper padding/margin
2510      containerNode.style.paddingBottom = '0px';
2511      containerNode.style.marginBottom = '0px';
2512    }
2513
2514    // Tagging after draw
2515    series.forEach((values, seriesName) => {
2516      const safe = cssSafe(seriesName);
2517      g.selectAll('path').filter(function() { return this.__data__ === values; })
2518        .classed('series-line', true)
2519        .classed(`series-${safe}`, true);
2520      g.selectAll('circle').filter(function() { return d3.select(this).datum() && d3.select(this).datum().series === seriesName; })
2521        .classed('series-dot', true)
2522        .classed(`series-${safe}`, true);
2523    });
2524}
2525
2526// Expose reset and download helpers
2527window.resetD3Zoom = function(containerId) {
2528  // Zoom functionality removed
2529}
2530
2531window.downloadD3Chart = function(containerId, filename) {
2532  return new Promise((resolve, reject) => {
2533    const container = document.getElementById(containerId);
2534    const svg = container.querySelector('svg');
2535    if (!svg) {
2536      resolve(false);
2537      return;
2538    }
2539    
2540    try {
2541      const serializer = new XMLSerializer();
2542      const source = serializer.serializeToString(svg);
2543      const svgBlob = new Blob([source], { type: 'image/svg+xml;charset=utf-8' });
2544      const url = URL.createObjectURL(svgBlob);
2545      const img = new Image();
2546      
2547      img.onload = function() {
2548        try {
2549          const canvas = document.createElement('canvas');
2550          canvas.width = svg.viewBox.baseVal.width || svg.getBoundingClientRect().width;
2551          canvas.height = svg.viewBox.baseVal.height || svg.getBoundingClientRect().height;
2552          const ctx = canvas.getContext('2d');
2553          const chartTheme = resolveChartTheme(containerId, container.__originalPlotData);
2554          ctx.fillStyle = chartTheme.exportBg;
2555          ctx.fillRect(0, 0, canvas.width, canvas.height);
2556          ctx.drawImage(img, 0, 0);
2557          
2558          canvas.toBlob((blob) => {
2559            if (blob) {
2560              const a = document.createElement('a');
2561              a.href = URL.createObjectURL(blob);
2562              a.download = filename || 'chart.png';
2563              document.body.appendChild(a);
2564              a.click();
2565              document.body.removeChild(a);
2566              URL.revokeObjectURL(a.href);
2567              URL.revokeObjectURL(url);
2568              resolve(true);
2569            } else {
2570              resolve(false);
2571            }
2572          });
2573        } catch (error) {
2574          URL.revokeObjectURL(url);
2575          resolve(false);
2576        }
2577      };
2578      
2579      img.onerror = function() {
2580        URL.revokeObjectURL(url);
2581        resolve(false);
2582      };
2583      
2584      img.src = url;
2585    } catch (error) {
2586      resolve(false);
2587    }
2588  });
2589}
2590
2591// Reset legend: show all legend entries and series
2592window.resetD3Legend = function(containerId) {
2593  const container = document.getElementById(containerId);
2594  const svg = container.querySelector('svg');
2595  if (!svg) return;
2596  // If we previously re-rendered with a filtered dataset, rebuild the chart
2597  // using the original full dataset stored on the container
2598  if (container.__originalPlotData) {
2599    try {
2600      // Deep clone to avoid accidental mutations
2601      const original = JSON.parse(JSON.stringify(container.__originalPlotData));
2602      return renderD3Chart(containerId, original);
2603    } catch (e) {
2604      return renderD3Chart(containerId, container.__originalPlotData);
2605    }
2606  }
2607  const legendGroup = svg.querySelector('g.legend');
2608  // Show all legend items
2609  (legendGroup ? legendGroup.querySelectorAll('g.legend-item') : svg.querySelectorAll('g.legend-item'))
2610    .forEach(g => { g.style.display = ''; });
2611  // Show all series of any type (lines, dots, bars, box shapes)
2612  svg.querySelectorAll('[class*="series-"]').forEach(el => {
2613    const isLegend = el.closest('g.legend-item');
2614    if (!isLegend) el.style.display = '';
2615  });
2616  // Reflow legend and adjust heights if d3 is available
2617  // Add notes section if notes are provided
2618  let notesHeight = 0;
2619  if (plotData.notes && plotData.notes.length > 0) {
2620    const notesContainer = container.append('div')
2621      .attr('class', 'chart-notes')
2622      .style('margin-top', '10px')
2623      .style('padding', '8px')
2624      .style('background-color', '#f8f9fa')
2625      .style('border-left', '3px solid #6366f1')
2626      .style('border-radius', '4px')
2627      .style('font-size', '12px')
2628      .style('color', '#6b7280');
2629    
2630    // Add toggle button for notes
2631    const notesHeader = notesContainer.append('div')
2632      .style('display', 'flex')
2633      .style('align-items', 'center')
2634      .style('margin-bottom', '4px');
2635    
2636    const toggleButton = notesHeader.append('button')
2637      .attr('class', 'notes-toggle')
2638      .style('background', 'none')
2639      .style('border', 'none')
2640      .style('color', '#6366f1')
2641      .style('cursor', 'pointer')
2642      .style('font-size', '12px')
2643      .style('font-weight', '600')
2644      .style('margin-right', '8px')
2645      .text('ℹ️ Chart Notes (click to toggle)');
2646    
2647    const notesList = notesContainer.append('div')
2648      .attr('class', 'notes-content')
2649      .style('display', 'none'); // Initially hidden
2650    
2651    // Add each note as a bullet point
2652    plotData.notes.forEach(note => {
2653      notesList.append('div')
2654        .style('margin-bottom', '2px')
2655        .html(`• ${note}`);
2656    });
2657    
2658    // Toggle functionality
2659    let notesVisible = false;
2660    toggleButton.on('click', () => {
2661      notesVisible = !notesVisible;
2662      notesList.style('display', notesVisible ? 'block' : 'none');
2663      toggleButton.text(notesVisible ? '▼ Chart Notes (click to hide)' : 'ℹ️ Chart Notes (click to show)');
2664    });
2665    
2666    // Calculate notes height for layout
2667    notesHeight = 40; // Base height for the toggle button
2668  }
2669
2670  if (typeof d3 !== 'undefined' && legendGroup) {
2671    const legend = d3.select(legendGroup);
2672    const margin = { top: 20, right: 20, bottom: 80, left: 80 };
2673    const maxLegendWidth = Math.max(200, svg.getBoundingClientRect().width - margin.left - margin.right);
2674    const rowHeight = 20;
2675    
2676    // For pie charts, calculate total row widths first to center them
2677    const isPieChart = plotData && plotData.plot_type === 'pie';
2678    let totalRowWidths = [];
2679    let currentRowWidth = 0;
2680    let x = 0, y = 0;
2681    
2682    if (isPieChart) {
2683      // First pass: calculate row widths
2684      legend.selectAll('g.legend-item').filter(function() { return this.style.display !== 'none'; })
2685        .each(function() {
2686          const bbox = this.getBBox();
2687          const itemWidth = Math.ceil(bbox.width) + 16;
2688          if (x + itemWidth > maxLegendWidth) {
2689            totalRowWidths.push(currentRowWidth);
2690            currentRowWidth = itemWidth;
2691            x = 0;
2692            y += rowHeight;
2693          } else {
2694            currentRowWidth += itemWidth;
2695          }
2696          x += itemWidth;
2697        });
2698      totalRowWidths.push(currentRowWidth);
2699      
2700      // Reset for actual positioning
2701      x = 0; y = 0;
2702      let currentRow = 0;
2703    }
2704    
2705    // Position legend items with centering for pie charts
2706    legend.selectAll('g.legend-item').filter(function() { return this.style.display !== 'none'; })
2707      .each(function() {
2708        const node = d3.select(this);
2709        const bbox = this.getBBox();
2710        const itemWidth = Math.ceil(bbox.width) + 16;
2711        if (x + itemWidth > maxLegendWidth) { 
2712          x = 0; 
2713          y += rowHeight; 
2714          if (isPieChart) currentRow++;
2715        }
2716        
2717        let finalX = x;
2718        if (isPieChart && totalRowWidths[currentRow]) {
2719          const availableWidth = svg.getBoundingClientRect().width - 40; // Use actual SVG width
2720          const rowCenterOffset = (availableWidth - totalRowWidths[currentRow]) / 2;
2721          finalX = x + Math.max(0, rowCenterOffset);
2722        }
2723        
2724        node.attr('transform', `translate(${finalX}, ${y})`);
2725        x += itemWidth;
2726      });
2727      
2728    const bbox = legend.node().getBBox();
2729    const m = legendGroup.getAttribute('transform').match(/,\s*([\d\.]+)/);
2730    const legendOffsetY = m ? parseFloat(m[1]) : 0;
2731    const totalHeight = legendOffsetY + bbox.height + 30 + notesHeight;
2732    d3.select(svg).attr('height', totalHeight);
2733    container.style.height = `${totalHeight + notesHeight}px`;
2734  }
2735}

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.