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.