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.
- package/README.md +497 -0
- package/babel.config.cjs +28 -0
- package/coverage/clover.xml +6 -0
- package/coverage/coverage-final.json +1 -0
- package/coverage/lcov-report/base.css +224 -0
- package/coverage/lcov-report/block-navigation.js +87 -0
- package/coverage/lcov-report/favicon.png +0 -0
- package/coverage/lcov-report/index.html +101 -0
- package/coverage/lcov-report/prettify.css +1 -0
- package/coverage/lcov-report/prettify.js +2 -0
- package/coverage/lcov-report/sort-arrow-sprite.png +0 -0
- package/coverage/lcov-report/sorter.js +210 -0
- package/coverage/lcov.info +0 -0
- package/demo-caching.js +68 -0
- package/dist/core/Sankey.d.ts +294 -0
- package/dist/core/Sankey.d.ts.map +1 -0
- package/dist/core/events/EventBus.d.ts +195 -0
- package/dist/core/events/EventBus.d.ts.map +1 -0
- package/dist/core/types/events.d.ts +42 -0
- package/dist/core/types/events.d.ts.map +1 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/sankey.esm.js +5212 -0
- package/dist/sankey.esm.js.map +1 -0
- package/dist/sankey.standalone.esm.js +9111 -0
- package/dist/sankey.standalone.esm.js.map +1 -0
- package/dist/sankey.standalone.min.js +2 -0
- package/dist/sankey.standalone.min.js.map +1 -0
- package/dist/sankey.standalone.umd.js +9119 -0
- package/dist/sankey.standalone.umd.js.map +1 -0
- package/dist/sankey.umd.js +5237 -0
- package/dist/sankey.umd.js.map +1 -0
- package/dist/sankey.umd.min.js +2 -0
- package/dist/sankey.umd.min.js.map +1 -0
- package/dist/services/AnimationService.d.ts +229 -0
- package/dist/services/AnimationService.d.ts.map +1 -0
- package/dist/services/ConfigurationService.d.ts +173 -0
- package/dist/services/ConfigurationService.d.ts.map +1 -0
- package/dist/services/InteractionService.d.ts +377 -0
- package/dist/services/InteractionService.d.ts.map +1 -0
- package/dist/services/RenderingService.d.ts +152 -0
- package/dist/services/RenderingService.d.ts.map +1 -0
- package/dist/services/calculation/GraphService.d.ts +111 -0
- package/dist/services/calculation/GraphService.d.ts.map +1 -0
- package/dist/services/calculation/SummaryService.d.ts +149 -0
- package/dist/services/calculation/SummaryService.d.ts.map +1 -0
- package/dist/services/data/DataService.d.ts +167 -0
- package/dist/services/data/DataService.d.ts.map +1 -0
- package/dist/services/data/DataValidationService.d.ts +48 -0
- package/dist/services/data/DataValidationService.d.ts.map +1 -0
- package/dist/types/index.d.ts +189 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/utils/Logger.d.ts +88 -0
- package/dist/utils/Logger.d.ts.map +1 -0
- package/jest.config.cjs +20 -0
- package/package.json +68 -0
- package/rollup.config.js +131 -0
- package/scripts/performance-validation-real.js +411 -0
- package/scripts/validate-optimization.sh +147 -0
- package/scripts/visual-validation-real-data.js +374 -0
- package/src/core/Sankey.ts +1039 -0
- package/src/core/events/EventBus.ts +488 -0
- package/src/core/types/events.ts +80 -0
- package/src/index.ts +35 -0
- package/src/services/AnimationService.ts +983 -0
- package/src/services/ConfigurationService.ts +497 -0
- package/src/services/InteractionService.ts +920 -0
- package/src/services/RenderingService.ts +484 -0
- package/src/services/calculation/GraphService.ts +616 -0
- package/src/services/calculation/SummaryService.ts +394 -0
- package/src/services/data/DataService.ts +380 -0
- package/src/services/data/DataValidationService.ts +155 -0
- package/src/styles/controls.css +184 -0
- package/src/styles/sankey.css +211 -0
- package/src/types/index.ts +220 -0
- package/src/utils/Logger.ts +105 -0
- package/tests/numerical-validation.test.js +575 -0
- package/tests/setup.js +53 -0
- 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
|
+
}
|