Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions projects/observability/src/public-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,8 @@ export * from './shared/graphql/model/schema/observability-traces';

export * from './shared/components/utils/d3/d3-visualization-builder.service';
export * from './shared/components/utils/d3/d3-util.service';
export * from './shared/components/utils/d3/zoom/d3-zoom';

export * from './shared/components/utils/chart-tooltip/chart-tooltip-builder.service';
export * from './shared/components/utils/chart-tooltip/chart-tooltip.module';
export * from './shared/components/utils/svg/svg-util.service';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,8 @@ export class D3Topology implements Topology {
container: svg,
target: data,
scroll: this.config.zoomable ? zoomScrollConfig : undefined,
pan: this.config.zoomable ? zoomPanConfig : undefined
pan: this.config.zoomable ? zoomPanConfig : undefined,
showBrush: true
});

this.onDestroy(() => {
Expand Down Expand Up @@ -281,7 +282,7 @@ export class D3Topology implements Topology {
topologyData.nodes.forEach(node => nodeRenderer.drawNode(groupElement, node));
topologyData.edges.forEach(edge => edgeRenderer.drawEdge(groupElement, edge));
topologyData.nodes.forEach(node => this.select(nodeRenderer.getElementForNode(node)!).raise());
this.zoom.updateBrushOverlay(topologyData.nodes);
this.zoom.updateBrushOverlayWithData(topologyData.nodes);
}

private updateMeasuredDimensions(): void {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,97 +1,21 @@
import { Key, MouseButton, throwIfNil, unionOfClientRects } from '@hypertrace/common';
import { brush, BrushBehavior, D3BrushEvent } from 'd3-brush';
// tslint:disable-next-line: no-restricted-globals weird tslint error. Rename event so we can type it and not mistake it for other events
import { event as _d3CurrentEvent, Selection } from 'd3-selection';
import { D3ZoomEvent, zoom, ZoomBehavior, zoomIdentity, ZoomTransform } from 'd3-zoom';
import { isEqual } from 'lodash-es';
import { BehaviorSubject, Observable } from 'rxjs';
import { throwIfNil, unionOfClientRects } from '@hypertrace/common';
import { D3Zoom } from '../../../../utils/d3/zoom/d3-zoom';
import { RenderableTopologyNode, RenderableTopologyNodeRenderedData } from '../../../topology';
import { D3ZoomConfiguration } from './../../../../utils/d3/zoom/d3-zoom';

export class TopologyZoom<TContainer extends Element = Element, TTarget extends Element = Element> {
private static readonly DEFAULT_MIN_ZOOM: number = 0.2;
private static readonly DEFAULT_MAX_ZOOM: number = 5.0;
private static readonly DATA_BRUSH_CONTEXT_CLASS: string = 'brush-context';
private static readonly DATA_BRUSH_OVERLAY_CLASS: string = 'overlay';
private static readonly DATA_BRUSH_SELECTION_CLASS: string = 'selection';

private static readonly DATA_BRUSH_OVERLAY_WIDTH: number = 200;
private static readonly DATA_BRUSH_OVERLAY_HEIGHT: number = 200;
private config?: TopologyZoomConfiguration<TContainer, TTarget>;
private readonly zoomBehavior: ZoomBehavior<TContainer, unknown>;
private readonly zoomChangeSubject: BehaviorSubject<number> = new BehaviorSubject(1);
private readonly brushBehaviour: BrushBehavior<unknown>;
public readonly zoomChange$: Observable<number> = this.zoomChangeSubject.asObservable();

private get minScale(): number {
return this.config && this.config.minScale !== undefined ? this.config.minScale : TopologyZoom.DEFAULT_MIN_ZOOM;
}

private get maxScale(): number {
return this.config && this.config.maxScale !== undefined ? this.config.maxScale : TopologyZoom.DEFAULT_MAX_ZOOM;
}

private getCurrentD3Event<T extends ZoomHandlerEvent | ZoomSourceEvent>(): T {
// Returned event type depends on where this is invoked. Filters get a source event.
return _d3CurrentEvent;
}

public constructor() {
this.zoomBehavior = zoom<TContainer, unknown>()
.filter(() => this.checkValidZoomEvent(this.getCurrentD3Event()))
.on('zoom', () => this.updateZoom(this.getCurrentD3Event<ZoomHandlerEvent>().transform))
.on('start.drag', () => this.updateDraggingClassIfNeeded(this.getCurrentD3Event()))
.on('end.drag', () => this.updateDraggingClassIfNeeded(this.getCurrentD3Event()));

this.brushBehaviour = brush<unknown>().on('start end', () => this.onBrushSelection(_d3CurrentEvent));
}

public attachZoom(configuration: TopologyZoomConfiguration<TContainer, TTarget>): this {
this.config = configuration;
this.zoomBehavior.scaleExtent([this.minScale, this.maxScale]);
this.config.container
.call(this.zoomBehavior)
// tslint:disable-next-line: no-null-keyword
.on('dblclick.zoom', null); // Remove default double click handler

return this;
}

public getZoomScale(): number {
return this.zoomChangeSubject.getValue();
}

public setZoomScale(factor: number): void {
this.zoomBehavior.scaleTo(this.getContainerSelectionOrThrow(), factor);
}

public resetZoom(): void {
this.zoomBehavior.transform(this.getContainerSelectionOrThrow(), zoomIdentity);
}

public canIncreaseScale(): boolean {
return this.maxScale > this.getZoomScale();
}

public canDecreaseScale(): boolean {
return this.minScale < this.getZoomScale();
}

export class TopologyZoom<TContainer extends Element = Element, TTarget extends Element = Element> extends D3Zoom<
TContainer,
TTarget
> {
public zoomToFit(nodes: RenderableTopologyNode[]): void {
const nodeClientRects = nodes
.map(node => node.renderedData())
.filter((renderedData): renderedData is RenderableTopologyNodeRenderedData => !!renderedData)
.map(renderedData => renderedData.getBoudingBox());

const requestedRect = unionOfClientRects(...nodeClientRects);
const availableRect = throwIfNil(this.config && this.config.container.node()).getBoundingClientRect();
// Add a bit of padding to requested width/height for padding
const requestedWidthScale = availableRect.width / (requestedRect.width + 24);
const requestedHeightScale = availableRect.height / (requestedRect.height + 24);
// Zoomed in more than this is fine, but this is min to fit everything
const minOverallScale = Math.min(requestedWidthScale, requestedHeightScale);
// Never zoom beyond 100% with zoom to fit
this.setZoomScale(Math.min(1, Math.max(this.minScale, minOverallScale)));
this.translateToRect(requestedRect);

this.zoomToRect(requestedRect);
}

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

public panToRect(viewRect: ClientRect): void {
const availableRect = throwIfNil(this.config && this.config.container.node()).getBoundingClientRect();
// AvailableRect is used for width since we are always keeping scale as 1
this.zoomBehavior.translateTo(
this.getContainerSelectionOrThrow(),
viewRect.left + availableRect.width / 2,
viewRect.top + availableRect.height / 2
);
}

public determineZoomScale(nodes: RenderableTopologyNode[], availableRect: ClientRect): number {
private determineZoomScale(nodes: RenderableTopologyNode[], availableRect: ClientRect): number {
const nodeClientRects = nodes
.map(node => node.renderedData())
.filter((renderedData): renderedData is RenderableTopologyNodeRenderedData => !!renderedData)
Expand All @@ -130,7 +44,7 @@ export class TopologyZoom<TContainer extends Element = Element, TTarget extends
return minOverallScale;
}

public updateBrushOverlay(nodes: RenderableTopologyNode[]): void {
public updateBrushOverlayWithData(nodes: RenderableTopologyNode[]): void {
const containerSelection = this.getContainerSelectionOrThrow();
containerSelection.select(`.${TopologyZoom.DATA_BRUSH_CONTEXT_CLASS}`).remove();
const containerdBox = throwIfNil(containerSelection.node()).getBoundingClientRect();
Expand All @@ -143,167 +57,12 @@ export class TopologyZoom<TContainer extends Element = Element, TTarget extends
width: TopologyZoom.DATA_BRUSH_OVERLAY_WIDTH,
height: TopologyZoom.DATA_BRUSH_OVERLAY_HEIGHT
};
const overlayZoomScale = this.determineZoomScale(nodes, boundingBox);
this.brushBehaviour.extent([
[0, 0],
[
TopologyZoom.DATA_BRUSH_OVERLAY_WIDTH / overlayZoomScale,
TopologyZoom.DATA_BRUSH_OVERLAY_HEIGHT / overlayZoomScale
]
]);

this.config!.brushOverlay = throwIfNil(this.config)
.target.clone(true)
.classed(TopologyZoom.DATA_BRUSH_CONTEXT_CLASS, true)
.attr('width', boundingBox.width)
.attr('height', boundingBox.height)
.attr('transform', `translate(${boundingBox.left - 20}, ${boundingBox.top - 40}) scale(${overlayZoomScale})`)
.insert('g', '.entity-edge')
// tslint:disable-next-line: no-any
.call(this.brushBehaviour as any);

this.styleBrushSelection(this.config!.brushOverlay, overlayZoomScale);
}

private styleBrushSelection(
brushSelection: Selection<SVGGElement, unknown, null, undefined>,
overlayZoomScale: number
): void {
// Map values
const overlayBorderRadius = 4 / overlayZoomScale;
const selectionBorderRadius = 4 / overlayZoomScale;
const strokeWidth = 1 / overlayZoomScale;

brushSelection
.select(`.${TopologyZoom.DATA_BRUSH_OVERLAY_CLASS}`)
.attr('rx', overlayBorderRadius)
.attr('ry', overlayBorderRadius);

brushSelection
.select(`.${TopologyZoom.DATA_BRUSH_SELECTION_CLASS}`)
.attr('rx', selectionBorderRadius)
.attr('ry', selectionBorderRadius)
.style('stroke-width', strokeWidth)
.style('stroke-dasharray', `${strokeWidth}, ${strokeWidth}`);
}

private onBrushSelection(event: D3BrushEvent<RenderableTopologyNode>): void {
if (!event.selection) {
return;
}

const [start, end] = event.selection as [[number, number], [number, number]];
if (isEqual(start, end)) {
return;
}
const chartZoomScale = this.getZoomScale();
const viewRect = {
top: start[1] * chartZoomScale,
left: start[0] * chartZoomScale,
bottom: end[1] * chartZoomScale,
right: end[0] * chartZoomScale,
width: end[0] * chartZoomScale - start[0] * chartZoomScale,
height: end[1] * chartZoomScale - start[1] * chartZoomScale
};

this.panToRect(viewRect);
}

private translateToRect(rect: ClientRect): void {
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
this.zoomBehavior.translateTo(this.getContainerSelectionOrThrow(), centerX, centerY);
}

private updateZoom(transform: ZoomTransform): void {
this.getTargetSelectionOrThrow().attr('transform', transform.toString());
this.zoomChangeSubject.next(transform.k);
}

private checkValidZoomEvent(receivedEvent: ZoomSourceEvent): boolean {
if (this.isScrollEvent(receivedEvent)) {
return this.isValidTriggerEvent(receivedEvent, this.config && this.config.scroll);
}
if (this.isPrimaryMouseOrTouchEvent(receivedEvent)) {
return this.isValidTriggerEvent(receivedEvent, this.config && this.config.pan);
}

return false;
}

private isValidTriggerEvent(
receivedEvent: TouchEvent | MouseEvent,
triggerConfig?: TopologyEventTriggerConfig
): boolean {
if (!triggerConfig) {
return false;
}
if (!triggerConfig.requireModifiers) {
return true;
}

return triggerConfig.requireModifiers.some(key => this.eventHasModifier(receivedEvent, key));
}

private eventHasModifier(receivedEvent: TouchEvent | MouseEvent, modifier: ZoomEventKeyModifier): boolean {
switch (modifier) {
case Key.Control:
return receivedEvent.ctrlKey;
case Key.Meta:
return receivedEvent.metaKey;
default:
return false;
}
}

private isPrimaryMouseOrTouchEvent(receivedEvent: ZoomSourceEvent): receivedEvent is TouchEvent | MouseEvent {
return (
('TouchEvent' in window && receivedEvent instanceof TouchEvent) ||
(receivedEvent instanceof MouseEvent &&
!this.isScrollEvent(receivedEvent) &&
receivedEvent.button === MouseButton.Primary)
);
}

private isScrollEvent(receivedEvent: ZoomSourceEvent): receivedEvent is WheelEvent {
return receivedEvent instanceof WheelEvent;
}

private updateDraggingClassIfNeeded(zoomEvent: ZoomHandlerEvent): void {
this.getContainerSelectionOrThrow().classed('dragging', this.isPanStartEvent(zoomEvent));
}

private isPanStartEvent(zoomEvent: ZoomHandlerEvent): boolean {
return zoomEvent.type === 'start' && this.isPrimaryMouseOrTouchEvent(zoomEvent.sourceEvent);
}

private getTargetSelectionOrThrow(): Selection<TTarget, unknown, null, unknown> {
return throwIfNil(this.config).target;
}
const overlayZoomScale = this.determineZoomScale(nodes, boundingBox);

private getContainerSelectionOrThrow(): Selection<TContainer, unknown, null, unknown> {
return throwIfNil(this.config).container;
this.showBrushOverlay(overlayZoomScale);
}
}

type ZoomEventKeyModifier = Key.Control | Key.Meta;
// Type ZoomSourceEventType = 'wheel' | 'mousedown' | 'mouseup' | 'mousemove';
type ZoomSourceEvent = MouseEvent | TouchEvent | null;
interface ZoomHandlerEvent extends D3ZoomEvent<Element, unknown> {
sourceEvent: ZoomSourceEvent;
type: 'start' | 'zoom' | 'end';
}

export interface TopologyZoomConfiguration<TContainer extends Element, TTarget extends Element> {
container: Selection<TContainer, unknown, null, undefined>;
target: Selection<TTarget, unknown, null, undefined>;
brushOverlay?: Selection<SVGGElement, unknown, null, undefined>;
scroll?: TopologyEventTriggerConfig;
pan?: TopologyEventTriggerConfig;
minScale?: number;
maxScale?: number;
}

interface TopologyEventTriggerConfig {
requireModifiers?: ZoomEventKeyModifier[];
}
export interface TopologyZoomConfiguration<TContainer extends Element, TTarget extends Element>
extends D3ZoomConfiguration<TContainer, TTarget> {}
Loading