From c6188f64c08725d97a593102a411924f68dbbb52 Mon Sep 17 00:00:00 2001 From: Ilfat Galiev Date: Tue, 22 Aug 2017 13:20:03 +0300 Subject: [PATCH] Dragging nodes to reorder positions (#24) * Dragging nodes * Use persistProperties method to store settings; * Add field names to tooltip labels title. * Add scale of node position in resizing viewport * Add UT for drag & drop testing --- CHANGELOG.md | 4 + capabilities.json | 20 ++++ package.json | 2 +- pbiviz.json | 4 +- src/dataInterfaces.ts | 7 ++ src/settings.ts | 15 ++- src/visual.ts | 264 ++++++++++++++++++++++++++++++++++++------ test/visualTest.ts | 82 +++++++++++++ 8 files changed, 361 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d79824..264dfd3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +## 1.4.0 + +* Feature to move nodes to any place in the viewport + ## 1.3.1 * Fix applying settings of link labels diff --git a/capabilities.json b/capabilities.json index a517bcd..8310c73 100644 --- a/capabilities.json +++ b/capabilities.json @@ -214,6 +214,26 @@ } } } + }, + "nodeComplexSettings": { + "displayName": "Sankey settigns", + "displayNameKey": "Visual_SankeySettings", + "properties": { + "nodePositions": { + "displayName": "Node positions", + "displayNameKey": "Visual_NodePositions", + "type": { + "text": true + } + }, + "viewportSize": { + "displayName": "Viewport sizes", + "displayNameKey": "Visual_ViewportSize", + "type": { + "text": true + } + } + } } }, "sorting": { diff --git a/package.json b/package.json index 74de304..16e33ef 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "powerbi-visuals-sankey", - "version": "1.3.1", + "version": "1.4.0", "description": "Sankey is a type of flow diagram in which the width of the series is in proportion to the quantity of the flow. Use it to find major contributions to an overall flow.", "repository": { "type": "git", diff --git a/pbiviz.json b/pbiviz.json index b34390e..d9915dd 100644 --- a/pbiviz.json +++ b/pbiviz.json @@ -1,10 +1,10 @@ { "visual": { "name": "SankeyDiagram", - "displayName": "Sankey 1.3.1", + "displayName": "Sankey 1.4.0", "guid": "SankeyDiagram1446463184954", "visualClassName": "SankeyDiagram", - "version": "1.3.1", + "version": "1.4.0", "description": "Sankey is a type of flow diagram in which the width of the series is in proportion to the quantity of the flow. Use it to find major contributions to an overall flow.", "supportUrl": "http://community.powerbi.com", "gitHubUrl": "https://github.com/Microsoft/powerbi-visuals-sankey" diff --git a/src/dataInterfaces.ts b/src/dataInterfaces.ts index c0de697..64b8943 100644 --- a/src/dataInterfaces.ts +++ b/src/dataInterfaces.ts @@ -63,6 +63,7 @@ module powerbi.extensibility.visual { colour: string; selectableDataPoints?: SelectableDataPoint[]; cloneLink?: SankeyDiagramNode; + settings?: SankeyDiagramNodePositionSetting; } export interface SankeyDiagramLink extends @@ -116,4 +117,10 @@ module powerbi.extensibility.visual { destination: any; weigth: number; } + + export interface SankeyDiagramNodePositionSetting { + name: string; + y?: string; + x?: string; + } } diff --git a/src/settings.ts b/src/settings.ts index 5c96038..1cc25eb 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -51,7 +51,17 @@ module powerbi.extensibility.visual { } export class SankeyLnScaleSettings { - public show: boolean= false; + public show: boolean = false; + } + + export class SankeyComplexSettings { + public nodePositions: string = "[]"; + public viewportSize: string = "{}"; + } + + export interface ViewportSize { + height?: string; + width?: string; } export class SankeyDiagramSettings extends DataViewObjectsParser { @@ -59,5 +69,8 @@ module powerbi.extensibility.visual { public labels: SankeyDiagramLabelsSettings = new SankeyDiagramLabelsSettings(); public _scale: SankeyDiagramScaleSettings = new SankeyDiagramScaleSettings(); public scaleSettings: SankeyLnScaleSettings = new SankeyLnScaleSettings(); + public nodeComplexSettings: SankeyComplexSettings = new SankeyComplexSettings(); + public _nodePositions: SankeyDiagramNodePositionSetting[] = []; + public _viewportSize: ViewportSize = {}; } } diff --git a/src/visual.ts b/src/visual.ts index 1dd3b70..85bf0e2 100755 --- a/src/visual.ts +++ b/src/visual.ts @@ -104,6 +104,11 @@ module powerbi.extensibility.visual { propertyName: "fill" }; + private static NodeComplexSettingsPropertyIdentifier: DataViewObjectPropertyIdentifier = { + objectName: "nodeComplexSettings", + propertyName: "nodePositions" + }; + private static NodesPropertyIdentifier: DataViewObjectPropertyIdentifier = { objectName: "nodes", propertyName: "fill" @@ -205,6 +210,10 @@ module powerbi.extensibility.visual { private fontFamily: string; + public static SourceCategoryIndex: number = 0; + public static DestinationCategoryIndex: number = 1; + public static FirstValueIndex: number = 0; + private get textProperties(): TextProperties { return { fontFamily: this.fontFamily, @@ -346,7 +355,12 @@ module powerbi.extensibility.visual { sourceCategories, destinationCategories, dataView.categorical.values, - sourceCategory.objects || []); + sourceCategory.objects || [], + settings, + dataView.categorical.categories[SankeyDiagram.SourceCategoryIndex].source.displayName, + dataView.categorical.categories[SankeyDiagram.DestinationCategoryIndex].source.displayName, + dataView.categorical.values[SankeyDiagram.FirstValueIndex].source.displayName + ); let cycles: SankeyDiagramCycleDictionary = this.checkCycles(nodes); @@ -535,6 +549,8 @@ module powerbi.extensibility.visual { color: settings.labels.fill }; + let nodeSettings: SankeyDiagramNodePositionSetting = this.getNodeSettings(item, settings); + nodes.push({ label: label, links: [], @@ -544,26 +560,26 @@ module powerbi.extensibility.visual { height: 0, colour: this.colorPalette.getColor(index.toString()).value, tooltipInfo: [], - selectableDataPoints: [] + selectableDataPoints: [], + settings: nodeSettings }); }); return nodes; } - private applyColorToNodes(nodes: SankeyDiagramNode[]): void { - nodes.forEach((node: SankeyDiagramNode, index: number) => { - node.colour = this.colorPalette.getColor(index.toString()).value; - }); - } - private createLinks( nodes: SankeyDiagramNode[], selectionIdBuilder: SankeyDiagramSelectionIdBuilder, sourceCategories: any[], destinationCategories: any[], valueColumns: DataViewValueColumns, - linksObjects: DataViewObjects[]): SankeyDiagramLink[] { + linksObjects: DataViewObjects[], + settings: SankeyDiagramSettings, + sourceFieldName: string, + destinationFieldName: string, + valueFieldName: string + ): SankeyDiagramLink[] { let valuesColumn: DataViewValueColumn = valueColumns && valueColumns[0], links: SankeyDiagramLink[] = [], weightValues: number[] = [], @@ -634,7 +650,11 @@ module powerbi.extensibility.visual { valuesFormatterForWeigth, sourceNode.label.formattedName, destinationNode.label.formattedName, - dataPoint.weigth), + dataPoint.weigth, + sourceFieldName, + destinationFieldName, + valueFieldName + ), identity: selectionId, selected: false }; @@ -650,20 +670,20 @@ module powerbi.extensibility.visual { SankeyDiagram.updateValueOfNode(sourceNode); SankeyDiagram.updateValueOfNode(destinationNode); + }); - sourceNode.tooltipInfo = SankeyDiagram.getTooltipForNode( + nodes.forEach((nodes: SankeyDiagramNode) => { + nodes.tooltipInfo = SankeyDiagram.getTooltipForNode( valuesFormatterForWeigth, - sourceNode.label.formattedName, - sourceNode.inputWeight - ? sourceNode.inputWeight - : sourceNode.outputWeight); + nodes.label.formattedName, + nodes.inputWeight + ? nodes.inputWeight + : nodes.outputWeight, + nodes.inputWeight > 0 && nodes.outputWeight > 0 ? `${sourceFieldName}-${destinationFieldName}` : nodes.outputWeight > 0 + ? sourceFieldName + : destinationFieldName, + valueFieldName); - destinationNode.tooltipInfo = SankeyDiagram.getTooltipForNode( - valuesFormatterForWeigth, - destinationNode.label.formattedName, - destinationNode.inputWeight - ? destinationNode.inputWeight - : destinationNode.outputWeight); }); return links; @@ -679,6 +699,21 @@ module powerbi.extensibility.visual { }; } + private getNodeSettings( + internalName: string, + settings: SankeyDiagramSettings): SankeyDiagramNodePositionSetting { + + let setting: SankeyDiagramNodePositionSetting = null; + settings._nodePositions.some( (nodePositions: SankeyDiagramNodePositionSetting) => { + if (nodePositions.name === internalName) { + setting = nodePositions; + return true; + } + }); + + return setting; + } + private getColor( properties: DataViewObjectPropertyIdentifier, defaultColor: string, @@ -696,7 +731,11 @@ module powerbi.extensibility.visual { valueFormatter: IValueFormatter, sourceNodeName: string, destinationNodeName: string, - linkWeight: number): VisualTooltipDataItem[] { + linkWeight: number, + sourceNodeDisplayName?: string, + destinationNodeDisplayName?: string, + valueDisplayName?: string, + ): VisualTooltipDataItem[] { let formattedLinkWeight: string; @@ -708,13 +747,13 @@ module powerbi.extensibility.visual { return [ { - displayName: SankeyDiagram.RoleNames.rows, + displayName: sourceNodeDisplayName || SankeyDiagram.RoleNames.rows, value: sourceNodeName }, { - displayName: SankeyDiagram.RoleNames.columns, + displayName: destinationNodeDisplayName || SankeyDiagram.RoleNames.columns, value: destinationNodeName }, { - displayName: SankeyDiagram.RoleNames.values, + displayName: valueDisplayName || SankeyDiagram.RoleNames.values, value: formattedLinkWeight } ]; @@ -737,7 +776,10 @@ module powerbi.extensibility.visual { private static getTooltipForNode( valueFormatter: IValueFormatter, nodeName: string, - nodeWeight: number): VisualTooltipDataItem[] { + nodeWeight: number, + nodeDisplayName?: string, + valueDisplayName?: string, + ): VisualTooltipDataItem[] { let formattedNodeWeigth: string; @@ -749,17 +791,35 @@ module powerbi.extensibility.visual { return [ { - displayName: SankeyDiagram.TooltipDisplayName, + displayName: nodeDisplayName || SankeyDiagram.TooltipDisplayName, value: nodeName }, { - displayName: SankeyDiagram.RoleNames.values, + displayName: valueDisplayName || SankeyDiagram.RoleNames.values, value: formattedNodeWeigth } ]; } private parseSettings(dataView: DataView): SankeyDiagramSettings { - return SankeyDiagramSettings.parse(dataView); + let settings: SankeyDiagramSettings = SankeyDiagramSettings.parse(dataView); + // node positions + try { + settings._nodePositions = JSON.parse(settings.nodeComplexSettings.nodePositions); + } + catch (exception) { + settings._nodePositions = []; + settings.nodeComplexSettings.nodePositions = "[]"; + } + + // viewport size + try { + settings._viewportSize = JSON.parse(settings.nodeComplexSettings.viewportSize); + } + catch (exception) { + settings._nodePositions = settings._nodePositions || []; + settings.nodeComplexSettings.viewportSize = "{}"; + } + return settings; } private computePositions(sankeyDiagramDataView: SankeyDiagramDataView): void { @@ -856,12 +916,35 @@ module powerbi.extensibility.visual { this.computeYPosition( sankeyDiagramDataView.nodes, - sankeyDiagramDataView.settings._scale.y); + sankeyDiagramDataView.settings._scale.y + ); + + this.applySavedPositions(sankeyDiagramDataView); this.computeBordersOfTheNode(sankeyDiagramDataView); SankeyDiagram.computeIntersections(sankeyDiagramDataView); } + private applySavedPositions(sankeyDiagramDataView: SankeyDiagramDataView) { + // if size were changed shift positions of nodes + let viewPort: ViewportSize = sankeyDiagramDataView.settings._viewportSize; + let scaleHeight: number = 1; + if (+viewPort.height !== this.viewport.height && viewPort.height && +viewPort.height !== 0) { + scaleHeight = this.viewport.height / +viewPort.height; + } + let scaleWidth: number = 1; + if (+viewPort.width !== this.viewport.width && viewPort.width && +viewPort.width !== 0) { + scaleWidth = this.viewport.width / +viewPort.width; + } + + sankeyDiagramDataView.nodes.forEach( (node: SankeyDiagramNode) => { + if (node.settings !== null) { + node.x = (+node.settings.x) * scaleWidth; + node.y = (+node.settings.y) * scaleHeight; + } + }); + } + private computeBordersOfTheNode(sankeyDiagramDataView: SankeyDiagramDataView): void { sankeyDiagramDataView.nodes.forEach((node: SankeyDiagramNode) => { let textHeight: number = textMeasurementService.estimateSvgTextHeight({ @@ -1153,7 +1236,7 @@ module powerbi.extensibility.visual { }) .classed(SankeyDiagram.NodeSelector.class, true); - nodesEnterSelection + let rectNodes: Selection = nodesEnterSelection .append("rect") .classed(SankeyDiagram.NodeRectSelector.class, true); @@ -1220,6 +1303,70 @@ module powerbi.extensibility.visual { return node.label.formattedName; }); + let drag = d3.behavior.drag() + .origin(function(node: SankeyDiagramNode, index: number) { + return { x: node.x, y: node.y}; + }) + .on("dragstart", dragstarted) + .on("drag", dragged) + .on("dragend", dragend); + + function dragstarted(node: SankeyDiagramNode) { + (d3.event as any).sourceEvent.stopPropagation(); + } + + let sankeyVisual = this; + let allowSave: boolean = true; + function dragged(node: SankeyDiagramNode) { + allowSave = false; + node.x = (d3.event as any).x; + node.y = (d3.event as any).y; + + if (node.x < 0 ) { + node.x = 0; + } + + if (node.y < 0 ) { + node.y = 0; + } + + if (node.x + node.width > sankeyVisual.viewport.width ) { + node.x = sankeyVisual.viewport.width - node.width; + } + + if (node.y + node.height > sankeyVisual.viewport.height ) { + node.y = sankeyVisual.viewport.height - node.height; + } + + node.settings = { + x: node.x.toFixed(2), + y: node.y.toFixed(2), + name: node.label.internalName + }; + + // Update each link related with this node + node.links.forEach( (link: SankeyDiagramLink) => { + // select link svg element by ID generated in link creation as Source-Destination + d3.select(`#${(link.source.label.internalName || "").replace(/\W*/g,"")}-${(link.destination.label.internalName || "").replace(/\W*/g,"")}`).attr({ + // get updated path params based on actual positions of node + d: sankeyVisual.getSvgPath(link) + }); + }); + + // Translate the object on the actual moved point + d3.select(this).attr({ + transform: translate(node.x, node.y) + }); + allowSave = true; + } + + function dragend(node: SankeyDiagramNode) { + sankeyVisual.saveNodePositions(sankeyVisual.dataView.nodes); + sankeyVisual.saveViewportSize(); + } + + nodesEnterSelection.call(drag); + nodesSelection .exit() .remove(); @@ -1227,6 +1374,54 @@ module powerbi.extensibility.visual { return nodesSelection; } + private saveViewportSize(): void { + const instance: VisualObjectInstance = { + objectName: "nodeComplexSettings", + selector: undefined, + properties: { + viewportSize: JSON.stringify({ + height: this.viewport.height.toString(), + width: this.viewport.width.toString() + }) + } + }; + + this.visualHost.persistProperties({ + merge: [ + instance + ] + }); + } + + private saveNodePositions(nodes: SankeyDiagramNode[]): void { + let nodePositions: SankeyDiagramNodePositionSetting[] = []; + nodes.forEach((node: SankeyDiagramNode) => { + if (node.height === 0) { + return; + } + let settings: SankeyDiagramNodePositionSetting = { + name: node.label.internalName, + x: node.x.toFixed(0), + y: node.y.toFixed(0) + }; + nodePositions.push(settings); + }); + + const instance: VisualObjectInstance = { + objectName: "nodeComplexSettings", + selector: undefined, + properties: { + nodePositions: JSON.stringify(nodePositions) + } + }; + + this.visualHost.persistProperties({ + merge: [ + instance + ] + }); + } + private getLabelPositionByAxisX(node: SankeyDiagramNode): number { if (this.isLabelLargerThanWidth(node)) { return -(SankeyDiagram.LabelMargin); @@ -1269,8 +1464,11 @@ module powerbi.extensibility.visual { .classed(SankeyDiagram.LinkSelector.class, true); linksSelection - .attr("d", (link: SankeyDiagramLink) => { - return this.getSvgPath(link); + .attr({ + d: (link: SankeyDiagramLink) => { + return this.getSvgPath(link); + }, + id: (link: SankeyDiagramLink) => `${(link.source.label.internalName || "").replace(/\W*/g,"")}-${(link.destination.label.internalName || "").replace(/\W*/g,"")}` }) .style({ "stroke-width": (link: SankeyDiagramLink) => link.height < SankeyDiagram.MinWidthOfLink ? SankeyDiagram.MinWidthOfLink : link.height, diff --git a/test/visualTest.ts b/test/visualTest.ts index 7c81d7b..da38fe7 100644 --- a/test/visualTest.ts +++ b/test/visualTest.ts @@ -40,6 +40,8 @@ module powerbi.extensibility.visual.test { import SankeyDiagramColumn = powerbi.extensibility.visual.SankeyDiagram1446463184954.SankeyDiagramColumn; import SankeyDiagramDataView = powerbi.extensibility.visual.SankeyDiagram1446463184954.SankeyDiagramDataView; import SankeyDiagramLink = powerbi.extensibility.visual.SankeyDiagram1446463184954.SankeyDiagramLink; + import SankeyDiagramNodePositionSetting = powerbi.extensibility.visual.SankeyDiagram1446463184954.SankeyDiagramNodePositionSetting; + import SankeyDiagramNodePositionSettingsCollection = powerbi.extensibility.visual.SankeyDiagram1446463184954.SankeyDiagramNodePositionSettingsCollection; // powerbi.extensibility.utils.test import clickElement = powerbi.extensibility.utils.test.helpers.clickElement; @@ -53,6 +55,12 @@ module powerbi.extensibility.visual.test { outputWeight: number; } + let fireMouseEvent = function (type, elem, centerX, centerY) { + let evt = document.createEvent('MouseEvents'); + evt.initMouseEvent(type, true, true, window, 1, 1, 1, centerX, centerY, false, false, false, false, 0, elem); + elem.dispatchEvent(evt); + }; + describe("SankeyDiagram", () => { let visualBuilder: SankeyDiagramBuilder, visualInstance: VisualClass, @@ -501,6 +509,80 @@ module powerbi.extensibility.visual.test { }); }); }); + + describe("nodes", () => { + it("must be dragged", done => { + let dataView: DataView = defaultDataViewBuilder.getDataView(); + + visualBuilder.updateRenderTimeout([dataView], () => { + let nodeToDrag = visualBuilder.nodeElements[0]; + + let pos = nodeToDrag.getBoundingClientRect(); + let center1X = Math.floor((pos.left + pos.right) / 2); + let center1Y = Math.floor((pos.top + pos.bottom) / 2); + + // user second node as target + let anotherNode = visualBuilder.nodeElements[1]; + pos = anotherNode.getBoundingClientRect(); + let center2X = Math.floor((pos.left + pos.right) / 2); + let center2Y = Math.floor((pos.top + pos.bottom) / 2); + + // mouse over dragged element and mousedown + fireMouseEvent('mousemove', nodeToDrag, center1X, center1Y); + fireMouseEvent('mouseenter', nodeToDrag, center1X, center1Y); + fireMouseEvent('mouseover', nodeToDrag, center1X, center1Y); + fireMouseEvent('mousedown', nodeToDrag, center1X, center1Y); + + // start dragging process over to drop target + fireMouseEvent('dragstart', nodeToDrag, center1X, center1Y); + fireMouseEvent('drag', nodeToDrag, center1X, center1Y); + fireMouseEvent('mousemove', nodeToDrag, center1X, center1Y); + fireMouseEvent('drag', nodeToDrag, center2X, center2Y); + fireMouseEvent('mousemove', nodeToDrag, center2X, center2Y); + fireMouseEvent('dragend', nodeToDrag, center2X, center2Y); + + pos = nodeToDrag.getBoundingClientRect(); + center1X = Math.floor((pos.left + pos.right) / 2); + center1Y = Math.floor((pos.top + pos.bottom) / 2); + + // positions must match after drag and drop + expect(center1X).toBe(center2X); + expect(center1Y).toBe(center2Y); + + // drag to outside of viewport + // mouse over dragged element and mousedown + fireMouseEvent('dragstart', nodeToDrag, center1X, center1Y); + fireMouseEvent('drag', nodeToDrag, center1X, center1Y); + fireMouseEvent('mousemove', nodeToDrag, center1X, center1Y); + fireMouseEvent('drag', nodeToDrag, -10, -10); + fireMouseEvent('mousemove', nodeToDrag, -10, -10); + fireMouseEvent('dragend', nodeToDrag, -10, -10); + + // positions must match after drag and drop + expect((nodeToDrag as any).getBoundingClientRect().left).toBeLessThan(20); + expect((nodeToDrag as any).getBoundingClientRect().top).toBeLessThan(20); + + // drag to outside of viewport + // mouse over dragged element and mousedown + fireMouseEvent('dragstart', nodeToDrag, center1X, center1Y); + fireMouseEvent('drag', nodeToDrag, center1X, center1Y); + fireMouseEvent('mousemove', nodeToDrag, center1X, center1Y); + fireMouseEvent('drag', nodeToDrag, visualBuilder.viewport.width + 10, visualBuilder.viewport.height + 10); + fireMouseEvent('mousemove', nodeToDrag, visualBuilder.viewport.width + 10, visualBuilder.viewport.height + 10); + fireMouseEvent('dragend', nodeToDrag, visualBuilder.viewport.width + 10, visualBuilder.viewport.height + 10); + + // positions must match after drag and drop + expect((nodeToDrag as any).getBoundingClientRect().right).toBeGreaterThan(visualBuilder.viewport.width - 20); + expect((nodeToDrag as any).getBoundingClientRect().bottom).toBeGreaterThan(visualBuilder.viewport.height - 20); + + // call private methods + (visualBuilder.instance as any).saveNodePositions((visualBuilder.instance as any).dataView.nodes); + (visualBuilder.instance as any).saveViewportSize(); + + done(); + }); + }); + }); }); }); }