energy-visualization-sankey 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/README.md +497 -0
  2. package/babel.config.cjs +28 -0
  3. package/coverage/clover.xml +6 -0
  4. package/coverage/coverage-final.json +1 -0
  5. package/coverage/lcov-report/base.css +224 -0
  6. package/coverage/lcov-report/block-navigation.js +87 -0
  7. package/coverage/lcov-report/favicon.png +0 -0
  8. package/coverage/lcov-report/index.html +101 -0
  9. package/coverage/lcov-report/prettify.css +1 -0
  10. package/coverage/lcov-report/prettify.js +2 -0
  11. package/coverage/lcov-report/sort-arrow-sprite.png +0 -0
  12. package/coverage/lcov-report/sorter.js +210 -0
  13. package/coverage/lcov.info +0 -0
  14. package/demo-caching.js +68 -0
  15. package/dist/core/Sankey.d.ts +294 -0
  16. package/dist/core/Sankey.d.ts.map +1 -0
  17. package/dist/core/events/EventBus.d.ts +195 -0
  18. package/dist/core/events/EventBus.d.ts.map +1 -0
  19. package/dist/core/types/events.d.ts +42 -0
  20. package/dist/core/types/events.d.ts.map +1 -0
  21. package/dist/index.d.ts +19 -0
  22. package/dist/index.d.ts.map +1 -0
  23. package/dist/sankey.esm.js +5212 -0
  24. package/dist/sankey.esm.js.map +1 -0
  25. package/dist/sankey.standalone.esm.js +9111 -0
  26. package/dist/sankey.standalone.esm.js.map +1 -0
  27. package/dist/sankey.standalone.min.js +2 -0
  28. package/dist/sankey.standalone.min.js.map +1 -0
  29. package/dist/sankey.standalone.umd.js +9119 -0
  30. package/dist/sankey.standalone.umd.js.map +1 -0
  31. package/dist/sankey.umd.js +5237 -0
  32. package/dist/sankey.umd.js.map +1 -0
  33. package/dist/sankey.umd.min.js +2 -0
  34. package/dist/sankey.umd.min.js.map +1 -0
  35. package/dist/services/AnimationService.d.ts +229 -0
  36. package/dist/services/AnimationService.d.ts.map +1 -0
  37. package/dist/services/ConfigurationService.d.ts +173 -0
  38. package/dist/services/ConfigurationService.d.ts.map +1 -0
  39. package/dist/services/InteractionService.d.ts +377 -0
  40. package/dist/services/InteractionService.d.ts.map +1 -0
  41. package/dist/services/RenderingService.d.ts +152 -0
  42. package/dist/services/RenderingService.d.ts.map +1 -0
  43. package/dist/services/calculation/GraphService.d.ts +111 -0
  44. package/dist/services/calculation/GraphService.d.ts.map +1 -0
  45. package/dist/services/calculation/SummaryService.d.ts +149 -0
  46. package/dist/services/calculation/SummaryService.d.ts.map +1 -0
  47. package/dist/services/data/DataService.d.ts +167 -0
  48. package/dist/services/data/DataService.d.ts.map +1 -0
  49. package/dist/services/data/DataValidationService.d.ts +48 -0
  50. package/dist/services/data/DataValidationService.d.ts.map +1 -0
  51. package/dist/types/index.d.ts +189 -0
  52. package/dist/types/index.d.ts.map +1 -0
  53. package/dist/utils/Logger.d.ts +88 -0
  54. package/dist/utils/Logger.d.ts.map +1 -0
  55. package/jest.config.cjs +20 -0
  56. package/package.json +68 -0
  57. package/rollup.config.js +131 -0
  58. package/scripts/performance-validation-real.js +411 -0
  59. package/scripts/validate-optimization.sh +147 -0
  60. package/scripts/visual-validation-real-data.js +374 -0
  61. package/src/core/Sankey.ts +1039 -0
  62. package/src/core/events/EventBus.ts +488 -0
  63. package/src/core/types/events.ts +80 -0
  64. package/src/index.ts +35 -0
  65. package/src/services/AnimationService.ts +983 -0
  66. package/src/services/ConfigurationService.ts +497 -0
  67. package/src/services/InteractionService.ts +920 -0
  68. package/src/services/RenderingService.ts +484 -0
  69. package/src/services/calculation/GraphService.ts +616 -0
  70. package/src/services/calculation/SummaryService.ts +394 -0
  71. package/src/services/data/DataService.ts +380 -0
  72. package/src/services/data/DataValidationService.ts +155 -0
  73. package/src/styles/controls.css +184 -0
  74. package/src/styles/sankey.css +211 -0
  75. package/src/types/index.ts +220 -0
  76. package/src/utils/Logger.ts +105 -0
  77. package/tests/numerical-validation.test.js +575 -0
  78. package/tests/setup.js +53 -0
  79. package/tsconfig.json +54 -0
@@ -0,0 +1,983 @@
1
+ import * as d3 from 'd3';
2
+ import {GraphData, SankeyOptions} from '@/types';
3
+ import {EventBus} from '@/core/events/EventBus';
4
+ import {SummaryService} from '@/services/calculation/SummaryService';
5
+ import {GraphService} from '@/services/calculation/GraphService';
6
+ import {Logger} from "@/utils/Logger";
7
+ import {DataService} from "@/services/data/DataService";
8
+ import {ConfigurationService} from "@/services/ConfigurationService";
9
+
10
+ // ==================== D3 SELECTION TYPES ====================
11
+
12
+ type D3SVGSelection = d3.Selection<SVGSVGElement, unknown, HTMLElement, any>;
13
+ type D3DivSelection = d3.Selection<HTMLDivElement, unknown, HTMLElement, any>;
14
+
15
+ // ==================== ANIMATION INTERFACES ====================
16
+
17
+ interface AnimationState {
18
+ currentYearIndex: number;
19
+ isAnimating: boolean;
20
+ animationTimer: number | null;
21
+ speed: number;
22
+ }
23
+
24
+ export interface GraphNest {
25
+ strokes: { [year: number]: { [fuel: string]: { [sector: string]: number } } };
26
+ tops: { [year: number]: { [fuel: string]: number } };
27
+ heights: { [year: number]: { [sector: string]: number } };
28
+ waste: { [year: number]: { [sector: string]: number } };
29
+ }
30
+
31
+ /**
32
+ * Animation Control Service - Advanced Timeline Management & Smooth Transitions
33
+ *
34
+ * ARCHITECTURAL RESPONSIBILITY: Temporal Visualization Control & User Interaction
35
+ *
36
+ * This service implements sophisticated animation control patterns for temporal energy
37
+ * visualizations, managing smooth year-to-year transitions, timeline navigation,
38
+ * milestone events, and user interaction with historical energy data.
39
+ *
40
+ * TEMPORAL VISUALIZATION PATTERNS:
41
+ * 1. **Timeline Navigation**: Seamless movement through 200+ years of energy history
42
+ * 2. **State Management**: Centralized animation state with event-driven updates
43
+ * 3. **Smooth Transitions**: D3.js-powered animations with configurable timing
44
+ * 4. **Milestone Integration**: Interactive historical event markers and dialogs
45
+ * 5. **User Controls**: Play/pause/seek controls with keyboard accessibility
46
+ * 6. **Loop Management**: Configurable animation looping for presentations
47
+ *
48
+ * ANIMATION ARCHITECTURE:
49
+ * - **Timeline State**: Current year, animation status, timing controls
50
+ * - **Transition Management**: Smooth interpolation between energy data years
51
+ * - **Interactive Controls**: Slider, buttons, and keyboard input handling
52
+ * - **Milestone System**: Historical event markers with contextual information
53
+ * - **Performance Optimization**: Efficient DOM updates and animation scheduling
54
+ *
55
+ * USER INTERACTION DESIGN:
56
+ * - Intuitive timeline slider with year selection
57
+ * - Responsive play/pause controls
58
+ * - Keyboard navigation (arrow keys, space bar)
59
+ * - Milestone hover/click interactions
60
+ * - Configurable playback speed controls
61
+ *
62
+ * EVENT-DRIVEN INTEGRATION:
63
+ * Communicates with other services via event bus for coordinated visual updates,
64
+ * ensuring synchronized animation across all visualization components.
65
+ */
66
+ export class AnimationService {
67
+ // ANIMATION STATE MANAGEMENT: Centralized temporal navigation state
68
+ // Tracks current position, timing, and animation status for coordinated updates
69
+ private state: AnimationState = {
70
+ currentYearIndex: 0, // Array index of current year in timeline
71
+ isAnimating: false, // Animation playback status flag
72
+ animationTimer: null, // JavaScript timer ID for animation loop
73
+ speed: 200 // Animation speed in milliseconds per year
74
+ };
75
+
76
+ // VISUALIZATION REFERENCES: D3.js selections and data structures
77
+ // Maintained for efficient updates during animation transitions
78
+ private svg: D3SVGSelection | null = null; // Main chart SVG element
79
+ private tooltip: D3DivSelection | null = null; // Interactive tooltip element
80
+ private graphs: GraphData[] = []; // Pre-calculated visualization data
81
+ private graphNest: GraphNest | null = null; // Nested data structure for efficient access
82
+ private sliderWidth: number | null = null;
83
+
84
+ constructor(
85
+ private configService: ConfigurationService,
86
+ private summaryCalculationService: SummaryService,
87
+ private graphCalculationService: GraphService,
88
+ private dataService: DataService,
89
+ private options: SankeyOptions,
90
+ private eventBus: EventBus,
91
+ private logger: Logger,
92
+ ) {
93
+ // ANIMATION TIMING INITIALIZATION: Configure playback speed from user options
94
+ // Speed determines milliseconds between year transitions during animation
95
+ // Range: 50ms (very fast) to 1000ms+ (very slow) for different presentation needs
96
+ this.state.speed = this.configService.SPEED;
97
+
98
+ // ANIMATION CONTROL SERVICE READY: Timeline management system initialized
99
+ // All temporal visualization capabilities are now available for energy data navigation
100
+ }
101
+
102
+ /**
103
+ * Receives pre-built data structures and sets up animation system
104
+ */
105
+ public setupAnimation(
106
+ graphs: GraphData[],
107
+ graphNest: GraphNest,
108
+ svg: D3SVGSelection,
109
+ tooltip: D3DivSelection
110
+ ): void {
111
+ this.logger.log('AnimationService: Setting up animation controls...');
112
+
113
+ // Extract years from graphs
114
+ this.state.currentYearIndex = 0;
115
+
116
+ // Store references in state
117
+ this.svg = svg;
118
+ this.tooltip = tooltip;
119
+ this.graphs = graphs;
120
+ this.graphNest = graphNest;
121
+
122
+ // Set up complete timeline controls
123
+ this.setupTimelineControls();
124
+
125
+ // Initialize with first year
126
+ this.setYear(this.dataService.firstYear);
127
+
128
+ this.logger.log(`AnimationControlService: Animation setup complete for ${this.dataService.yearsLength} years`);
129
+ }
130
+
131
+
132
+ /**
133
+ * Sets up slider, year labels, tick marks, and milestone interactions
134
+ */
135
+ private setupTimelineControls(): void {
136
+ // Range slider event handler
137
+ const animationServiceRef = this; // Capture reference for closure
138
+ d3.select('#rangeSlider').on('input', function (this: any) {
139
+ const rangeElement = this as HTMLInputElement; // 'this' is the slider element
140
+ const value = parseFloat(rangeElement.value);
141
+
142
+ // Call animation service method
143
+ animationServiceRef.setYear(value);
144
+
145
+ // Update slider indicator position
146
+ animationServiceRef.updateSliderIndicator();
147
+ });
148
+
149
+ // Create timeline sliders
150
+ const rangeSliderElement = document.getElementById('rangeSlider');
151
+ this.sliderWidth = rangeSliderElement ?
152
+ rangeSliderElement.getBoundingClientRect().width : 1200;
153
+
154
+ // Top year labels
155
+ const svgTopYear = d3.select('#axisTop')
156
+ .style('margin', '-5px')
157
+ .style('margin-left', '5px')
158
+ .append('svg')
159
+ .attr('id', 'sliderYear')
160
+ .attr('width', this.sliderWidth)
161
+ .attr('height', 40)
162
+ .attr('preserveAspectRatio', 'xMinYMin meet')
163
+ .attr('viewBox', `0 0 ${this.sliderWidth} 40`);
164
+
165
+ // Bottom tick marks
166
+ const svgTick = d3.select('#testTick')
167
+ .style('height', '15px')
168
+ .style('margin', '-5px')
169
+ .style('margin-top', '-7px')
170
+ .style('margin-left', '5px')
171
+ .append('svg')
172
+ .attr('id', 'slider')
173
+ .attr('width', this.sliderWidth)
174
+ .attr('height', 50)
175
+ .attr('preserveAspectRatio', 'xMinYMin meet')
176
+ .attr('viewBox', `0 0 ${this.sliderWidth} 50`);
177
+
178
+ // Set up milestone years and dialogs
179
+ this.setupMilestones(svgTopYear, svgTick);
180
+
181
+ // Initialize slider range and position
182
+ const rangeSlider = document.getElementById('rangeSlider') as HTMLInputElement;
183
+ if (rangeSlider) {
184
+ rangeSlider.min = this.dataService.firstYear.toString();
185
+ rangeSlider.max = this.dataService.lastYear.toString();
186
+ rangeSlider.value = this.dataService.firstYear.toString();
187
+
188
+ // Initialize indicator position
189
+ this.updateSliderIndicator();
190
+ }
191
+ }
192
+
193
+ /**
194
+ * Creates milestone markers and dialog interactions
195
+ */
196
+ private setupMilestones(
197
+ svgTopYear: D3SVGSelection,
198
+ svgTick: D3SVGSelection,
199
+ ): void {
200
+ // All milestone years identified for timeline visualization
201
+ const scale = d3.scaleLinear()
202
+ .range([0, this.sliderWidth! - this.configService.LEFT_X])
203
+ .domain([this.dataService.firstYear, this.dataService.lastYear]);
204
+
205
+ let step = 15;
206
+ if (this.dataService.yearsLength < 50) {
207
+ step = 5;
208
+ }
209
+ const yearTop = [];
210
+ for (let i = 5; i < this.dataService.yearsLength; i += step) {
211
+ yearTop.push(this.dataService.years[i]);
212
+ }
213
+
214
+ // Top axis with year labels
215
+ const axisTop = d3.axisTop(scale)
216
+ .tickValues(yearTop)
217
+ .tickFormat(d => Math.floor(d as number).toString());
218
+
219
+ const gY = svgTopYear.append("g")
220
+ .attr("transform", "translate(0, 53)")
221
+ .call(axisTop)
222
+ .call(g => g.select(".domain").remove())
223
+ .call(g => g.selectAll("line").remove());
224
+
225
+ gY.selectAll('.tick text').attr('y', -25);
226
+
227
+ // Extract milestone years
228
+ const milestoneYears: number[] = this.dataService.getYearsWithMilestones();
229
+
230
+ // Bottom axis with milestone markers
231
+ const axisBottom = d3.axisBottom(scale)
232
+ .tickValues(milestoneYears)
233
+ .tickFormat(() => "\u25CF"); // Black dot character
234
+
235
+ const gX = svgTick.append("g")
236
+ .attr("transform", "translate(4, 0)")
237
+ .call(axisBottom)
238
+ .call(g => g.select(".domain").remove());
239
+
240
+ gX.selectAll('.tick text')
241
+ .attr("data-toggle", "dialog")
242
+ .style("font-size", "20px")
243
+ .attr('y', 3);
244
+
245
+ // Set up milestone dialog interactions
246
+ this.setupMilestoneDialogs(gX);
247
+ }
248
+
249
+ /**
250
+ * Handles milestone dot clicks and dialog positioning
251
+ */
252
+ private setupMilestoneDialogs(
253
+ gX: any,
254
+ ): void {
255
+
256
+ // Create dialog object
257
+ const milestoneDialog = this.createMilestoneDialog();
258
+
259
+ // Global click handler for closing dialog
260
+ setTimeout(() => {
261
+ document.addEventListener('click', (event: MouseEvent) => {
262
+ const dialog = document.getElementById('dialog');
263
+ if (!dialog || !milestoneDialog.isOpen) return;
264
+
265
+ const isClickInsideDialog = dialog.contains(event.target as Node);
266
+ const tickElement = (event.target as Element).closest('.tick');
267
+ const isClickOnMilestone = tickElement !== null;
268
+
269
+ if (!isClickInsideDialog && !isClickOnMilestone) {
270
+ milestoneDialog.close();
271
+ }
272
+ }, true);
273
+ }, 100);
274
+
275
+ // Milestone click handlers (proper D3 context)
276
+ // Configure interactive milestone click handlers
277
+ const animationServiceRef = this; // Capture reference for closure
278
+
279
+ gX.selectAll(".tick").select("text")
280
+ .on("click", function (this: any, event: any, d: any) {
281
+ // Milestone interaction detected for year navigation
282
+ const clickedElement = this as HTMLElement; // Now 'this' is the DOM element
283
+ const year = d; // Year is passed as data
284
+
285
+ // Stop propagation
286
+ if (event) {
287
+ event.stopPropagation();
288
+ }
289
+
290
+ // Set diagram to this year
291
+ animationServiceRef.setYear(year);
292
+
293
+ // Update slider
294
+ const rangeSlider = d3.select('#rangeSlider').node() as HTMLInputElement;
295
+ if (rangeSlider) {
296
+ rangeSlider.focus();
297
+ rangeSlider.value = year.toString();
298
+ }
299
+
300
+ animationServiceRef.updateSliderIndicator();
301
+
302
+ // Stop any running animation
303
+ if (animationServiceRef.state.animationTimer) {
304
+ animationServiceRef.pause();
305
+ }
306
+
307
+ d3.select("#play-button").classed("playbutton", true);
308
+
309
+ // Show milestone dialog
310
+ const yearData = animationServiceRef.dataService.getYearData(year);
311
+ // Year milestone information retrieved
312
+
313
+ if (!yearData?.milestone) {
314
+ // No milestone data available for specified year
315
+ return;
316
+ }
317
+
318
+ // Display milestone dialog with historical content
319
+
320
+ // Calculate dialog positioning
321
+ const milestoneYearGroups = {
322
+ left: yearData.year >= 1800 && yearData.year <= 1862,
323
+ center: yearData.year >= 1877 && yearData.year <= 1933,
324
+ right: yearData.year >= 1947 && yearData.year <= 2019,
325
+ };
326
+
327
+ const dialogContent = `<b>${yearData.year}: </b>${yearData.milestone}`;
328
+ const dialogWidth = Math.ceil(animationServiceRef.sliderWidth! / 2);
329
+ const rect = clickedElement.getBoundingClientRect();
330
+ const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
331
+ const scrollLeft = window.pageXOffset || document.documentElement.scrollLeft;
332
+
333
+ let leftPosition: number;
334
+
335
+ if (milestoneYearGroups.left) {
336
+ leftPosition = rect.left + scrollLeft;
337
+ } else if (milestoneYearGroups.right) {
338
+ leftPosition = rect.right + scrollLeft - dialogWidth;
339
+ } else {
340
+ leftPosition = rect.left + scrollLeft + (rect.width / 2) - (dialogWidth / 2);
341
+ }
342
+
343
+ leftPosition = Math.max(10, Math.min(leftPosition, window.innerWidth - dialogWidth - 10));
344
+
345
+ if (milestoneDialog.isOpen) {
346
+ milestoneDialog.close();
347
+ }
348
+
349
+ setTimeout(() => {
350
+ milestoneDialog.html(dialogContent);
351
+ const topPosition = rect.bottom + scrollTop + 5;
352
+
353
+ if (milestoneDialog.dialog) {
354
+ milestoneDialog.dialog.style.width = `${dialogWidth}px`;
355
+ milestoneDialog.dialog.style.left = `${leftPosition}px`;
356
+ milestoneDialog.dialog.style.top = `${topPosition}px`;
357
+ }
358
+
359
+ milestoneDialog.open();
360
+
361
+ // Mobile adjustments
362
+ if (window.innerWidth < 768) {
363
+ milestoneDialog.dialog.style.width = `${window.innerWidth - 20}px`;
364
+ }
365
+ }, 20);
366
+ })
367
+ .style('cursor', 'pointer')
368
+ .attr('title', function (this: any, d: any) {
369
+ const year = d as number;
370
+ const milestoneData = animationServiceRef.dataService!.getYearData(year);
371
+ return milestoneData?.milestone ? `Click to see ${year} milestone` : '';
372
+ });
373
+ }
374
+
375
+ /**
376
+ * Creates dialog element with same styling and behavior
377
+ */
378
+ private createMilestoneDialog(): any {
379
+ let dialogElement = document.getElementById('dialog');
380
+ if (!dialogElement) {
381
+ dialogElement = document.createElement('div');
382
+ dialogElement.id = 'dialog';
383
+ dialogElement.style.opacity = '0';
384
+ document.body.appendChild(dialogElement);
385
+ }
386
+
387
+ return {
388
+ isOpen: false,
389
+ dialog: dialogElement,
390
+ open() {
391
+ this.dialog.style.display = 'block';
392
+ this.dialog.style.visibility = 'visible';
393
+ this.dialog.style.position = 'absolute';
394
+ this.dialog.style.background = '#fff';
395
+ this.dialog.style.border = '1px solid #ddd';
396
+ this.dialog.style.borderRadius = '4px';
397
+ this.dialog.style.boxShadow = '0 4px 12px rgba(0,0,0,0.15)';
398
+ this.dialog.style.padding = '15px';
399
+ this.dialog.style.zIndex = '1000';
400
+ this.dialog.style.fontSize = '14px';
401
+ this.dialog.style.lineHeight = '1.4';
402
+ this.dialog.style.color = '#333';
403
+ this.dialog.style.maxWidth = '90vw';
404
+ this.dialog.style.opacity = '1';
405
+ this.isOpen = true;
406
+ },
407
+ close() {
408
+ this.dialog.style.opacity = '0';
409
+ this.dialog.style.display = 'none';
410
+ this.isOpen = false;
411
+ },
412
+ html(content: string) {
413
+ this.dialog.innerHTML = content;
414
+ },
415
+ option(options: { width?: number | string; position?: any }) {
416
+ if (options.width) {
417
+ this.dialog.style.width = typeof options.width === 'number' ?
418
+ `${options.width}px` : options.width;
419
+ }
420
+ }
421
+ };
422
+ }
423
+
424
+ /**
425
+ * Set up play/pause button controls
426
+ */
427
+ private setupPlayControls(): void {
428
+ const playButton = document.getElementById('play-button');
429
+ if (!playButton) return;
430
+
431
+ playButton.addEventListener('click', () => {
432
+ if (this.state.isAnimating) {
433
+ this.pause();
434
+ } else {
435
+ this.play();
436
+ }
437
+ });
438
+
439
+ // Set initial state
440
+ playButton.className = 'playbutton';
441
+ this.state.isAnimating = false;
442
+ }
443
+
444
+ /**
445
+ * Set up year display element
446
+ */
447
+ private setupYearDisplay(): void {
448
+ const yearOutput = document.getElementById('dynamicYear') as HTMLOutputElement;
449
+ if (yearOutput) {
450
+ yearOutput.textContent = this.dataService.years[0].toString();
451
+ }
452
+ }
453
+
454
+ /**
455
+ * Set Year - Programmatic Timeline Navigation with Coordinated Updates
456
+ *
457
+ * NAVIGATION RESPONSIBILITY: Move visualization to specific year with system-wide coordination
458
+ *
459
+ * This method implements the core temporal navigation functionality, orchestrating
460
+ * synchronized updates across all visualization components when changing years.
461
+ * Essential for both user interaction (slider) and programmatic control (API).
462
+ *
463
+ * COORDINATED UPDATE SEQUENCE:
464
+ * 1. **State Validation**: Verify target year exists in available data
465
+ * 2. **State Update**: Update internal animation state to new year
466
+ * 3. **UI Synchronization**: Update slider position to reflect state
467
+ * 4. **Visualization Update**: Trigger complex chart transition animations
468
+ * 5. **Visual Indicators**: Update timeline position indicators
469
+ * 6. **Display Update**: Update year text display elements
470
+ * 7. **Event Broadcasting**: Notify other services of year change
471
+ *
472
+ * ANIMATION INTEGRATION:
473
+ * Seamlessly integrates with animation playback - can be called during
474
+ * active animation for smooth seeking or by user interaction for direct navigation.
475
+ *
476
+ * PERFORMANCE CONSIDERATIONS:
477
+ * Efficiently updates only necessary DOM elements and triggers minimal
478
+ * re-calculations by leveraging pre-computed mathematical data structures.
479
+ */
480
+ public setYear(year: number): void {
481
+ // YEAR VALIDATION: Ensure target year exists in available data
482
+ const yearIndex = this.dataService.getYearIndex(year);
483
+ if (yearIndex === -1) {
484
+ console.warn(`AnimationControlService: Year ${year} not found in available years`);
485
+ return;
486
+ }
487
+
488
+ // PREVIOUS STATE TRACKING: Capture current state for event data and comparison
489
+ const previousYear = this.getCurrentYear();
490
+
491
+ // STATE UPDATE: Set new current year position in timeline
492
+ this.state.currentYearIndex = yearIndex;
493
+
494
+ // UI SYNCHRONIZATION: Update range slider position to reflect new state
495
+ // Critical for maintaining UI consistency when year is changed programmatically
496
+ const rangeSlider = document.getElementById('rangeSlider') as HTMLInputElement;
497
+ if (rangeSlider) {
498
+ rangeSlider.value = year.toString();
499
+ }
500
+
501
+ // VISUALIZATION UPDATE: Trigger complex chart transition to new year
502
+ // This is the core visualization update mechanism - handles all visual transitions
503
+ this.animatePeriod(yearIndex);
504
+
505
+ // DISPLAY ELEMENT UPDATES: Update year text display
506
+ this.updateYearDisplay(year);
507
+
508
+ // EVENT SYSTEM INTEGRATION: Notify other services of year change
509
+ // Enables coordinated updates across the entire visualization system
510
+ this.eventBus.emit({
511
+ type: 'year.changed',
512
+ timestamp: Date.now(),
513
+ source: 'AnimationControlService',
514
+ data: {
515
+ year, // New target year
516
+ previousYear, // Previous year for transition context
517
+ yearIndex, // Array index for efficient data access
518
+ isAnimating: this.state.isAnimating // Animation state for context
519
+ },
520
+ });
521
+ }
522
+
523
+ /**
524
+ * Handles the complex animation transitions between years
525
+ */
526
+ private animatePeriod(yearIndex: number): void {
527
+ if (!this.svg || !this.graphs || !this.graphNest) return;
528
+
529
+ const svg = this.svg;
530
+ const tooltip = this.tooltip;
531
+ const graphs = this.graphs;
532
+ const graphNest = this.graphNest;
533
+
534
+ // Hide/show labels based on data values
535
+ svg.selectAll('.label')
536
+ .classed('hidden', function (this: any) {
537
+ const d = d3.select(this);
538
+ if (d.classed('sector')) {
539
+ const sector = d.attr('data-sector');
540
+ return graphs[yearIndex]?.totals[sector] <= 0;
541
+ } else if (d.classed('fuel')) {
542
+ const fuel = d.attr('data-fuel');
543
+ return graphs[yearIndex]?.totals[fuel] <= 0;
544
+ }
545
+ return false;
546
+ });
547
+
548
+ // Set up hover interactions and animations
549
+ const configService = this.configService;
550
+ const graphCalculationService = this.graphCalculationService;
551
+ const summaryCalculationService = this.summaryCalculationService;
552
+ const years = this.dataService.years;
553
+
554
+ d3.selectAll('.animate')
555
+ .on('mouseover', function (this: any, event: any) {
556
+ if (!tooltip) return;
557
+
558
+ const d = d3.select(this);
559
+
560
+ if (d.classed('flow')) {
561
+ const fuel = d.attr('data-fuel');
562
+ const sector = d.attr('data-sector');
563
+
564
+ const flowData = graphs[yearIndex]?.graph.find((e: any) =>
565
+ e.fuel === fuel && e.box === sector
566
+ );
567
+
568
+ if (flowData) {
569
+ tooltip.attr("style", "");
570
+ tooltip.transition().duration(200).style('opacity', 1);
571
+
572
+ const fuelName = configService.getFuelDisplayName(flowData.fuel);
573
+ const sectorName = configService.getBoxDisplayName(flowData.box);
574
+ const value = graphCalculationService.sigfig2(flowData.value);
575
+
576
+ // use event object for mouse position
577
+ const mouseX = event?.pageX || 0;
578
+ const mouseY = event?.pageY || 0;
579
+
580
+ tooltip.html(`${fuelName} → ${sectorName}<div class='fuel_value'>${value}</div>`)
581
+ .style('left', `${mouseX}px`)
582
+ .style('top', `${mouseY - 35}px`);
583
+ }
584
+ } else if (d.classed('fuel') && !d.classed('elec') && !d.classed('heat')) {
585
+ if (!tooltip) return;
586
+
587
+ tooltip.attr("style", "");
588
+ tooltip.transition().duration(200).style('opacity', 1);
589
+
590
+ const fuel = d.attr('data-fuel');
591
+ const value = parseFloat(d.attr('data-value') || '0');
592
+ const fuelName = configService.getFuelDisplayName(fuel);
593
+
594
+ // use event object for mouse position
595
+ const mouseX = event?.pageX || 0;
596
+ const mouseY = event?.pageY || 0;
597
+
598
+ tooltip.html(`${fuelName} → ${graphCalculationService.sigfig2(value)}`)
599
+ .style('left', `${mouseX}px`)
600
+ .style('top', `${mouseY - 35}px`);
601
+ }
602
+ })
603
+ .on('mouseout', () => {
604
+ if (tooltip) {
605
+ tooltip.transition().duration(500).style('opacity', 0);
606
+ }
607
+ })
608
+ .transition()
609
+ .duration(5 * this.configService.SPEED)
610
+ .ease(d3.easeLinear)
611
+ .on('start', function (this: any) {
612
+ const d = d3.select(this);
613
+ const activeTransition = d3.active(this);
614
+
615
+ if (!activeTransition) return;
616
+
617
+ activeTransition
618
+ .attr('d', function (this: any) {
619
+ if (d.classed('flow')) {
620
+ const fuel = d.attr('data-fuel');
621
+ const sector = d.attr('data-sector');
622
+
623
+ const flowData = graphs[yearIndex]?.graph.find((e: any) =>
624
+ e.fuel === fuel && e.box === sector
625
+ );
626
+
627
+ if (flowData) {
628
+ const lineGen = graphCalculationService.createLine();
629
+ return lineGen([flowData.a, flowData.b, flowData.c, flowData.d]);
630
+ }
631
+ }
632
+ return d.attr('d');
633
+ })
634
+ .attr('stroke-width', function (this: any) {
635
+ if (d.classed('flow')) {
636
+ // access stroke value directly
637
+ let s = graphNest.strokes[years[yearIndex]][d.attr('data-fuel')][d.attr('data-sector')] as unknown as number;
638
+ if (s > 0) {
639
+ return s + configService.BLEED;
640
+ }
641
+ return 0;
642
+ }
643
+ return d.attr('stroke-width');
644
+ })
645
+ .attr('y', function (this: any) {
646
+ if (d.classed('box') && d.classed('fuel')) {
647
+ return graphNest.tops[years[yearIndex]][d.attr('data-fuel')];
648
+ } else if (d.classed('label') && d.classed('fuel')) {
649
+ return graphNest.tops[years[yearIndex]][d.attr('data-fuel')] - 5;
650
+ }
651
+ return d.attr('y');
652
+ })
653
+ .attr('height', function (this: any) {
654
+ if (d.classed('box') && d.classed('sector')) {
655
+ return graphNest.heights[years[yearIndex]][d.attr('data-sector')];
656
+ }
657
+ return d.attr('height');
658
+ })
659
+ .attr('data-value', function (this: any) {
660
+ if (d.classed('label') && d.classed('fuel') && !d.classed('elec') && !d.classed('heat')) {
661
+ return graphs[yearIndex].totals[d.attr('data-fuel')];
662
+ }
663
+ return d.attr('data-value');
664
+ })
665
+ .tween('text', function (this: any): any {
666
+ const that = this as HTMLElement;
667
+
668
+ if (d.classed('year')) {
669
+ const a = parseInt(that.textContent || '0');
670
+ const b = years[yearIndex];
671
+ return function (t: number) {
672
+ const v = a + (b - a) * t;
673
+ that.setAttribute('data-value', v.toString());
674
+ that.textContent = Math.round(v).toString();
675
+ };
676
+ } else if (d.classed('year-total')) {
677
+ // calculate total energy usage per capita
678
+ return function (t: number) {
679
+ const yearSums = summaryCalculationService.yearSums!;
680
+ const sum_value = Math.floor(yearSums[years[yearIndex]] || 0);
681
+ that.setAttribute('data-value', sum_value.toString());
682
+ that.textContent = `${Math.round(sum_value)} W/capita`;
683
+ };
684
+ } else if (d.classed('waste-level')) {
685
+ // animate waste heat values
686
+ const a = parseFloat(that.getAttribute('data-value') || '0');
687
+ const b = graphNest.waste[years[yearIndex]]?.[that.getAttribute('data-sector') || ''] || 0;
688
+ return function (t: number) {
689
+ const v = a + (b - a) * t;
690
+ that.setAttribute('data-value', v.toString());
691
+ that.textContent = (graphCalculationService.sigfig2(v) || 0).toString();
692
+ };
693
+ } else if (d.classed('total')) {
694
+ const a = parseFloat(that.getAttribute('data-value') || '0');
695
+ const b = graphs[yearIndex].totals[that.getAttribute('data-sector') || ''] || 0;
696
+ return function (t: number) {
697
+ const v = a + (b - a) * t;
698
+ that.setAttribute('data-value', v.toString());
699
+ that.textContent = graphCalculationService.sigfig2(v).toString();
700
+ };
701
+ }
702
+
703
+ return null;
704
+ });
705
+ });
706
+
707
+ // Update slider position
708
+ this.updateSliderIndicator();
709
+ }
710
+
711
+ /**
712
+ * Updates the position and content of the year indicator above the slider
713
+ */
714
+ private updateSliderIndicator(): void {
715
+ const slider = document.getElementById("rangeSlider") as HTMLInputElement;
716
+ const indicator = document.getElementById('dynamicYear') as HTMLElement;
717
+
718
+ if (!slider || !indicator) return;
719
+
720
+ // Get current year and slider state
721
+ const currentYear = this.getCurrentYear();
722
+ const minYear = this.dataService.firstYear;
723
+ const maxYear = this.dataService.lastYear;
724
+
725
+ // Calculate precise positioning
726
+ const position = this.calculateIndicatorPosition(slider, currentYear, minYear, maxYear);
727
+
728
+ // Apply positioning and content
729
+ this.applyIndicatorPosition(indicator, position, currentYear);
730
+ }
731
+
732
+ private calculateIndicatorPosition(
733
+ slider: HTMLInputElement,
734
+ currentYear: number,
735
+ minYear: number,
736
+ maxYear: number
737
+ ): number {
738
+ const sliderRect = slider.getBoundingClientRect();
739
+ const progress = (currentYear - minYear) / (maxYear - minYear);
740
+
741
+ // Account for thumb dimensions (11px width from CSS)
742
+ const thumbWidth = 11;
743
+ const effectiveWidth = sliderRect.width - thumbWidth;
744
+ const thumbCenter = (thumbWidth / 2) + (progress * effectiveWidth);
745
+
746
+ // Center 54px indicator over thumb
747
+ return thumbCenter - 26; // 54px / 2 = 26px
748
+ }
749
+
750
+ private applyIndicatorPosition(
751
+ indicator: HTMLElement,
752
+ position: number,
753
+ year: number
754
+ ): void {
755
+ indicator.style.left = `${position}px`;
756
+ indicator.textContent = year.toString();
757
+
758
+ this.logger.log(`AnimationControlService: Indicator positioned: ${position.toFixed(1)}px for year ${year}`);
759
+ }
760
+
761
+ private updateYearDisplay(year: number): void {
762
+ const yearOutput = document.getElementById('dynamicYear') as HTMLOutputElement;
763
+ if (yearOutput) {
764
+ yearOutput.textContent = year.toString();
765
+ }
766
+ }
767
+
768
+ // ==================== PUBLIC ANIMATION CONTROL METHODS ====================
769
+
770
+ /**
771
+ * Start Animation Playback - Temporal Visualization Timeline Control
772
+ *
773
+ * PLAYBACK RESPONSIBILITY: Initiate automated year-by-year progression through energy data
774
+ *
775
+ * This method starts the animation loop that automatically advances through years
776
+ * of energy data, creating a cinematic progression through US energy history.
777
+ * Essential for presentation mode and automated demonstration of energy trends.
778
+ *
779
+ * ANIMATION LIFECYCLE MANAGEMENT:
780
+ * 1. **Guard Clause**: Prevent multiple simultaneous animations
781
+ * 2. **State Update**: Set animation flag for system-wide coordination
782
+ * 3. **UI Update**: Change play button to pause state for user feedback
783
+ * 4. **Timer Initialization**: Start interval-based animation loop
784
+ * 5. **Event Broadcasting**: Notify other services animation has started
785
+ *
786
+ * TIMING MECHANISM:
787
+ * Uses JavaScript setInterval() for consistent frame timing at configured speed.
788
+ * Timer interval determined by this.state.speed (milliseconds between years).
789
+ * Each timer tick calls nextFrame() for year progression logic.
790
+ *
791
+ * USER INTERACTION INTEGRATION:
792
+ * Updates visual play button state to indicate animation status,
793
+ * providing immediate visual feedback for user understanding.
794
+ */
795
+ public play(): void {
796
+ // GUARD CLAUSE: Prevent multiple simultaneous animations
797
+ // Essential for preventing timer conflicts and state corruption
798
+ if (this.state.isAnimating) return;
799
+
800
+ // STATE UPDATE: Set animation active flag for system coordination
801
+ this.state.isAnimating = true;
802
+
803
+ // UI FEEDBACK: Update play button visual state for user clarity
804
+ const playButton = document.getElementById('play-button');
805
+ if (playButton) {
806
+ playButton.className = 'playpaused'; // Visual state: playing → show pause icon
807
+ }
808
+
809
+ // ANIMATION TIMER INITIALIZATION: Start automated year progression
810
+ // setInterval creates consistent timing for smooth temporal navigation
811
+ this.state.animationTimer = window.setInterval(() => {
812
+ this.nextFrame(); // Advance to next year in sequence
813
+ }, this.state.speed); // Configurable timing (50-1000ms typically)
814
+
815
+ // EVENT SYSTEM INTEGRATION: Broadcast animation start to other services
816
+ // Enables coordinated behavior across visualization components during playback
817
+ this.eventBus.emit({
818
+ type: 'animation.started',
819
+ timestamp: Date.now(),
820
+ source: 'AnimationControlService',
821
+ data: {
822
+ isPlaying: true,
823
+ currentYear: this.getCurrentYear(), // Starting year for context
824
+ speed: this.state.speed // Playback speed for coordination
825
+ },
826
+ });
827
+ }
828
+
829
+ /**
830
+ * Pause Animation Playback - Temporal Visualization Control
831
+ *
832
+ * PAUSE RESPONSIBILITY: Stop automated timeline progression while preserving current position
833
+ *
834
+ * This method halts the animation loop while maintaining the current year position,
835
+ * enabling users to pause for detailed examination of specific time periods.
836
+ * Critical for interactive exploration and presentation control.
837
+ *
838
+ * PAUSE LIFECYCLE MANAGEMENT:
839
+ * 1. **Guard Clause**: Ensure animation is actually running before stopping
840
+ * 2. **State Update**: Clear animation flag for system coordination
841
+ * 3. **UI Update**: Restore play button state for user interface consistency
842
+ * 4. **Timer Cleanup**: Properly clear interval timer to prevent memory leaks
843
+ * 5. **Event Broadcasting**: Notify other services animation has stopped
844
+ *
845
+ * RESOURCE MANAGEMENT:
846
+ * Properly clears JavaScript interval timer to prevent continued execution
847
+ * and potential memory leaks during long-running visualization sessions.
848
+ */
849
+ public pause(): void {
850
+ // GUARD CLAUSE: Only pause if animation is currently active
851
+ // Prevents unnecessary state changes and event emissions
852
+ if (!this.state.isAnimating) return;
853
+
854
+ // STATE UPDATE: Clear animation active flag for system coordination
855
+ this.state.isAnimating = false;
856
+
857
+ // UI FEEDBACK: Restore play button visual state
858
+ const playButton = document.getElementById('play-button');
859
+ if (playButton) {
860
+ playButton.className = 'playbutton'; // Visual state: paused → show play icon
861
+ }
862
+
863
+ // TIMER CLEANUP: Stop animation interval and prevent memory leaks
864
+ if (this.state.animationTimer) {
865
+ clearInterval(this.state.animationTimer); // Stop the automated year progression
866
+ this.state.animationTimer = null; // Clear timer reference
867
+ }
868
+
869
+ // EVENT SYSTEM INTEGRATION: Broadcast animation stop to other services
870
+ // Enables coordinated pause behavior across visualization components
871
+ this.eventBus.emit({
872
+ type: 'animation.stopped',
873
+ timestamp: Date.now(),
874
+ source: 'AnimationControlService',
875
+ data: {
876
+ isPlaying: false,
877
+ currentYear: this.getCurrentYear(), // Current position preserved
878
+ speed: this.state.speed // Speed settings maintained
879
+ }
880
+ });
881
+ }
882
+
883
+ /**
884
+ * Move to next frame in animation
885
+ * Stop at end instead of looping
886
+ */
887
+ private nextFrame(): void {
888
+ // Handle end of animation based on loopAnimation option
889
+ if (this.state.currentYearIndex + 1 >= this.dataService.yearsLength) {
890
+ this.state.currentYearIndex = 0;
891
+
892
+ if (!this.options.loopAnimation) {
893
+ // Stop at end
894
+ this.pause();
895
+ return;
896
+ }
897
+ }
898
+
899
+ this.nextYear()
900
+ }
901
+
902
+ /**
903
+ * Set animation speed AnimationService.setSpeed()
904
+ */
905
+ public setSpeed(speed: number): void {
906
+ if (speed <= 0) {
907
+ throw new Error('Animation speed must be positive');
908
+ }
909
+
910
+ this.state.speed = speed;
911
+
912
+ // If currently playing, restart with new speed
913
+ if (this.state.isAnimating) {
914
+ this.pause();
915
+ setTimeout(() => {
916
+ this.play();
917
+ }, 100);
918
+ }
919
+
920
+ // Emit speed changed event
921
+ this.eventBus.emit({
922
+ type: 'speed.changed',
923
+ timestamp: Date.now(),
924
+ source: 'AnimationControlService',
925
+ data: {
926
+ speed
927
+ }
928
+ });
929
+
930
+ // Animation speed updated for timeline playback
931
+ }
932
+
933
+ /**
934
+ * Check if animation is currently playing
935
+ */
936
+ public isPlaying(): boolean {
937
+ return this.state.isAnimating;
938
+ }
939
+
940
+ /**
941
+ * Get current year
942
+ */
943
+ public getCurrentYear(): number {
944
+ return this.dataService.years[this.state.currentYearIndex] || this.dataService.firstYear;
945
+ }
946
+
947
+ /**
948
+ * Move to next year
949
+ */
950
+ public nextYear(): void {
951
+ if (this.state.currentYearIndex < this.dataService.yearsLength - 1) {
952
+ this.state.currentYearIndex++;
953
+ this.setYear(this.dataService.years[this.state.currentYearIndex]);
954
+ }
955
+ }
956
+
957
+ /**
958
+ * Move to previous year
959
+ */
960
+ public previousYear(): void {
961
+ if (this.state.currentYearIndex > 0) {
962
+ this.state.currentYearIndex--;
963
+ this.setYear(this.dataService.years[this.state.currentYearIndex]);
964
+ }
965
+ }
966
+
967
+ /**
968
+ * Clean up animation resources
969
+ */
970
+ public cleanup(): void {
971
+ this.pause();
972
+
973
+ // Clear state
974
+ this.state.currentYearIndex = 0;
975
+ this.state.isAnimating = false;
976
+ this.svg = null;
977
+ this.tooltip = null;
978
+ this.graphs = [];
979
+ this.graphNest = null;
980
+
981
+ // Animation service cleanup completed successfully
982
+ }
983
+ }