diff --git a/CHANGELOG.md b/CHANGELOG.md index b3317f00..7a457c10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed +* rotate=90 graph attribute does not set the rotation #251 + ## [5.6.0] – 2024-08-18 ### Changed @@ -22,7 +25,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * Upgrade @hpcc-js/wasm to 2.16.2 (Graphviz 11.0.0) ### Fixed -* Cluster's box shadows nodes inside it after animated transition +* Cluster's box shadows nodes inside it after animated transition #308 ## [5.3.0] – 2024-02-11 diff --git a/examples/demo.html b/examples/demo.html index 34afdf24..5dbef82f 100644 --- a/examples/demo.html +++ b/examples/demo.html @@ -52,7 +52,7 @@ a -> c }`, `digraph { - node [style="filled"] + node [style="filled"] a [fillcolor="#d62728" shape="box"] b [fillcolor="#1f77b4" shape="parallelogram"] c [fillcolor="#2ca02c" shape="pentagon"] @@ -70,7 +70,7 @@ b -> c }`, `digraph { - node [style="filled"] + node [style="filled"] a [fillcolor="#d62728" shape="triangle"] b [fillcolor="#1f77b4" shape="diamond"] c [fillcolor="#2ca02c" shape="trapezium"] @@ -79,7 +79,7 @@ b -> c }`, `digraph { - node [style="filled"] + node [style="filled"] a [fillcolor="#d62728" shape="box"] b [fillcolor="#1f77b4" shape="parallelogram"] c [fillcolor="#2ca02c" shape="pentagon"] diff --git a/src/element.js b/src/element.js index 5aa398a6..75a5ed16 100644 --- a/src/element.js +++ b/src/element.js @@ -23,8 +23,18 @@ export function extractElementData(element) { var transform = element.node().transform; if (transform && transform.baseVal.numberOfItems != 0) { var matrix = transform.baseVal.consolidate().matrix; - datum.translation = {x: matrix.e, y: matrix.f}; - datum.scale = matrix.a; + if (matrix.b == 0) { + // drawing orientation is portrait + datum.translation = { x: matrix.e, y: matrix.f }; + datum.scale = matrix.a; + datum.rotation = 0; + } + else { + // drawing orientation is landscape + datum.translation = { x: matrix.e, y: matrix.f }; + datum.scale = matrix.c; + datum.rotation = -90; + } } if (tag == 'ellipse') { datum.center = { diff --git a/src/graphviz.js b/src/graphviz.js index c4638cb0..37dc2a04 100644 --- a/src/graphviz.js +++ b/src/graphviz.js @@ -138,6 +138,7 @@ export function Graphviz(selection, options) { this._images = []; this._translation = undefined; this._scale = undefined; + this._rotation = undefined; this._eventTypes = [ 'initEnd', 'start', diff --git a/src/render.js b/src/render.js index 9c3007b6..a35e4c08 100644 --- a/src/render.js +++ b/src/render.js @@ -288,8 +288,12 @@ function _render(callback) { elementTransition .tween("attr.transform", function() { var node = this; - return function(t) { - node.setAttribute("transform", interpolateTransformSvg(zoomTransform(graphvizInstance._zoomSelection.node()).toString(), getTranslatedZoomTransform.call(graphvizInstance, element).toString())(t)); + return function (t) { + const rotateStr = ` rotate(${data.rotation})`; + const fromTransform = zoomTransform(graphvizInstance._zoomSelection.node()).toString() + rotateStr; + const toTransform = getTranslatedZoomTransform.call(graphvizInstance, element).toString() + rotateStr; + const interpolationFunction = interpolateTransformSvg(fromTransform, toTransform); + node.setAttribute("transform", interpolationFunction(t)); }; }); } diff --git a/src/zoom.js b/src/zoom.js index a8fd7300..b101c53b 100644 --- a/src/zoom.js +++ b/src/zoom.js @@ -21,7 +21,18 @@ export function createZoomBehavior() { var graphvizInstance = this; function zoomed(event) { var g = d3.select(svg.node().querySelector("g")); - g.attr('transform', event.transform); + var transform = g.node().transform; + var matrix = transform.baseVal.consolidate().matrix; + if (matrix.b == 0) { + // drawing orientation is portrait + g.attr('transform', event.transform); + } + else { + // drawing orientation is landscape + const t = event.transform; + const transformStr = `rotate(-90) translate(${-t.y} ${t.x}) scale(${t.k})`; + g.attr('transform', transformStr); + } graphvizInstance._dispatch.call('zoom', graphvizInstance); } diff --git a/test/dot-data-test.js b/test/dot-data-test.js index 4854ce36..d5df8b34 100644 --- a/test/dot-data-test.js +++ b/test/dot-data-test.js @@ -69,6 +69,7 @@ var basic_data = { "y": 112 }, "scale": 1, + "rotation": 0, "parent": "[Circular ~]", "children": [ { diff --git a/test/it.js b/test/it.js index acc370ef..66a6ff79 100644 --- a/test/it.js +++ b/test/it.js @@ -11,3 +11,17 @@ export default function it_wrapper(decription, run) { }); }); } + +export function it_xfail(decription, run) { + it(decription + " XFAIL", async function () { + await new Promise(async (resolve, reject) => { + try { + await run.call(this); + } + catch (e) { + resolve(); + } + reject(Error("The test unexpectedly passed (XPASS)")); + }); + }); +} diff --git a/test/jsdom.js b/test/jsdom.js index 9554b98c..1e3022df 100644 --- a/test/jsdom.js +++ b/test/jsdom.js @@ -93,29 +93,33 @@ export default function jsdomit(html, options) { if (!('transform' in window.SVGElement.prototype)) { Object.defineProperty(window.SVGElement.prototype, 'transform', { get: function() { - if (this.getAttribute('transform')) { - var translate = this.getAttribute('transform').replace(/.*translate\((-*[\d.]+[ ,]+-*[\d.]+)\).*/, function(match, xy) { + const transform = this.getAttribute('transform'); + if (transform) { + var translate = transform.replace(/.*translate\((-*[\d.]+[ ,]+-*[\d.]+)\).*/, function(match, xy) { return xy; }).split(/[ ,]+/).map(function(v) { return +v; }); - var scale = this.getAttribute('transform').replace(/.*.*scale\((-*[\d.]+[ ,]*-*[\d.]*)\).*/, function(match, scale) { + var scale = transform.includes('scale') ? transform.replace(/.*scale\((-*[\d.]+[ ,]*-*[\d.]*)\).*/, function(match, scale) { return scale; }).split(/[ ,]+/).map(function(v) { return +v; - }); + }) : 1; + var rotate = transform.includes('rotate') ? transform.replace(/.*rotate\((-*[\d.]+)\).*/, function (match, rotate) { + return rotate; + }) : 0; return { baseVal: { numberOfItems: 1, consolidate: function() { return { matrix: { - 'a': scale[0], - 'b': 0, - 'c': 0, - 'd': scale[1] || scale[0], - 'e': translate[0], - 'f': translate[1], + 'a': rotate == 0 ? scale[0] : 0, + 'b': rotate == 0 ? 0 : -scale[0], + 'c': rotate == 0 ? 0 : scale[0], + 'd': rotate == 0 ? scale[1] || scale[0] : 0, + 'e': rotate == 0 ? translate[0] : translate[1], + 'f': rotate == 0 ? translate[1] : -translate[0], } }; }, diff --git a/test/simple-rotation-test.js b/test/simple-rotation-test.js new file mode 100644 index 00000000..d9efa4e4 --- /dev/null +++ b/test/simple-rotation-test.js @@ -0,0 +1,47 @@ +import assert from "assert"; +import it from "./it.js"; +import jsdom from "./jsdom.js"; +import * as d3 from "d3-selection"; +import * as d3_graphviz from "../index.js"; + +it("Simple rendering an SVG from graphviz DOT with rotate=90 and zoom disabled.", async () => { + var window = global.window = jsdom('
'); + global.document = window.document; + + var graphviz; + + await new Promise(resolve => { + graphviz = d3_graphviz.graphviz("#graph") + .zoom(false) + .renderDot('digraph {rotate=90; a -> b;}', resolve); + }); + + assert.equal(d3.selectAll('.node').size(), 2, 'Number of nodes'); + assert.equal(d3.selectAll('.edge').size(), 1, 'Number of edges'); + assert.equal(d3.selectAll('ellipse').size(), 2, 'Number of ellipses'); + assert.equal(d3.selectAll('polygon').size(), 2, 'Number of polygons'); + assert.equal(d3.selectAll('path').size(), 1, 'Number of paths'); + + assert.equal(d3.select('#graph0').attr("transform"), "scale(1 1) rotate(-90) translate(-58 112)"); +}); + +it("Simple rendering an SVG from graphviz DOT with rotate=90 and zoom enabled.", async () => { + var window = global.window = jsdom('
'); + global.document = window.document; + + var graphviz; + + await new Promise(resolve => { + graphviz = d3_graphviz.graphviz("#graph") + .zoom(true) + .renderDot('digraph {rotate=90; a -> b;}', resolve); + }); + + assert.equal(d3.selectAll('.node').size(), 2, 'Number of nodes'); + assert.equal(d3.selectAll('.edge').size(), 1, 'Number of edges'); + assert.equal(d3.selectAll('ellipse').size(), 2, 'Number of ellipses'); + assert.equal(d3.selectAll('polygon').size(), 2, 'Number of polygons'); + assert.equal(d3.selectAll('path').size(), 1, 'Number of paths'); + + assert.equal(d3.select('#graph0').attr("transform"), "rotate(-90) translate(-58 112) scale(1)"); +}); diff --git a/test/zoom-rotation-test.js b/test/zoom-rotation-test.js new file mode 100644 index 00000000..0bf4ee98 --- /dev/null +++ b/test/zoom-rotation-test.js @@ -0,0 +1,249 @@ +import assert from "assert"; +import it from "./it.js"; +import jsdom from "./jsdom.js"; +import * as d3_graphviz from "../index.js"; +import * as d3_transition from "d3-transition"; +import * as d3_zoom from "d3-zoom"; +import * as d3_selection from "d3-selection"; + +it("resetZoom resets the zoom transform to the original transform when rotate=90.", async () => { + var window = global.window = jsdom('
'); + global.document = window.document; + var graphviz; + + await new Promise(resolve => { + graphviz = d3_graphviz.graphviz("#graph") + .on('initEnd', resolve); + }); + + var dx = 10; + var dy = 20; + + graphviz + .zoom(true); + + assert.ok(graphviz._options.zoom, '.zoom(true) enables zooming'); + assert.ok(!graphviz._zoomBehavior, 'The zoom behavior is not attached before a graph has been rendered'); + + await new Promise(resolve => { + graphviz + .renderDot('digraph {rotate=90; a -> b;}', resolve); + }); + + assert.ok(graphviz._zoomBehavior, 'The zoom behavior is attached when the graph rendering has been initiated'); + const selection = graphviz._zoomSelection; + const zoom = graphviz._zoomBehavior; + + const matrix0 = d3_selection.select('g').node().transform.baseVal.consolidate().matrix; + assert.deepStrictEqual( + d3_zoom.zoomTransform(graphviz._zoomSelection.node()), + d3_zoom.zoomIdentity.translate(matrix0.e, matrix0.f).scale(matrix0.c), + 'The zoom transform is equal to the "g" transform after rendering' + ); + + selection.call(zoom.translateBy, dx, dy); + assert.deepStrictEqual( + d3_zoom.zoomTransform(graphviz._zoomSelection.node()), + d3_zoom.zoomIdentity.translate(matrix0.e + dx, matrix0.f + dy).scale(matrix0.c), + 'The zoom transform is translated after zooming' + ); + + const matrix1 = d3_selection.select('g').node().transform.baseVal.consolidate().matrix; + assert.deepStrictEqual( + d3_zoom.zoomTransform(graphviz._zoomSelection.node()), + d3_zoom.zoomIdentity.translate(matrix1.e, matrix1.f).scale(matrix1.c), + 'The zoom transform is equal to the "g" transform after zooming' + ); + + graphviz.resetZoom(); + assert.deepStrictEqual( + d3_zoom.zoomTransform(graphviz._zoomSelection.node()), + d3_zoom.zoomIdentity.translate(matrix0.e, matrix0.f).scale(matrix0.c), + 'The original zoom transform is restored after zoom reset' + ); + + selection.call(zoom.translateBy, dx, dy); + assert.deepStrictEqual( + d3_zoom.zoomTransform(graphviz._zoomSelection.node()), + d3_zoom.zoomIdentity.translate(matrix0.e + dx, matrix0.f + dy).scale(matrix0.c), + 'The zoom transform is translated after zooming' + ); + + await new Promise(resolve => { + graphviz.resetZoom( + d3_transition.transition() + .duration(0) + .on("end", function() { + d3_zoom.zoomTransform(graphviz._zoomSelection.node()), + d3_zoom.zoomIdentity.translate(matrix0.e, matrix0.f).scale(matrix0.c), + 'The original zoom transform is restored after zoom reset with transition' + resolve(); + }) + ); + assert.deepStrictEqual( + d3_zoom.zoomTransform(graphviz._zoomSelection.node()), + d3_zoom.zoomIdentity.translate(matrix0.e + dx, matrix0.f + dy).scale(matrix0.c), + 'The original zoom transform is not restored directly after zoom reset with transition' + ); + }); + +}); + +it("resetZoom resets the zoom transform to the original transform of the latest rendered graph when rotate=90.", async () => { + var window = global.window = jsdom('
'); + global.document = window.document; + var graphviz; + + await new Promise(resolve => { + graphviz = d3_graphviz.graphviz("#graph") + .on('initEnd', resolve); + }); + + var dx = 10; + var dy = 20; + + graphviz + .zoom(true); + + assert.ok(graphviz._options.zoom, '.zoom(true) enables zooming'); + assert.ok(!graphviz._zoomBehavior, 'The zoom behavior is not attached before a graph has been rendered'); + + graphviz + .renderDot('digraph {rotate=90; a -> b;}'); + + assert.ok(graphviz._zoomBehavior, 'The zoom behavior is attached when the graph rendering has been initiated'); + const selection = graphviz._zoomSelection; + const zoom = graphviz._zoomBehavior; + + const matrix0 = d3_selection.select('g').node().transform.baseVal.consolidate().matrix; + assert.deepStrictEqual( + d3_zoom.zoomTransform(graphviz._zoomSelection.node()), + d3_zoom.zoomIdentity.translate(matrix0.e, matrix0.f).scale(matrix0.c), + 'The zoom transform is equal to the "g" transform after rendering' + ); + + selection.call(zoom.translateBy, dx, dy); + assert.deepStrictEqual( + d3_zoom.zoomTransform(graphviz._zoomSelection.node()), + d3_zoom.zoomIdentity.translate(matrix0.e + dx, matrix0.f + dy).scale(matrix0.c), + 'The zoom transform is translated after zooming' + ); + + const matrix1 = d3_selection.select('g').node().transform.baseVal.consolidate().matrix; + assert.deepStrictEqual( + d3_zoom.zoomTransform(graphviz._zoomSelection.node()), + d3_zoom.zoomIdentity.translate(matrix1.e, matrix1.f).scale(matrix1.c), + 'The zoom transform is equal to the "g" transform after zooming' + ); + + graphviz.resetZoom(); + assert.deepStrictEqual( + d3_zoom.zoomTransform(graphviz._zoomSelection.node()), + d3_zoom.zoomIdentity.translate(matrix0.e, matrix0.f).scale(matrix0.c), + 'The original zoom transform is restored after zoom reset' + ); + + selection.call(zoom.translateBy, dx, dy); + assert.deepStrictEqual( + d3_zoom.zoomTransform(graphviz._zoomSelection.node()), + d3_zoom.zoomIdentity.translate(matrix0.e + dx, matrix0.f + dy).scale(matrix0.c), + 'The zoom transform is translated after zooming' + ); + + const width1 = +d3_selection.select('svg').attr("width").replace('pt', ''); + + graphviz + .renderDot('digraph {rotate=90; a -> b; b -> c}'); + + const width2 = +d3_selection.select('svg').attr("width").replace('pt', ''); + + const matrix2 = { ...matrix1 }; + matrix2.e += width2 - width1 + + assert.deepStrictEqual( + d3_zoom.zoomTransform(graphviz._zoomSelection.node()), + d3_zoom.zoomIdentity.translate(matrix2.e, matrix2.f).scale(matrix2.c), + 'The zoom transform translation is unchanged after rendering' + ); + + graphviz.resetZoom(); + + const matrix3 = { ...matrix0 }; + matrix3.e += width2 - width1 + + assert.deepStrictEqual( + d3_zoom.zoomTransform(graphviz._zoomSelection.node()), + d3_zoom.zoomIdentity.translate(matrix3.e, matrix3.f).scale(matrix3.c), + 'The original zoom transform is restored directly after zoom reset' + ); + +}); + +it("zooming rescales transforms during transitions when rotate=90.", async () => { + var window = global.window = jsdom('
'); + global.document = window.document; + var graphviz; + + await new Promise(resolve => { + graphviz = d3_graphviz.graphviz("#graph") + .on('initEnd', resolve); + }); + + graphviz + .zoom(true) + .transition(d3_transition.transition().duration(100)); + + assert.ok(graphviz._options.zoom, '.zoom(true) enables zooming'); + assert.ok(!graphviz._zoomBehavior, 'The zoom behavior is not attached before a graph has been rendered'); + + await new Promise(resolve => { + graphviz + .renderDot('digraph {rotate=90; a -> b;}') + .on('transitionStart', function() { + assert.ok(graphviz._zoomBehavior, 'The zoom behavior is attached when transition starts'); + }) + .on('end', resolve); + + assert.ok(graphviz._zoomBehavior, 'The zoom behavior is attached when the graph rendering has been initiated'); + + assert.deepStrictEqual( + d3_zoom.zoomTransform(graphviz._zoomSelection.node()), + d3_zoom.zoomIdentity, + 'The zoom transform is equal to the zoom identity transform before transition' + ); + }); + + const matrix = d3_selection.select('g').node().transform.baseVal.consolidate().matrix; + assert.deepStrictEqual( + d3_zoom.zoomTransform(graphviz._zoomSelection.node()), + d3_zoom.zoomIdentity.translate(matrix.e, matrix.f).scale(matrix.c), + 'The zoom transform is equal to the "g" transform after transition' + ); + + await new Promise(resolve => { + graphviz + .transition(d3_transition.transition().duration(100)) + .renderDot('digraph {rotate=90; a -> b; b -> c}') + .on('transitionStart', function() { + assert.ok(graphviz._zoomBehavior, 'The zoom behavior is attached when transition starts'); + }) + .on('end', resolve); + + assert.ok(graphviz._zoomBehavior, 'The zoom behavior is attached when the graph rendering has been initiated'); + + assert.deepStrictEqual( + d3_zoom.zoomTransform(graphviz._zoomSelection.node()), + d3_zoom.zoomIdentity.translate(matrix.e, matrix.f).scale(matrix.c), + 'The zoom transform is unchanged before 2nd transition' + ); + }); + + const matrix2 = d3_selection.select('g').node().transform.baseVal.consolidate().matrix; + assert.notDeepEqual(matrix2, matrix, 'The "g" transform changes when the graph changes'); + assert.deepStrictEqual( + d3_zoom.zoomTransform(graphviz._zoomSelection.node()), + d3_zoom.zoomIdentity.translate(matrix2.e, matrix2.f).scale(matrix2.c), + 'The zoom transform is equal to the "g" transform after 2nd transition' + ); + +});