diff --git a/src/components/Graph/GraphEditor.vue b/src/components/Graph/GraphEditor.vue index d634111..5877392 100644 --- a/src/components/Graph/GraphEditor.vue +++ b/src/components/Graph/GraphEditor.vue @@ -228,7 +228,7 @@ import ForceAtlasLayoutSettings from '@/components/Graph/ForceAtlasLayoutSetting import AdvancedForceAtlasLayoutSettings from '@/components/Graph/AdvancedForceAtlasLayoutSettings.vue' import CirclePackLayoutSettings from '@/components/Graph/CirclePackLayoutSettings.vue' import FA2Layout from 'graphology-layout-forceatlas2/worker' -import forceAtlas2 from 'graphology-layout-forceatlas2' +import * as forceAtlas2 from 'graphology-layout-forceatlas2' import RunIcon from '@/components/svg/run.vue' import StopIcon from '@/components/svg/stop.vue' import { downloadAsPNG, drawOnCanvas } from '@sigma/export-image' @@ -508,7 +508,7 @@ export default { if (layoutType === 'random') { random.assign(this.graph, { - rng: seedrandom(this.settings.layout.options.seedValue || 1) + rng: seedrandom(this.settings.layout.options.seedValue) }) return } @@ -541,7 +541,7 @@ export default { this.settings.layout.options.hierarchyAttributes?.map( (_, index) => 'hierarchyAttribute' + index ) || [], - rng: seedrandom(this.settings.layout.options.seedValue || 1) + rng: seedrandom(this.settings.layout.options.seedValue) }) return } @@ -591,10 +591,6 @@ export default { } }, autoRunFA2Layout() { - if (this.fa2Layout.isRunning()) { - this.stopFA2Layout() - } - let iteration = 1 this.checkIteration = () => { if ( @@ -609,7 +605,7 @@ export default { this.fa2Layout.start() }, setRecommendedFA2Settings() { - const sensibleSettings = forceAtlas2.inferSettings(this.graph) + const sensibleSettings = forceAtlas2.default.inferSettings(this.graph) this.settings.layout.options = { initialIterationsAmount: 50, adjustSizes: false, diff --git a/tests/components/Graph/GraphEditor.spec.js b/tests/components/Graph/GraphEditor.spec.js index 0df27ed..4042270 100644 --- a/tests/components/Graph/GraphEditor.spec.js +++ b/tests/components/Graph/GraphEditor.spec.js @@ -1,11 +1,12 @@ import { expect } from 'chai' import { mount } from '@vue/test-utils' import GraphEditor from '@/components/Graph/GraphEditor.vue' -import { nextTick } from 'vue' +import { nextTick, ref } from 'vue' import FA2Layout from 'graphology-layout-forceatlas2/worker' import sinon from 'sinon' import { waitCondition } from '/tests/testUtils' import time from '@/lib/utils/time' +import * as forceAtlas2 from 'graphology-layout-forceatlas2' const defaultInitOptions = { structure: { @@ -1101,10 +1102,10 @@ describe('GraphEditor', () => { props: { dataSources: { doc: [ - '{"type": 0, "node_id": 1, "size": 20}', - '{"type": 0, "node_id": 2, "size": 2}', - '{"type": 0, "node_id": 3, "size": 2}', - '{"type": 0, "node_id": 4, "size": 2}' + '{"type": 0, "node_id": 1, "size": 20, "color": "red"}', + '{"type": 0, "node_id": 2, "size": 2, "color": "blue"}', + '{"type": 0, "node_id": 3, "size": 2, "color": "red"}', + '{"type": 0, "node_id": 4, "size": 2, "color": "green"}' ] }, initOptions: { @@ -1187,14 +1188,42 @@ describe('GraphEditor', () => { .trigger('click') await nextTick() - const circlePackCoordinatesWithHierarchy = graph - .export() - .nodes.map(node => `x:${node.attributes.x},y:${node.attributes.y}`) + let nodes = graph.export().nodes + const circlePackCoordinatesWithHierarchy = nodes + .map(node => `x:${node.attributes.x},y:${node.attributes.y}`) .join() expect(circlePackCoordinatesWithHierarchy).to.not.equal( circlePackCoordinatesNoHierarchy ) + expect(nodes[0].attributes.hierarchyAttribute0).to.equal(20) + expect(nodes[1].attributes.hierarchyAttribute0).to.equal(2) + expect(nodes[2].attributes.hierarchyAttribute0).to.equal(2) + expect(nodes[3].attributes.hierarchyAttribute0).to.equal(2) + + // Set another hierarchy + await wrapper.find('.multiselect__tag-icon').trigger('mousedown') + await nextTick() + await hierarchyInput.trigger('mousedown') + await wrapper + .find('ul.multiselect__content') + .findAll('li')[3] + .find('span') + .trigger('click') + await nextTick() + + nodes = graph.export().nodes + const circlePackCoordinatesWithAnotherHierarchy = nodes + .map(node => `x:${node.attributes.x},y:${node.attributes.y}`) + .join() + + expect(circlePackCoordinatesWithHierarchy).to.not.equal( + circlePackCoordinatesWithAnotherHierarchy + ) + expect(nodes[0].attributes.hierarchyAttribute0).to.equal('red') + expect(nodes[1].attributes.hierarchyAttribute0).to.equal('blue') + expect(nodes[2].attributes.hierarchyAttribute0).to.equal('red') + expect(nodes[3].attributes.hierarchyAttribute0).to.equal('green') wrapper.unmount() }) @@ -1640,4 +1669,206 @@ describe('GraphEditor', () => { wrapper.unmount() }) + + it('calls scheduleRendering when tab becomes visible', async () => { + const tabLayout = ref({ dataView: 'hidden' }) + const wrapper = mount(GraphEditor, { + attachTo: document.body, + props: { + dataSources: { + doc: ['{"type": 0, "node_id": 1}', '{"type": 0, "node_id": 2}'] + }, + initOptions: { + ...defaultInitOptions, + structure: { + nodeId: 'node_id', + objectType: 'type', + edgeSource: 'source', + edgeTarget: 'target' + } + }, + showViewSettings: true + }, + global: { + provide: { + tabLayout + } + } + }) + + sinon.spy(wrapper.vm.renderer, 'scheduleRender') + + tabLayout.value = { dataView: 'above' } + await nextTick() + expect(wrapper.vm.renderer.scheduleRender.calledOnce).to.equal(true) + + tabLayout.value = { dataView: 'hidden' } + await nextTick() + expect(wrapper.vm.renderer.scheduleRender.calledOnce).to.equal(true) + wrapper.unmount() + }) + + it('FA2: stops running and auto run when data changes', async () => { + const stopSpy = sinon.spy(FA2Layout.prototype, 'stop') + const startSpy = sinon.spy(FA2Layout.prototype, 'start') + const wrapper = mount(GraphEditor, { + attachTo: document.body, + props: { + dataSources: { + doc: [ + '{"type": 0, "node_id": 1}', + '{"type": 0, "node_id": 2}', + '{"type": 1, "source": 1, "target": 2}' + ] + }, + initOptions: { + ...defaultInitOptions, + structure: { + nodeId: 'node_id', + objectType: 'type', + edgeSource: 'source', + edgeTarget: 'target' + }, + layout: { + type: 'forceAtlas2', + options: { + initialIterationsAmount: 50, + gravity: 1.5, + scalingRatio: 1.2, + adjustSizes: true, + barnesHutOptimize: true, + barnesHutTheta: 0.5, + strongGravityMode: false, + linLogMode: true, + outboundAttractionDistribution: false, + slowDown: 1, + edgeWeightInfluence: 0 + } + } + }, + showViewSettings: true + }, + global: { + provide: { + tabLayout: { dataView: 'above' } + } + } + }) + + const styleMenuItem = wrapper.findAll('.sidebar__group__title')[1] + await styleMenuItem.trigger('click') + + const layoutMenuItem = wrapper.findAll('.sidebar__item')[4] + await layoutMenuItem.trigger('click') + + expect(startSpy.calledOnce).to.equal(true) + await waitCondition(() => stopSpy.callCount === 1) + + // Click Start + const toggleButton = wrapper.find('button.test_fa2_toggle') + await toggleButton.trigger('click') + expect(startSpy.callCount).to.equal(2) + + await time.sleep(10) + + await wrapper.setProps({ + dataSources: { + doc: [ + '{"type": 0, "node_id": 1}', + '{"type": 0, "node_id": 2}', + '{"type": 0, "node_id": 3}', + '{"type": 1, "source": 1, "target": 2}', + '{"type": 1, "source": 1, "target": 3}' + ] + } + }) + expect(stopSpy.calledTwice).to.equal(true) + expect(startSpy.callCount).to.equal(3) + await waitCondition(() => stopSpy.callCount === 3) + + wrapper.unmount() + }) + + it('FA2: replaces recommended slowdown with 1 if it is Infinity', async () => { + const stopSpy = sinon.spy(FA2Layout.prototype, 'stop') + const startSpy = sinon.spy(FA2Layout.prototype, 'start') + + sinon.stub(forceAtlas2.default, 'inferSettings').returns({ + initialIterationsAmount: 1, + adjustSizes: false, + barnesHutOptimize: false, + barnesHutTheta: 0.9, + edgeWeightInfluence: 0, + gravity: 0.5, + linLogMode: true, + outboundAttractionDistribution: false, + scalingRatio: 1.5, + slowDown: -Infinity, + strongGravityMode: false + }) + const wrapper = mount(GraphEditor, { + attachTo: document.body, + props: { + dataSources: { + doc: [ + '{"type": 0, "node_id": 1, "size": 20}', + '{"type": 0, "node_id": 2, "size": 2}', + '{"type": 0, "node_id": 3, "size": 2}', + '{"type": 0, "node_id": 4, "size": 2}' + ] + }, + initOptions: { + ...defaultInitOptions, + structure: { + nodeId: 'node_id', + objectType: 'type', + edgeSource: 'source', + edgeTarget: 'target' + } + }, + showViewSettings: true + }, + global: { + provide: { + tabLayout: { dataView: 'above' } + } + } + }) + + const graph = wrapper.vm.graph + const initialCoordinates = graph + .export() + .nodes.map(node => `x:${node.attributes.x},y:${node.attributes.y}`) + .join() + + const styleMenuItem = wrapper.findAll('.sidebar__group__title')[1] + await styleMenuItem.trigger('click') + + const layoutMenuItem = wrapper.findAll('.sidebar__item')[4] + await layoutMenuItem.trigger('click') + + // Set FA2 pack layout + await wrapper + .find( + '.test_layout_algorithm_select .dropdown-container .Select__indicator' + ) + .wrapperElement.dispatchEvent( + new MouseEvent('mousedown', { bubbles: true }) + ) + + await wrapper.findAll('.Select__menu .Select__option')[3].trigger('click') + + expect(startSpy.calledOnce).to.equal(true) + await waitCondition(() => stopSpy.callCount === 1) + expect(wrapper.text()).to.contain('Start') + + const coordinates = graph + .export() + .nodes.map(node => `x:${node.attributes.x},y:${node.attributes.y}`) + .join() + + expect(coordinates).not.to.equal(initialCoordinates) + + wrapper.unmount() + }) }) diff --git a/tests/views/MainView/Workspace/Tabs/Tab/DataView/Graph/Graph.spec.js b/tests/views/MainView/Workspace/Tabs/Tab/DataView/Graph/Graph.spec.js index 84e07df..2e3198b 100644 --- a/tests/views/MainView/Workspace/Tabs/Tab/DataView/Graph/Graph.spec.js +++ b/tests/views/MainView/Workspace/Tabs/Tab/DataView/Graph/Graph.spec.js @@ -245,6 +245,113 @@ describe('Graph.vue', () => { wrapper.unmount() }) + it('rerenders when dataSource changes but does not rerender if dataSources is empty', async () => { + const wrapper = mount(Graph, { + attachTo: document.body, + props: { + showViewSettings: true, + dataSources: { + doc: [ + '{"object_type": 0, "node_id": 1}', + '{"object_type": 0, "node_id": 2}', + '{"object_type": 1, "source": 1, "target": 2}' + ] + } + }, + global: { + mocks: { $store }, + provide: { + tabLayout: { dataView: 'above' } + } + } + }) + + const container = + wrapper.find('.graph-container').wrapperElement.parentElement + container.style.height = '400px' + + await wrapper + .find('.test_object_type_select.dropdown-container .Select__indicator') + .wrapperElement.dispatchEvent( + new MouseEvent('mousedown', { bubbles: true }) + ) + + let options = wrapper.findAll('.Select__menu .Select__option') + + await options[0].trigger('click') + + await wrapper + .find('.test_node_id_select.dropdown-container .Select__indicator') + .wrapperElement.dispatchEvent( + new MouseEvent('mousedown', { bubbles: true }) + ) + + options = wrapper.findAll('.Select__menu .Select__option') + await options[1].trigger('click') + + await wrapper + .find('.test_edge_source_select.dropdown-container .Select__indicator') + .wrapperElement.dispatchEvent( + new MouseEvent('mousedown', { bubbles: true }) + ) + + options = wrapper.findAll('.Select__menu .Select__option') + await options[2].trigger('click') + + await wrapper + .find('.test_edge_target_select.dropdown-container .Select__indicator') + .wrapperElement.dispatchEvent( + new MouseEvent('mousedown', { bubbles: true }) + ) + + options = wrapper.findAll('.Select__menu .Select__option') + await options[3].trigger('click') + + const nodeCanvasPixelsBefore = getPixels( + wrapper.find('.test_graph_output canvas.sigma-nodes').wrapperElement + ) + const edgeCanvasPixelsBefore = getPixels( + wrapper.find('.test_graph_output canvas.sigma-edges').wrapperElement + ) + await wrapper.setProps({ + dataSources: { + doc: [ + '{"object_type": 0, "node_id": 1}', + '{"object_type": 0, "node_id": 2}', + '{"object_type": 0, "node_id": 3}', + '{"object_type": 1, "source": 1, "target": 2}', + '{"object_type": 1, "source": 1, "target": 3}' + ] + } + }) + + const nodeCanvasPixelsAfter = getPixels( + wrapper.find('.test_graph_output canvas.sigma-nodes').wrapperElement + ) + const edgeCanvasPixelsAfter = getPixels( + wrapper.find('.test_graph_output canvas.sigma-edges').wrapperElement + ) + + expect(nodeCanvasPixelsBefore).not.equal(nodeCanvasPixelsAfter) + expect(edgeCanvasPixelsBefore).not.equal(edgeCanvasPixelsAfter) + + await wrapper.setProps({ + dataSources: null + }) + + const nodeCanvasPixelsAfterEmtyData = getPixels( + wrapper.find('.test_graph_output canvas.sigma-nodes').wrapperElement + ) + const edgeCanvasPixelsAfterEmtyData = getPixels( + wrapper.find('.test_graph_output canvas.sigma-edges').wrapperElement + ) + + expect(nodeCanvasPixelsAfterEmtyData).equal(nodeCanvasPixelsAfter) + expect(edgeCanvasPixelsAfterEmtyData).equal(edgeCanvasPixelsAfter) + + wrapper.unmount() + }) + it('saveAsPng', async () => { const wrapper = mount(Graph, { props: {