-
Notifications
You must be signed in to change notification settings - Fork 4k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
aa255fd
commit f09c978
Showing
11 changed files
with
7,234 additions
and
10 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,277 @@ | ||
/** | ||
* Use of this source code is governed by an MIT-style license that can be | ||
* found in the LICENSE file at https://github.com/NG-ZORRO/ng-zorro-antd/blob/master/LICENSE | ||
*/ | ||
|
||
import { ZoomBehavior, ZoomTransform } from 'd3'; | ||
import { select } from 'd3-selection'; | ||
import { NzSafeAny } from 'ng-zorro-antd/core/types'; | ||
|
||
// tslint:disable-next-line:no-duplicate-imports | ||
import * as d3 from 'd3'; | ||
|
||
const FRAC_VIEWPOINT_AREA = 0.8; | ||
|
||
export class Minimap { | ||
private minimap: HTMLElement; | ||
private canvas: HTMLCanvasElement; | ||
private canvasRect: ClientRect; | ||
private canvasBuffer: HTMLCanvasElement; | ||
private download!: HTMLLinkElement; | ||
private downloadCanvas: HTMLCanvasElement; | ||
private minimapSvg: SVGSVGElement; | ||
private viewpoint: SVGRectElement; | ||
private scaleMinimap!: number; | ||
private scaleMain!: number; | ||
private maxWandH: number; | ||
private translate!: [number, number]; | ||
private viewpointCoord: { x: number; y: number }; | ||
private minimapSize!: { width: number; height: number }; | ||
private labelPadding: number; | ||
|
||
private svg: SVGSVGElement; | ||
private zoomG: SVGGElement; | ||
private mainZoom: ZoomBehavior<NzSafeAny, NzSafeAny>; | ||
|
||
constructor( | ||
svg: SVGSVGElement, | ||
zoomG: SVGGElement, | ||
mainZoom: ZoomBehavior<NzSafeAny, NzSafeAny>, | ||
minimap: HTMLElement, | ||
maxWandH: number, | ||
labelPadding: number | ||
) { | ||
this.svg = svg; | ||
this.labelPadding = labelPadding; | ||
this.zoomG = zoomG; | ||
this.mainZoom = mainZoom; | ||
this.maxWandH = maxWandH; | ||
const minimapElement = select(minimap); | ||
const minimapSvgElement = minimapElement.select('svg'); | ||
const viewpointElement = minimapSvgElement.select('rect'); | ||
this.canvas = minimapElement.select('canvas.first').node() as HTMLCanvasElement; | ||
this.canvasRect = this.canvas.getBoundingClientRect(); | ||
|
||
const handleEvent = (event: NzSafeAny): void => { | ||
const minimapOffset = this.minimapOffset(); | ||
const width = Number(viewpointElement.attr('width')); | ||
const height = Number(viewpointElement.attr('height')); | ||
// @ts-ignore | ||
const clickCoords = d3.pointer(event, minimapSvgElement.node() as NzSafeAny); | ||
this.viewpointCoord.x = clickCoords[0] - width / 2 - minimapOffset.x; | ||
this.viewpointCoord.y = clickCoords[1] - height / 2 - minimapOffset.y; | ||
this.updateViewpoint(); | ||
}; | ||
this.viewpointCoord = { x: 0, y: 0 }; | ||
const drag = d3.drag().subject(Object).on('drag', handleEvent); | ||
// @ts-ignore | ||
viewpointElement.datum(this.viewpointCoord as NzSafeAny).call(drag); | ||
|
||
// Make the minimap clickable. | ||
minimapSvgElement.on('click', event => { | ||
if ((event as Event).defaultPrevented) { | ||
// This click was part of a drag event, so suppress it. | ||
return; | ||
} | ||
handleEvent(event); | ||
}); | ||
this.viewpoint = viewpointElement.node() as SVGRectElement; | ||
this.minimapSvg = minimapSvgElement.node() as SVGSVGElement; | ||
this.minimap = minimap; | ||
this.canvasBuffer = minimapElement.select('canvas.second').node() as HTMLCanvasElement; | ||
this.downloadCanvas = minimapElement.select('canvas.download').node() as HTMLCanvasElement; | ||
d3.select(this.downloadCanvas).style('display', 'none'); | ||
this.update(); | ||
} | ||
|
||
private minimapOffset(): { x: number; y: number } { | ||
return { | ||
x: (this.canvasRect.width - this.minimapSize.width) / 2, | ||
y: (this.canvasRect.height - this.minimapSize.height) / 2 | ||
}; | ||
} | ||
|
||
private updateViewpoint(): void { | ||
// Update the coordinates of the viewpoint rectangle. | ||
d3.select(this.viewpoint).attr('x', this.viewpointCoord.x).attr('y', this.viewpointCoord.y); | ||
// Update the translation vector of the main svg to reflect the | ||
// new viewpoint. | ||
const mainX = (-this.viewpointCoord.x * this.scaleMain) / this.scaleMinimap; | ||
const mainY = (-this.viewpointCoord.y * this.scaleMain) / this.scaleMinimap; | ||
d3.select(this.svg).call(this.mainZoom.transform, d3.zoomIdentity.translate(mainX, mainY).scale(this.scaleMain)); | ||
} | ||
|
||
update(): void { | ||
let sceneSize = null; | ||
try { | ||
// Get the size of the entire scene. | ||
sceneSize = this.zoomG.getBBox(); | ||
if (sceneSize.width === 0) { | ||
// There is no scene anymore. We have been detached from the dom. | ||
return; | ||
} | ||
} catch (e) { | ||
// Firefox produced NS_ERROR_FAILURE if we have been | ||
// detached from the dom. | ||
return; | ||
} | ||
const downloadSelection = d3.select('#graphdownload'); | ||
this.download = downloadSelection.node() as HTMLLinkElement; | ||
downloadSelection.on('click', () => { | ||
this.download.href = this.downloadCanvas.toDataURL('image/png'); | ||
}); | ||
|
||
const svgSelection = d3.select(this.svg); | ||
// Read all the style rules in the document and embed them into the svg. | ||
// The svg needs to be self contained, i.e. all the style rules need to be | ||
// embedded so the canvas output matches the origin. | ||
let stylesText = ''; | ||
|
||
for (const k of new Array(document.styleSheets.length).keys()) { | ||
try { | ||
const cssRules = (document.styleSheets[k] as NzSafeAny).cssRules || (document.styleSheets[k] as NzSafeAny).rules; | ||
if (cssRules == null) { | ||
continue; | ||
} | ||
for (const i of new Array(cssRules.length).keys()) { | ||
// Remove tf-* selectors from the styles. | ||
stylesText += cssRules[i].cssText.replace(/ ?tf-[\w-]+ ?/g, '') + '\n'; | ||
} | ||
} catch (e) { | ||
if (e.name !== 'SecurityError') { | ||
throw e; | ||
} | ||
} | ||
} | ||
|
||
// Temporarily add the css rules to the main svg. | ||
const svgStyle = svgSelection.append('style'); | ||
svgStyle.text(stylesText); | ||
|
||
// Temporarily remove the zoom/pan transform from the main svg since we | ||
// want the minimap to show a zoomed-out and centered view. | ||
const zoomGSelection = d3.select(this.zoomG); | ||
const zoomTransform = zoomGSelection.attr('transform'); | ||
zoomGSelection.attr('transform', null); | ||
|
||
// Since we add padding, account for that here. | ||
sceneSize.height += this.labelPadding * 2; | ||
sceneSize.width += this.labelPadding * 2; | ||
|
||
// Temporarily assign an explicit width/height to the main svg, since | ||
// it doesn't have one (uses flex-box), but we need it for the canvas | ||
// to work. | ||
svgSelection.attr('width', sceneSize.width).attr('height', sceneSize.height); | ||
|
||
// Since the content inside the svg changed (e.g. a node was expanded), | ||
// the aspect ratio have also changed. Thus, we need to update the scale | ||
// factor of the minimap. The scale factor is determined such that both | ||
// the width and height of the minimap are <= maximum specified w/h. | ||
this.scaleMinimap = this.maxWandH / Math.max(sceneSize.width, sceneSize.height); | ||
this.minimapSize = { | ||
width: sceneSize.width * this.scaleMinimap, | ||
height: sceneSize.height * this.scaleMinimap | ||
}; | ||
|
||
const minimapOffset = this.minimapOffset(); | ||
|
||
// Update the size of the minimap's svg, the buffer canvas and the | ||
// viewpoint rect. | ||
d3.select(this.minimapSvg).attr(this.minimapSize as NzSafeAny); | ||
d3.select(this.canvasBuffer).attr(this.minimapSize as NzSafeAny); | ||
|
||
// Download canvas width and height are multiples of the style width and | ||
// height in order to increase pixel density of the PNG for clarity. | ||
const downloadCanvasSelection = d3.select(this.downloadCanvas); | ||
downloadCanvasSelection.style('width', sceneSize.width); | ||
downloadCanvasSelection.style('height', sceneSize.height); | ||
downloadCanvasSelection.attr('width', sceneSize.width * 3); | ||
downloadCanvasSelection.attr('height', sceneSize.height * 3); | ||
|
||
if (this.translate != null && this.zoom != null) { | ||
// Update the viewpoint rectangle shape since the aspect ratio of the | ||
// map has changed. | ||
requestAnimationFrame(() => this.zoom()); | ||
} | ||
|
||
// Serialize the main svg to a string which will be used as the rendering | ||
// content for the canvas. | ||
const svgXml = new XMLSerializer().serializeToString(this.svg); | ||
|
||
// Now that the svg is serialized for rendering, remove the temporarily | ||
// assigned styles, explicit width and height and bring back the pan/zoom | ||
// transform. | ||
svgStyle.remove(); | ||
svgSelection.attr('width', '100%').attr('height', '100%'); | ||
|
||
zoomGSelection.attr('transform', zoomTransform); | ||
|
||
const image = new Image(); | ||
image.onload = () => { | ||
// Draw the svg content onto the buffer canvas. | ||
const context = this.canvasBuffer.getContext('2d'); | ||
context!.clearRect(0, 0, this.canvasBuffer.width, this.canvasBuffer.height); | ||
|
||
context!.drawImage(image, minimapOffset.x, minimapOffset.y, this.minimapSize.width, this.minimapSize.height); | ||
requestAnimationFrame(() => { | ||
// Hide the old canvas and show the new buffer canvas. | ||
d3.select(this.canvasBuffer).style('display', null); | ||
d3.select(this.canvas).style('display', 'none'); | ||
// Swap the two canvases. | ||
[this.canvas, this.canvasBuffer] = [this.canvasBuffer, this.canvas]; | ||
}); | ||
const downloadContext = this.downloadCanvas.getContext('2d'); | ||
downloadContext!.clearRect(0, 0, this.downloadCanvas.width, this.downloadCanvas.height); | ||
downloadContext!.drawImage(image, 0, 0, this.downloadCanvas.width, this.downloadCanvas.height); | ||
}; | ||
image.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svgXml); | ||
} | ||
|
||
/** | ||
* Handles changes in zooming/panning. Should be called from the main svg | ||
* to notify that a zoom/pan was performed and this minimap will update it's | ||
* viewpoint rectangle. | ||
* | ||
* @param translate The translate vector, or none to use the last used one. | ||
* @param scale The scaling factor, or none to use the last used one. | ||
*/ | ||
zoom(transform?: ZoomTransform): void { | ||
if (this.scaleMinimap == null) { | ||
// Scene is not ready yet. | ||
return; | ||
} | ||
// Update the new translate and scale params, only if specified. | ||
if (transform) { | ||
this.translate = [transform.x, transform.y]; | ||
this.scaleMain = transform.k; | ||
} | ||
|
||
// Update the location of the viewpoint rectangle. | ||
const svgRect = this.svg.getBoundingClientRect(); | ||
const minimapOffset = this.minimapOffset(); | ||
const viewpointSelection = d3.select(this.viewpoint); | ||
this.viewpointCoord.x = (-this.translate[0] * this.scaleMinimap) / this.scaleMain; | ||
this.viewpointCoord.y = (-this.translate[1] * this.scaleMinimap) / this.scaleMain; | ||
const viewpointWidth = (svgRect.width * this.scaleMinimap) / this.scaleMain; | ||
const viewpointHeight = (svgRect.height * this.scaleMinimap) / this.scaleMain; | ||
viewpointSelection | ||
.attr('x', this.viewpointCoord.x + minimapOffset.x) | ||
.attr('y', this.viewpointCoord.y + minimapOffset.y) | ||
.attr('width', viewpointWidth) | ||
.attr('height', viewpointHeight); | ||
// Show/hide the minimap depending on the viewpoint area as fraction of the | ||
// whole minimap. | ||
const mapWidth = this.minimapSize.width; | ||
const mapHeight = this.minimapSize.height; | ||
const x = this.viewpointCoord.x; | ||
const y = this.viewpointCoord.y; | ||
const w = Math.min(Math.max(0, x + viewpointWidth), mapWidth) - Math.min(Math.max(0, x), mapWidth); | ||
const h = Math.min(Math.max(0, y + viewpointHeight), mapHeight) - Math.min(Math.max(0, y), mapHeight); | ||
const fracIntersect = (w * h) / (mapWidth * mapHeight); | ||
if (fracIntersect < FRAC_VIEWPOINT_AREA) { | ||
this.minimap.classList.remove('hidden'); | ||
} else { | ||
this.minimap.classList.add('hidden'); | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,74 @@ | ||
/** | ||
* Use of this source code is governed by an MIT-style license that can be | ||
* found in the LICENSE file at https://github.com/NG-ZORRO/ng-zorro-antd/blob/master/LICENSE | ||
*/ | ||
|
||
import { Component, ElementRef, OnDestroy, OnInit } from '@angular/core'; | ||
import { ZoomBehavior, ZoomTransform } from 'd3'; | ||
import { NzSafeAny } from 'ng-zorro-antd/core/types'; | ||
import { Subscription } from 'rxjs'; | ||
import { Minimap } from './core/minimap'; | ||
import { NZ_LAYOUT_SETTING } from './interface'; | ||
|
||
@Component({ | ||
selector: 'nz-graph-minimap', | ||
template: ` | ||
<svg> | ||
<defs> | ||
<filter id="minimapDropShadow" x="-20%" y="-20%" width="150%" height="150%"> | ||
<feOffset result="offOut" in="SourceGraphic" dx="1" dy="1"></feOffset> | ||
<feColorMatrix | ||
result="matrixOut" | ||
in="offOut" | ||
type="matrix" | ||
values="0.1 0 0 0 0 0 0.1 0 0 0 0 0 0.1 0 0 0 0 0 0.5 0" | ||
></feColorMatrix> | ||
<feGaussianBlur result="blurOut" in="matrixOut" stdDeviation="2"></feGaussianBlur> | ||
<feBlend in="SourceGraphic" in2="blurOut" mode="normal"></feBlend> | ||
</filter> | ||
</defs> | ||
<rect></rect> | ||
</svg> | ||
<canvas class="first"></canvas> | ||
<!-- Additional canvas to use as buffer to avoid flickering between updates --> | ||
<canvas class="second"></canvas> | ||
<canvas class="download"></canvas> | ||
`, | ||
host: { | ||
'[class.ant-graph-minimap]': 'true' | ||
} | ||
}) | ||
export class NzGraphMinimapComponent implements OnInit, OnDestroy { | ||
minimap?: Minimap; | ||
zoomInit$ = new Subscription(); | ||
constructor(private elementRef: ElementRef<HTMLElement>) {} | ||
|
||
ngOnInit(): void {} | ||
|
||
ngOnDestroy(): void { | ||
this.zoomInit$.unsubscribe(); | ||
} | ||
|
||
init(svgEle: SVGSVGElement, zoomEle: SVGGElement, zoomBehavior: ZoomBehavior<NzSafeAny, NzSafeAny>): void { | ||
this.minimap = new Minimap( | ||
svgEle, | ||
zoomEle, | ||
zoomBehavior, | ||
this.elementRef.nativeElement, | ||
NZ_LAYOUT_SETTING.minimap.size, | ||
NZ_LAYOUT_SETTING.subscene.meta.labelHeight | ||
); | ||
} | ||
|
||
zoom(transform: ZoomTransform): void { | ||
if (this.minimap) { | ||
this.minimap.zoom(transform); | ||
} | ||
} | ||
|
||
update(): void { | ||
if (this.minimap) { | ||
this.minimap.update(); | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.