Skip to content

Commit efbf8da

Browse files
authored
feat: separate zoom into a shared util file (#606)
* feat: separate zoom into a shared util file * refactor: more changes * refactor: updating config
1 parent 23cf371 commit efbf8da

File tree

4 files changed

+302
-258
lines changed

4 files changed

+302
-258
lines changed

projects/observability/src/public-api.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,8 @@ export * from './shared/graphql/model/schema/observability-traces';
204204

205205
export * from './shared/components/utils/d3/d3-visualization-builder.service';
206206
export * from './shared/components/utils/d3/d3-util.service';
207+
export * from './shared/components/utils/d3/zoom/d3-zoom';
208+
207209
export * from './shared/components/utils/chart-tooltip/chart-tooltip-builder.service';
208210
export * from './shared/components/utils/chart-tooltip/chart-tooltip.module';
209211
export * from './shared/components/utils/svg/svg-util.service';

projects/observability/src/shared/components/topology/d3/d3-topology.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,8 @@ export class D3Topology implements Topology {
181181
container: svg,
182182
target: data,
183183
scroll: this.config.zoomable ? zoomScrollConfig : undefined,
184-
pan: this.config.zoomable ? zoomPanConfig : undefined
184+
pan: this.config.zoomable ? zoomPanConfig : undefined,
185+
showBrush: true
185186
});
186187

187188
this.onDestroy(() => {
@@ -281,7 +282,7 @@ export class D3Topology implements Topology {
281282
topologyData.nodes.forEach(node => nodeRenderer.drawNode(groupElement, node));
282283
topologyData.edges.forEach(edge => edgeRenderer.drawEdge(groupElement, edge));
283284
topologyData.nodes.forEach(node => this.select(nodeRenderer.getElementForNode(node)!).raise());
284-
this.zoom.updateBrushOverlay(topologyData.nodes);
285+
this.zoom.updateBrushOverlayWithData(topologyData.nodes);
285286
}
286287

287288
private updateMeasuredDimensions(): void {
Lines changed: 15 additions & 256 deletions
Original file line numberDiff line numberDiff line change
@@ -1,97 +1,21 @@
1-
import { Key, MouseButton, throwIfNil, unionOfClientRects } from '@hypertrace/common';
2-
import { brush, BrushBehavior, D3BrushEvent } from 'd3-brush';
3-
// tslint:disable-next-line: no-restricted-globals weird tslint error. Rename event so we can type it and not mistake it for other events
4-
import { event as _d3CurrentEvent, Selection } from 'd3-selection';
5-
import { D3ZoomEvent, zoom, ZoomBehavior, zoomIdentity, ZoomTransform } from 'd3-zoom';
6-
import { isEqual } from 'lodash-es';
7-
import { BehaviorSubject, Observable } from 'rxjs';
1+
import { throwIfNil, unionOfClientRects } from '@hypertrace/common';
2+
import { D3Zoom } from '../../../../utils/d3/zoom/d3-zoom';
83
import { RenderableTopologyNode, RenderableTopologyNodeRenderedData } from '../../../topology';
4+
import { D3ZoomConfiguration } from './../../../../utils/d3/zoom/d3-zoom';
95

10-
export class TopologyZoom<TContainer extends Element = Element, TTarget extends Element = Element> {
11-
private static readonly DEFAULT_MIN_ZOOM: number = 0.2;
12-
private static readonly DEFAULT_MAX_ZOOM: number = 5.0;
13-
private static readonly DATA_BRUSH_CONTEXT_CLASS: string = 'brush-context';
14-
private static readonly DATA_BRUSH_OVERLAY_CLASS: string = 'overlay';
15-
private static readonly DATA_BRUSH_SELECTION_CLASS: string = 'selection';
16-
17-
private static readonly DATA_BRUSH_OVERLAY_WIDTH: number = 200;
18-
private static readonly DATA_BRUSH_OVERLAY_HEIGHT: number = 200;
19-
private config?: TopologyZoomConfiguration<TContainer, TTarget>;
20-
private readonly zoomBehavior: ZoomBehavior<TContainer, unknown>;
21-
private readonly zoomChangeSubject: BehaviorSubject<number> = new BehaviorSubject(1);
22-
private readonly brushBehaviour: BrushBehavior<unknown>;
23-
public readonly zoomChange$: Observable<number> = this.zoomChangeSubject.asObservable();
24-
25-
private get minScale(): number {
26-
return this.config && this.config.minScale !== undefined ? this.config.minScale : TopologyZoom.DEFAULT_MIN_ZOOM;
27-
}
28-
29-
private get maxScale(): number {
30-
return this.config && this.config.maxScale !== undefined ? this.config.maxScale : TopologyZoom.DEFAULT_MAX_ZOOM;
31-
}
32-
33-
private getCurrentD3Event<T extends ZoomHandlerEvent | ZoomSourceEvent>(): T {
34-
// Returned event type depends on where this is invoked. Filters get a source event.
35-
return _d3CurrentEvent;
36-
}
37-
38-
public constructor() {
39-
this.zoomBehavior = zoom<TContainer, unknown>()
40-
.filter(() => this.checkValidZoomEvent(this.getCurrentD3Event()))
41-
.on('zoom', () => this.updateZoom(this.getCurrentD3Event<ZoomHandlerEvent>().transform))
42-
.on('start.drag', () => this.updateDraggingClassIfNeeded(this.getCurrentD3Event()))
43-
.on('end.drag', () => this.updateDraggingClassIfNeeded(this.getCurrentD3Event()));
44-
45-
this.brushBehaviour = brush<unknown>().on('start end', () => this.onBrushSelection(_d3CurrentEvent));
46-
}
47-
48-
public attachZoom(configuration: TopologyZoomConfiguration<TContainer, TTarget>): this {
49-
this.config = configuration;
50-
this.zoomBehavior.scaleExtent([this.minScale, this.maxScale]);
51-
this.config.container
52-
.call(this.zoomBehavior)
53-
// tslint:disable-next-line: no-null-keyword
54-
.on('dblclick.zoom', null); // Remove default double click handler
55-
56-
return this;
57-
}
58-
59-
public getZoomScale(): number {
60-
return this.zoomChangeSubject.getValue();
61-
}
62-
63-
public setZoomScale(factor: number): void {
64-
this.zoomBehavior.scaleTo(this.getContainerSelectionOrThrow(), factor);
65-
}
66-
67-
public resetZoom(): void {
68-
this.zoomBehavior.transform(this.getContainerSelectionOrThrow(), zoomIdentity);
69-
}
70-
71-
public canIncreaseScale(): boolean {
72-
return this.maxScale > this.getZoomScale();
73-
}
74-
75-
public canDecreaseScale(): boolean {
76-
return this.minScale < this.getZoomScale();
77-
}
78-
6+
export class TopologyZoom<TContainer extends Element = Element, TTarget extends Element = Element> extends D3Zoom<
7+
TContainer,
8+
TTarget
9+
> {
7910
public zoomToFit(nodes: RenderableTopologyNode[]): void {
8011
const nodeClientRects = nodes
8112
.map(node => node.renderedData())
8213
.filter((renderedData): renderedData is RenderableTopologyNodeRenderedData => !!renderedData)
8314
.map(renderedData => renderedData.getBoudingBox());
8415

8516
const requestedRect = unionOfClientRects(...nodeClientRects);
86-
const availableRect = throwIfNil(this.config && this.config.container.node()).getBoundingClientRect();
87-
// Add a bit of padding to requested width/height for padding
88-
const requestedWidthScale = availableRect.width / (requestedRect.width + 24);
89-
const requestedHeightScale = availableRect.height / (requestedRect.height + 24);
90-
// Zoomed in more than this is fine, but this is min to fit everything
91-
const minOverallScale = Math.min(requestedWidthScale, requestedHeightScale);
92-
// Never zoom beyond 100% with zoom to fit
93-
this.setZoomScale(Math.min(1, Math.max(this.minScale, minOverallScale)));
94-
this.translateToRect(requestedRect);
17+
18+
this.zoomToRect(requestedRect);
9519
}
9620

9721
public panToTopLeft(nodes: RenderableTopologyNode[]): void {
@@ -104,17 +28,7 @@ export class TopologyZoom<TContainer extends Element = Element, TTarget extends
10428
this.panToRect(unionOfClientRects(...nodeClientRects));
10529
}
10630

107-
public panToRect(viewRect: ClientRect): void {
108-
const availableRect = throwIfNil(this.config && this.config.container.node()).getBoundingClientRect();
109-
// AvailableRect is used for width since we are always keeping scale as 1
110-
this.zoomBehavior.translateTo(
111-
this.getContainerSelectionOrThrow(),
112-
viewRect.left + availableRect.width / 2,
113-
viewRect.top + availableRect.height / 2
114-
);
115-
}
116-
117-
public determineZoomScale(nodes: RenderableTopologyNode[], availableRect: ClientRect): number {
31+
private determineZoomScale(nodes: RenderableTopologyNode[], availableRect: ClientRect): number {
11832
const nodeClientRects = nodes
11933
.map(node => node.renderedData())
12034
.filter((renderedData): renderedData is RenderableTopologyNodeRenderedData => !!renderedData)
@@ -130,7 +44,7 @@ export class TopologyZoom<TContainer extends Element = Element, TTarget extends
13044
return minOverallScale;
13145
}
13246

133-
public updateBrushOverlay(nodes: RenderableTopologyNode[]): void {
47+
public updateBrushOverlayWithData(nodes: RenderableTopologyNode[]): void {
13448
const containerSelection = this.getContainerSelectionOrThrow();
13549
containerSelection.select(`.${TopologyZoom.DATA_BRUSH_CONTEXT_CLASS}`).remove();
13650
const containerdBox = throwIfNil(containerSelection.node()).getBoundingClientRect();
@@ -143,167 +57,12 @@ export class TopologyZoom<TContainer extends Element = Element, TTarget extends
14357
width: TopologyZoom.DATA_BRUSH_OVERLAY_WIDTH,
14458
height: TopologyZoom.DATA_BRUSH_OVERLAY_HEIGHT
14559
};
146-
const overlayZoomScale = this.determineZoomScale(nodes, boundingBox);
147-
this.brushBehaviour.extent([
148-
[0, 0],
149-
[
150-
TopologyZoom.DATA_BRUSH_OVERLAY_WIDTH / overlayZoomScale,
151-
TopologyZoom.DATA_BRUSH_OVERLAY_HEIGHT / overlayZoomScale
152-
]
153-
]);
154-
155-
this.config!.brushOverlay = throwIfNil(this.config)
156-
.target.clone(true)
157-
.classed(TopologyZoom.DATA_BRUSH_CONTEXT_CLASS, true)
158-
.attr('width', boundingBox.width)
159-
.attr('height', boundingBox.height)
160-
.attr('transform', `translate(${boundingBox.left - 20}, ${boundingBox.top - 40}) scale(${overlayZoomScale})`)
161-
.insert('g', '.entity-edge')
162-
// tslint:disable-next-line: no-any
163-
.call(this.brushBehaviour as any);
164-
165-
this.styleBrushSelection(this.config!.brushOverlay, overlayZoomScale);
166-
}
167-
168-
private styleBrushSelection(
169-
brushSelection: Selection<SVGGElement, unknown, null, undefined>,
170-
overlayZoomScale: number
171-
): void {
172-
// Map values
173-
const overlayBorderRadius = 4 / overlayZoomScale;
174-
const selectionBorderRadius = 4 / overlayZoomScale;
175-
const strokeWidth = 1 / overlayZoomScale;
176-
177-
brushSelection
178-
.select(`.${TopologyZoom.DATA_BRUSH_OVERLAY_CLASS}`)
179-
.attr('rx', overlayBorderRadius)
180-
.attr('ry', overlayBorderRadius);
181-
182-
brushSelection
183-
.select(`.${TopologyZoom.DATA_BRUSH_SELECTION_CLASS}`)
184-
.attr('rx', selectionBorderRadius)
185-
.attr('ry', selectionBorderRadius)
186-
.style('stroke-width', strokeWidth)
187-
.style('stroke-dasharray', `${strokeWidth}, ${strokeWidth}`);
188-
}
189-
190-
private onBrushSelection(event: D3BrushEvent<RenderableTopologyNode>): void {
191-
if (!event.selection) {
192-
return;
193-
}
194-
195-
const [start, end] = event.selection as [[number, number], [number, number]];
196-
if (isEqual(start, end)) {
197-
return;
198-
}
199-
const chartZoomScale = this.getZoomScale();
200-
const viewRect = {
201-
top: start[1] * chartZoomScale,
202-
left: start[0] * chartZoomScale,
203-
bottom: end[1] * chartZoomScale,
204-
right: end[0] * chartZoomScale,
205-
width: end[0] * chartZoomScale - start[0] * chartZoomScale,
206-
height: end[1] * chartZoomScale - start[1] * chartZoomScale
207-
};
208-
209-
this.panToRect(viewRect);
210-
}
211-
212-
private translateToRect(rect: ClientRect): void {
213-
const centerX = rect.left + rect.width / 2;
214-
const centerY = rect.top + rect.height / 2;
215-
this.zoomBehavior.translateTo(this.getContainerSelectionOrThrow(), centerX, centerY);
216-
}
217-
218-
private updateZoom(transform: ZoomTransform): void {
219-
this.getTargetSelectionOrThrow().attr('transform', transform.toString());
220-
this.zoomChangeSubject.next(transform.k);
221-
}
222-
223-
private checkValidZoomEvent(receivedEvent: ZoomSourceEvent): boolean {
224-
if (this.isScrollEvent(receivedEvent)) {
225-
return this.isValidTriggerEvent(receivedEvent, this.config && this.config.scroll);
226-
}
227-
if (this.isPrimaryMouseOrTouchEvent(receivedEvent)) {
228-
return this.isValidTriggerEvent(receivedEvent, this.config && this.config.pan);
229-
}
230-
231-
return false;
232-
}
233-
234-
private isValidTriggerEvent(
235-
receivedEvent: TouchEvent | MouseEvent,
236-
triggerConfig?: TopologyEventTriggerConfig
237-
): boolean {
238-
if (!triggerConfig) {
239-
return false;
240-
}
241-
if (!triggerConfig.requireModifiers) {
242-
return true;
243-
}
244-
245-
return triggerConfig.requireModifiers.some(key => this.eventHasModifier(receivedEvent, key));
246-
}
24760

248-
private eventHasModifier(receivedEvent: TouchEvent | MouseEvent, modifier: ZoomEventKeyModifier): boolean {
249-
switch (modifier) {
250-
case Key.Control:
251-
return receivedEvent.ctrlKey;
252-
case Key.Meta:
253-
return receivedEvent.metaKey;
254-
default:
255-
return false;
256-
}
257-
}
258-
259-
private isPrimaryMouseOrTouchEvent(receivedEvent: ZoomSourceEvent): receivedEvent is TouchEvent | MouseEvent {
260-
return (
261-
('TouchEvent' in window && receivedEvent instanceof TouchEvent) ||
262-
(receivedEvent instanceof MouseEvent &&
263-
!this.isScrollEvent(receivedEvent) &&
264-
receivedEvent.button === MouseButton.Primary)
265-
);
266-
}
267-
268-
private isScrollEvent(receivedEvent: ZoomSourceEvent): receivedEvent is WheelEvent {
269-
return receivedEvent instanceof WheelEvent;
270-
}
271-
272-
private updateDraggingClassIfNeeded(zoomEvent: ZoomHandlerEvent): void {
273-
this.getContainerSelectionOrThrow().classed('dragging', this.isPanStartEvent(zoomEvent));
274-
}
275-
276-
private isPanStartEvent(zoomEvent: ZoomHandlerEvent): boolean {
277-
return zoomEvent.type === 'start' && this.isPrimaryMouseOrTouchEvent(zoomEvent.sourceEvent);
278-
}
279-
280-
private getTargetSelectionOrThrow(): Selection<TTarget, unknown, null, unknown> {
281-
return throwIfNil(this.config).target;
282-
}
61+
const overlayZoomScale = this.determineZoomScale(nodes, boundingBox);
28362

284-
private getContainerSelectionOrThrow(): Selection<TContainer, unknown, null, unknown> {
285-
return throwIfNil(this.config).container;
63+
this.showBrushOverlay(overlayZoomScale);
28664
}
28765
}
28866

289-
type ZoomEventKeyModifier = Key.Control | Key.Meta;
290-
// Type ZoomSourceEventType = 'wheel' | 'mousedown' | 'mouseup' | 'mousemove';
291-
type ZoomSourceEvent = MouseEvent | TouchEvent | null;
292-
interface ZoomHandlerEvent extends D3ZoomEvent<Element, unknown> {
293-
sourceEvent: ZoomSourceEvent;
294-
type: 'start' | 'zoom' | 'end';
295-
}
296-
297-
export interface TopologyZoomConfiguration<TContainer extends Element, TTarget extends Element> {
298-
container: Selection<TContainer, unknown, null, undefined>;
299-
target: Selection<TTarget, unknown, null, undefined>;
300-
brushOverlay?: Selection<SVGGElement, unknown, null, undefined>;
301-
scroll?: TopologyEventTriggerConfig;
302-
pan?: TopologyEventTriggerConfig;
303-
minScale?: number;
304-
maxScale?: number;
305-
}
306-
307-
interface TopologyEventTriggerConfig {
308-
requireModifiers?: ZoomEventKeyModifier[];
309-
}
67+
export interface TopologyZoomConfiguration<TContainer extends Element, TTarget extends Element>
68+
extends D3ZoomConfiguration<TContainer, TTarget> {}

0 commit comments

Comments
 (0)