Skip to content

Commit d48f59f

Browse files
committed
fix: improve plot event typing and responsiveness
1 parent d9a77a2 commit d48f59f

5 files changed

Lines changed: 236 additions & 49 deletions

File tree

‎MIGRATION.md‎

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,18 +55,42 @@ package compatible.
5555
| --- | --- | --- |
5656
| `2.x` | 9 | Not compatible with Angular 13 |
5757
| `3.x` | 10 | Angular 10 library-format release |
58-
| `5.x` | 13 | Use this line for Angular 13 |
59-
| `6.x` | 16 | Angular 16 release |
58+
| `4.x` | 11–12 | Declares Angular greater than 10.0.9 |
59+
| `5.x` | 13–15 | Angular 13 compatibility line |
60+
| `6.x` | 16–19 | Declares Angular 16 or newer; validate your application before a major Angular upgrade |
61+
| `20.x` | 20 | Angular 20 compatibility line |
62+
| `21.x` | 21 | Peer dependencies are capped below Angular 22 |
63+
| `22.x` | 22 | Current compatibility line; peer dependencies are capped below Angular 23 |
6064

6165
The package version does not increase for every Angular major. Check the
6266
package peer dependencies before upgrading across an Angular major, and use
6367
the package line that declares support for the Angular version in the
6468
application.
6569

70+
### Plotly.js bundles and Angular 19
71+
72+
`angular-plotly.js` does not install a Plotly.js bundle for an application.
73+
The bundle is selected by the application and passed to `PlotlyModule.forRoot`
74+
or `PlotlyService.setPlotly`. The library's own tested baseline is currently
75+
`plotly.js-dist` 2.35.3.
76+
77+
If an Angular 19 test build fails to resolve the `d3-*` modules from
78+
`plotly.js-dist-min` 3.x, pin the bundle to the known-good 2.x line while
79+
investigating the application build configuration:
80+
81+
```bash
82+
npm install plotly.js-dist-min@^2.35.3
83+
```
84+
85+
The wrapper only requires a Plotly object with `newPlot`/`react`; the missing
86+
module errors occur while Angular bundles Plotly.js, before the wrapper runs.
87+
Use a small production build and a test build to validate a move to Plotly.js
88+
3.x before changing the pin.
89+
6690
### Angular 13 follow-up notes
6791

6892
Version 5.0.0 migrated the library to the Ivy engine and removed Protractor
6993
and Codelyzer from the library development setup. Review application code and
7094
CI configuration for those Angular ecosystem changes as part of the Angular
7195
13 migration. The Plotly component API is otherwise unchanged in the release
72-
notes for 5.0.0.
96+
notes for 5.0.0.

‎README.md‎

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,34 @@ You should see a plot like this:
9797
<img src="example.png" alt="Example plot" width="320" height="240">
9898
</p>
9999

100+
### Standalone applications
101+
102+
`PlotlyComponent` is standalone. Configure the Plotly bundle once, then import
103+
the component wherever a chart is rendered:
104+
105+
```typescript
106+
import { Component } from '@angular/core';
107+
import * as PlotlyJS from 'plotly.js-dist-min';
108+
import { PlotlyComponent, PlotlyService } from 'angular-plotly.js';
109+
110+
PlotlyService.setPlotly(PlotlyJS);
111+
112+
@Component({
113+
standalone: true,
114+
imports: [PlotlyComponent],
115+
template: '<plotly-plot [data]="data" [layout]="layout" />',
116+
})
117+
export class SalesChartComponent {
118+
data = [{ x: [1, 2, 3], y: [2, 6, 3], type: 'scatter' }];
119+
layout = { autosize: true };
120+
}
121+
```
122+
123+
To keep Plotly out of the initial bundle, put this setup in a component loaded
124+
through a lazy route. The `plotly.js-dist-min` import then belongs to that lazy
125+
chunk. Do not configure `PlotlyModule.forRoot()` in application-wide providers
126+
when the chart itself is intended to be lazy-loaded.
127+
100128

101129
For a full description of Plotly chart types and attributes see the following resources:
102130

@@ -161,19 +189,19 @@ Event handlers for specific [`plotly.js` events](https://plot.ly/javascript/plot
161189
| `(autoSize)` | `Function` | `plotly_autosize` | |
162190
| `(beforeExport)` | `Function` | `plotly_beforeexport` | |
163191
| `(buttonClicked)` | `Function` | `plotly_buttonclicked` | |
164-
| `(plotlyClick)` | `Function` | `plotly_click` | [why not (click)?](FAQ.md#why-using-plotlyclick-instead-of-click) |
192+
| `(plotlyClick)` | `Plotly.PlotMouseEvent` | `plotly_click` | [why not (click)?](FAQ.md#why-using-plotlyclick-instead-of-click) |
165193
| `(clickAnnotation)` | `Function` | `plotly_clickannotation` | |
166194
| `(deselect)` | `Function` | `plotly_deselect` | |
167195
| `(doubleClick)` | `Function` | `plotly_doubleclick` | |
168196
| `(framework)` | `Function` | `plotly_framework` | |
169-
| `(hover)` | `Function` | `plotly_hover` | |
197+
| `(hover)` | `Plotly.PlotMouseEvent` | `plotly_hover` | |
170198
| `(legendClick)` | `Function` | `plotly_legendclick` | |
171199
| `(legendDoubleClick)` | `Function` | `plotly_legenddoubleclick` | |
172200
| `(react)` | `Function` | `plotly_react` | |
173-
| `(relayout)` | `Function` | `plotly_relayout` | |
174-
| `(restyle)` | `Function` | `plotly_restyle` | |
201+
| `(relayout)` | `Plotly.PlotRelayoutEvent` | `plotly_relayout` | |
202+
| `(restyle)` | `Plotly.PlotRestyleEvent` | `plotly_restyle` | |
175203
| `(redraw)` | `Function` | `plotly_redraw` | |
176-
| `(selected)` | `Function` | `plotly_selected` | |
204+
| `(selected)` | `Plotly.PlotSelectionEvent` | `plotly_selected` | |
177205
| `(selecting)` | `Function` | `plotly_selecting` | |
178206
| `(sliderChange)` | `Function` | `plotly_sliderchange` | |
179207
| `(sliderEnd)` | `Function` | `plotly_sliderend` | |

‎projects/plotly/src/lib/plotly.component.spec.ts‎

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,26 @@
1-
import { SimpleChange, ComponentRef } from '@angular/core';
1+
import { Component, SimpleChange, ComponentRef } from '@angular/core';
22
import { ComponentFixture, TestBed } from '@angular/core/testing';
33

44
import { PlotlyComponent } from './plotly.component';
5+
import { Plotly } from './plotly.interface';
56
import { PlotlyService } from './plotly.service';
67

78
import PlotlyJS from 'plotly.js-dist';
89

910

1011
PlotlyService.setPlotly(PlotlyJS);
1112

13+
@Component({
14+
standalone: true,
15+
imports: [PlotlyComponent],
16+
template: '<plotly-plot (plotlyClick)="capture($event)" />',
17+
})
18+
class TypedEventHostComponent {
19+
capture(event: Plotly.PlotMouseEvent): void {
20+
void event.points;
21+
}
22+
}
23+
1224
describe('PlotlyComponent', () => {
1325
let component: PlotlyComponent;
1426
let fixture: ComponentFixture<PlotlyComponent>;
@@ -19,7 +31,7 @@ describe('PlotlyComponent', () => {
1931
windowSpy = jasmine.createSpyObj('Window', ['addEventListener', 'removeEventListener']);
2032

2133
TestBed.configureTestingModule({
22-
imports: [PlotlyComponent],
34+
imports: [PlotlyComponent, TypedEventHostComponent],
2335
providers: [
2436
PlotlyService,
2537
],
@@ -198,6 +210,40 @@ describe('PlotlyComponent', () => {
198210
expect(deprecatedClick).toHaveBeenCalledWith(event);
199211
});
200212

213+
it('should type-check plotlyClick handlers in standalone templates', () => {
214+
expect(TestBed.createComponent(TypedEventHostComponent).componentInstance).toBeTruthy();
215+
});
216+
217+
it('should resize an autosized plot after a layout update', async () => {
218+
const instance = document.createElement('div') as any;
219+
component.plotlyInstance = instance;
220+
componentRef.setInput('layout', { autosize: true });
221+
spyOn(component.plotly, 'update').and.resolveTo(instance);
222+
spyOn(component.plotly, 'resize');
223+
spyOn(component, 'getWindow').and.returnValue({
224+
requestAnimationFrame: (callback: FrameRequestCallback) => {
225+
callback(0);
226+
return 1;
227+
},
228+
} as Window);
229+
230+
await component.updatePlot();
231+
232+
expect(component.plotly.resize).toHaveBeenCalledWith(instance);
233+
});
234+
235+
it('should not resize after a layout update with explicit dimensions', async () => {
236+
const instance = document.createElement('div') as any;
237+
component.plotlyInstance = instance;
238+
componentRef.setInput('layout', { autosize: true, height: 400 });
239+
spyOn(component.plotly, 'update').and.resolveTo(instance);
240+
spyOn(component.plotly, 'resize');
241+
242+
await component.updatePlot();
243+
244+
expect(component.plotly.resize).not.toHaveBeenCalled();
245+
});
246+
201247
it('should skip Plotly initialization on the server', () => {
202248
const serverComponent = TestBed.runInInjectionContext(() => new PlotlyComponent(
203249
component.plotly,

‎projects/plotly/src/lib/plotly.component.ts‎

Lines changed: 93 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,11 @@ export class PlotlyComponent implements OnInit, OnChanges, OnDestroy, DoCheck {
4141

4242
public plotlyInstance?: Plotly.PlotlyHTMLElement;
4343
public resizeHandler?: (instance: Plotly.PlotlyHTMLElement) => void;
44+
public resizeObserver?: ResizeObserver;
4445
public layoutDiffer?: KeyValueDiffer<string, any>;
4546
public dataDiffer?: IterableDiffer<Plotly.Data>;
4647
private destroyed = false;
48+
private resizeAnimationFrame?: number;
4749

4850
@ViewChild('plot', { static: true }) plotEl!: ElementRef;
4951

@@ -72,49 +74,49 @@ export class PlotlyComponent implements OnInit, OnChanges, OnDestroy, DoCheck {
7274
purge = output<Plotly.Figure>();
7375
error = output<Error>();
7476

75-
afterExport = output();
76-
afterPlot = output();
77-
animated = output();
78-
animatingFrame = output();
79-
animationInterrupted = output();
80-
autoSize = output();
81-
beforeExport = output();
82-
beforeHover = output();
83-
buttonClicked = output();
77+
afterExport = output<Plotly.Event>();
78+
afterPlot = output<Plotly.Event>();
79+
animated = output<Plotly.Event>();
80+
animatingFrame = output<Plotly.Event>();
81+
animationInterrupted = output<Plotly.Event>();
82+
autoSize = output<Plotly.Event>();
83+
beforeExport = output<Plotly.Event>();
84+
beforeHover = output<Plotly.Event>();
85+
buttonClicked = output<Plotly.Event>();
8486
/**
8587
* @deprecated DEPRECATED: Reconsider using `(plotlyClick)` instead of `(click)` to avoid event conflict. Please check https://github.com/plotly/angular-plotly.js#FAQ
8688
*/
87-
click = output();
88-
plotlyClick = output();
89-
clickAnnotation = output();
90-
deselect = output();
91-
doubleClick = output();
92-
framework = output();
93-
hover = output();
94-
legendClick = output();
95-
legendDoubleClick = output();
89+
click = output<Plotly.PlotMouseEvent>();
90+
plotlyClick = output<Plotly.PlotMouseEvent>();
91+
clickAnnotation = output<Plotly.Event>();
92+
deselect = output<Plotly.Event>();
93+
doubleClick = output<Plotly.Event>();
94+
framework = output<Plotly.Event>();
95+
hover = output<Plotly.PlotMouseEvent>();
96+
legendClick = output<Plotly.Event>();
97+
legendDoubleClick = output<Plotly.Event>();
9698
/**
9799
* @deprecated DEPRECATED: Event react is not list as an plotly.js event
98100
*/
99-
react = output();
100-
relayout = output();
101-
relayouting = output();
102-
restyle = output();
103-
redraw = output();
104-
selected = output();
105-
selecting = output();
106-
sliderChange = output();
107-
sliderEnd = output();
108-
sliderStart = output();
109-
sunburstclick = output();
110-
transitioning = output();
111-
transitionInterrupted = output();
112-
unhover = output();
101+
react = output<Plotly.Event>();
102+
relayout = output<Plotly.PlotRelayoutEvent>();
103+
relayouting = output<Plotly.PlotRelayoutEvent>();
104+
restyle = output<Plotly.PlotRestyleEvent>();
105+
redraw = output<Plotly.Event>();
106+
selected = output<Plotly.PlotSelectionEvent>();
107+
selecting = output<Plotly.PlotSelectionEvent>();
108+
sliderChange = output<Plotly.Event>();
109+
sliderEnd = output<Plotly.Event>();
110+
sliderStart = output<Plotly.Event>();
111+
sunburstclick = output<Plotly.PlotMouseEvent>();
112+
transitioning = output<Plotly.Event>();
113+
transitionInterrupted = output<Plotly.Event>();
114+
unhover = output<Plotly.PlotMouseEvent>();
113115
/**
114116
* @deprecated DEPRECATED: Event treemapclick is not list as an plotly.js event
115117
*/
116-
treemapclick = output();
117-
webglcontextlost = output();
118+
treemapclick = output<Plotly.PlotMouseEvent>();
119+
webglcontextlost = output<Plotly.Event>();
118120

119121

120122
public eventNames = ['afterExport', 'afterPlot', 'animated', 'animatingFrame', 'animationInterrupted', 'autoSize',
@@ -149,6 +151,12 @@ export class PlotlyComponent implements OnInit, OnChanges, OnDestroy, DoCheck {
149151
this.getWindow().removeEventListener('resize', this.resizeHandler as any);
150152
this.resizeHandler = undefined;
151153
}
154+
this.resizeObserver?.disconnect();
155+
this.resizeObserver = undefined;
156+
if (this.resizeAnimationFrame !== undefined) {
157+
this.getWindow().cancelAnimationFrame?.(this.resizeAnimationFrame);
158+
this.resizeAnimationFrame = undefined;
159+
}
152160

153161
if (this.plotlyInstance) {
154162
const figure = this.createFigure();
@@ -254,14 +262,14 @@ export class PlotlyComponent implements OnInit, OnChanges, OnDestroy, DoCheck {
254262

255263
this.eventNames.forEach(name => {
256264
const eventName = `plotly_${name.toLowerCase()}`;
257-
const event = (this as any)[name] as OutputEmitterRef<any>;
265+
const event = (this as any)[name] as OutputEmitterRef<Plotly.Event>;
258266

259-
plotlyInstance.on(eventName, (data: any) => event.emit(data));
267+
plotlyInstance.on(eventName, (data: unknown) => event.emit(data as Plotly.Event));
260268
});
261269

262-
plotlyInstance.on('plotly_click', (data: any) => {
263-
this.plotlyClick.emit(data);
264-
this.click.emit(data);
270+
plotlyInstance.on('plotly_click', (data: unknown) => {
271+
this.plotlyClick.emit(data as Plotly.PlotMouseEvent);
272+
this.click.emit(data as Plotly.PlotMouseEvent);
265273
});
266274

267275
this.updateWindowResizeHandler();
@@ -299,6 +307,7 @@ export class PlotlyComponent implements OnInit, OnChanges, OnDestroy, DoCheck {
299307
this.config(),
300308
this.frames()
301309
).then(() => {
310+
this.scheduleResizeAfterLayoutUpdate();
302311
const figure = this.createFigure();
303312
this.update.emit(figure);
304313
}, err => {
@@ -319,6 +328,51 @@ export class PlotlyComponent implements OnInit, OnChanges, OnDestroy, DoCheck {
319328
this.resizeHandler = undefined;
320329
}
321330
}
331+
332+
this.updateResizeObserver();
333+
}
334+
335+
private updateResizeObserver(): void {
336+
const shouldObserve = this.useResizeHandler()
337+
&& this.plotlyInstance
338+
&& isPlatformBrowser(this.platformId)
339+
&& typeof ResizeObserver !== 'undefined';
340+
341+
if (shouldObserve) {
342+
if (this.resizeObserver === undefined) {
343+
this.resizeObserver = new ResizeObserver(() => this.scheduleResize());
344+
this.resizeObserver.observe(this.plotEl.nativeElement);
345+
}
346+
} else {
347+
this.resizeObserver?.disconnect();
348+
this.resizeObserver = undefined;
349+
}
350+
}
351+
352+
private scheduleResizeAfterLayoutUpdate(): void {
353+
const layout = this.layout();
354+
if (layout?.['autosize'] === true && layout['height'] === undefined && layout['width'] === undefined) {
355+
this.scheduleResize();
356+
}
357+
}
358+
359+
private scheduleResize(): void {
360+
if (this.destroyed || !this.plotlyInstance || !isPlatformBrowser(this.platformId) || this.resizeAnimationFrame !== undefined) {
361+
return;
362+
}
363+
364+
const requestAnimationFrame = this.getWindow().requestAnimationFrame;
365+
if (typeof requestAnimationFrame !== 'function') {
366+
this.plotly.resize(this.plotlyInstance);
367+
return;
368+
}
369+
370+
this.resizeAnimationFrame = requestAnimationFrame.call(this.getWindow(), () => {
371+
this.resizeAnimationFrame = undefined;
372+
if (!this.destroyed && this.plotlyInstance) {
373+
this.plotly.resize(this.plotlyInstance);
374+
}
375+
});
322376
}
323377

324378
dataDifferTrackBy(_: number, item: any): unknown {

0 commit comments

Comments
 (0)