diff --git a/changelog.md b/changelog.md index 720adfc1..f95bb730 100644 --- a/changelog.md +++ b/changelog.md @@ -23,6 +23,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `getDefinedAttributesByType` will now return nested attributes inside object, fix [this bug](https://github.com/ditrit/leto-modelizer-plugin-core/issues/203). +- Add `arrangeComponentsPosition` method in `DefaultDrawer` and `DefaultPlugin` +- Add inheritable class `DefaultLayout` for automatic layout in the diagram (does nothing) +- Add inherited class `ElkLayout` for automatic layout in the diagram, using ELK. +- Add HTML attribute equality as a Cypress step definition in `html.js` +- Add `elkjs` and `web-worker` as package dependencies + ## [0.18.0] - 2023/07/20 ### Added diff --git a/cypress/e2e/automatic-layout.feature b/cypress/e2e/automatic-layout.feature index b80fbfff..f030c431 100644 --- a/cypress/e2e/automatic-layout.feature +++ b/cypress/e2e/automatic-layout.feature @@ -16,9 +16,6 @@ Feature: Test automatic layout for the graph And I expect "#wfstep2" to be at position 30,110 And I expect "#wfstep3" to be at position 30,190 And I expect "#wfstep4" to be at position 30,270 - And I expect "#wfstep5" to be at position 30,30 - And I expect "#wfstep6" to be at position 302,30 And I expect "#wfstep7" to be at position 574,30 - And I expect "#wfstep8" to be at position 846,30 And I expect "#server1" to be at position 12,12 And I expect "#server2" to be at position 12,112 diff --git a/cypress/support/step_definitions/html.js b/cypress/support/step_definitions/html.js index e4cdc05d..c8b71cc3 100644 --- a/cypress/support/step_definitions/html.js +++ b/cypress/support/step_definitions/html.js @@ -103,6 +103,7 @@ Then('I expect {string} to be at position {int},{int}', (templateSelector, x, y) expect(Math.trunc(parseInt(element.attr('x')))).eq(x); expect(Math.trunc(parseInt(element.attr('y')))).eq(y); }); + }); Then('I expect {string} width is {int}', (templateSelector, width) => { diff --git a/demo/src/App.vue b/demo/src/App.vue index a7808cdf..f351a167 100644 --- a/demo/src/App.vue +++ b/demo/src/App.vue @@ -48,7 +48,7 @@ -
+
@@ -82,17 +82,17 @@ function savePosition() { async function automaticLayout() { await plugin.arrangeComponentsPosition(); - plugin.draw('root', readOnly.value); + plugin.draw('view-port', readOnly.value); } function reset() { - document.querySelector('#root').innerHTML = ''; - plugin.draw('root', readOnly.value); + document.querySelector('#view-port').innerHTML = ''; + plugin.draw(); } function renameComponent() { plugin.data.renameComponentId(selectedId.value, renamedId.value); - plugin.draw('root', readOnly.value); + plugin.draw('view-port', readOnly.value); updateComponentsIds(); selectedId.value = ''; renamedId.value = ''; @@ -108,10 +108,16 @@ const defaultConfiguration = JSON.stringify({ demo: { internal1: new ComponentDrawOption({ x: 42, - y: 666, + y: 550, width: 242, - height: 50, + height: 200, }), + network1: new ComponentDrawOption({ + x: 400, + y: 150, + width: 250, + height: 312, + }) }, }, }); @@ -125,8 +131,8 @@ onMounted(() => { path: 'localstorage', content: window.localStorage.getItem('configuration') || defaultConfiguration, })); - plugin.draw('root'); - updateComponentsIds(); + plugin.initDrawingContext(); + plugin.draw(); }); @@ -158,19 +164,12 @@ main { align-items: center; } -#viewport { - -webkit-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - user-select: none; -} - .disabled{ opacity: 0.5; cursor: not-allowed; } -#root { +#view-port { width: 100%; height: 100%; diff --git a/demo/src/assets/resources.js b/demo/src/assets/resources.js index 72ac6d1b..257d711b 100644 --- a/demo/src/assets/resources.js +++ b/demo/src/assets/resources.js @@ -6,85 +6,86 @@ export default { }, models: { DefaultModel: ` - - + + + + + + {{ icon | safe }} + + {% if hasError %} - - - - + + + + {% endif %} - - - {{ name }} - {{ definition.type }} - id: {{ id }} + + {{ name }} + {{ definition.type }} - id: {{ id }} - - - - - `, DefaultContainer: ` - - - {% if hasError %} - - - - - + - - + + {% if hasWidth and hasHeight and drawOption.innerWidth > 0 and drawOption.innerHeight > 0 %} + width="{{ drawOption.innerWidth + 12 + padding * 2 }}" + height="{{ drawOption.innerHeight + 56 + padding * 2 }}" + {% else %} + width="254" height="118" + {% endif %} + > + + + {% if hasError %} + + + - - - {{ name }} - {{ definition.type }} - id: {{ id }} + {% endif %} + + + + {{ icon | safe }} + + + + {{ name }} + {{ definition.type }} - id: {{ id }} - - - - - - - + + 0 and drawOption.innerHeight > 0 %} + width="{{ drawOption.innerWidth + padding * 2 }}" + height="{{ drawOption.innerHeight + padding * 2 }}" + {% else %} + width={{ 254 - 12 }} height={{ 68 - 6 }} + {% endif %} + /> + + `, }, diff --git a/src/draw/DefaultDrawer.js b/src/draw/DefaultDrawer.js index ee370bb8..cc0dea08 100644 --- a/src/draw/DefaultDrawer.js +++ b/src/draw/DefaultDrawer.js @@ -1,9 +1,6 @@ import * as d3 from 'd3'; -import nunjucks from 'nunjucks'; -import ElkLayout from './ElkLayout'; -import ComponentDrawOption from '../models/ComponentDrawOption'; -import actionIcons from '../assets/actions/actionIcons'; -import ComponentLink from '../models/ComponentLink'; +import ElkLayout from './layout/ElkLayout'; +import ComponentRenderer from './render/ComponentRenderer'; /** * Class that draws a component in a graphical representation. @@ -13,1724 +10,189 @@ class DefaultDrawer { * Default constructor * @param {DefaultData} pluginData - Plugin data storage. * @param {object} [resources] - Object that contains resources. - * @param {string} [rootId] - Id of HTML element where we want to draw. - * @param {object} [options] - Rendering options. - * @param {number} [options.minWidth] - Minimum width of a component. - * @param {number} [options.minHeight] - Minimum height of a component. - * @param {number} [options.padding] - Padding around a component. - * @param {number} [options.margin] - Component margin thickness. - * @param {number[]} [options.lineLengthPerDepth] - Number of components - * per line at a given depth. Valid values: 1 - Infinity. - * @param {number} [options.actionMenuButtonSize] - The size of each action menu button. - * @param {DefaultLayout} [layout] - The manager for an automatic diagram layout. + * @param {string} [viewPortId] - Id of HTML element where we want to draw. + * @param option */ - constructor(pluginData, resources = null, rootId = 'root', options = {}, layout = null) { + constructor(pluginData, resources = null, viewPortId = 'view-port', option = { + padding: 10, + gap: 50, + }) { /** * Plugin data storage. * @type {DefaultData} */ this.pluginData = pluginData; + + /** + * Component renderer. + */ + this.componentRenderer = new ComponentRenderer({ padding: option.padding }); + /** * Plugin layout system. * @type {DefaultLayout} * @default new ElkLayout() */ - this.layout = layout ?? new ElkLayout(this.pluginData); - /** - * Id of HTML element where we want to draw. - * @type {string} - * @default 'root' - */ - this.rootId = rootId || 'root'; + this.layout = new ElkLayout( + this.pluginData, + { componentRenderer: this.componentRenderer }, + { + 'elk.padding': `[ + left=${option.padding}, + top=${option.padding}, + right=${option.padding}, + bottom=${option.padding} + ]`, + 'elk.layered.spacing.baseValue': option.gap, + }, + ); + /** * Object that contains resources. * @type {object} + * @default null */ this.resources = resources; + /** - * Minimum width of a component. - * @type {number} - * @default 230 - */ - this.minWidth = options.minWidth !== undefined ? options.minWidth : 230; - /** - * Minimum height of a component. - * @type {number} - * @default 50 - */ - this.minHeight = options.minHeight !== undefined ? options.minHeight : 50; - /** - * Padding around components. - * @type {number} - * @default 30 - */ - this.padding = options.padding !== undefined ? options.padding : 30; - /** - * Component margin thickness. - * @type {number} - * @default 6 - */ - this.margin = options.margin !== undefined ? options.margin : 6; - /** - * Number of components per line at a given depth. Valid values: 1 - Infinity. - * @type {number[]} - * @default [5, 1] + * Id of HTML element where we want to draw. + * @type {string} + * @default 'view-port' */ - this.lineLengthPerDepth = options.lineLengthPerDepth !== undefined - ? options.lineLengthPerDepth : [5, 1]; + this.viewPortId = viewPortId; + /** - * The size of each action menu button. - * @type {number} - * @default 24 + * D3 selection of the view port. + * @type {Selection} */ - this.actionMenuButtonSize = options.actionMenuButtonSize || 24; + this.viewPort = null; + /** - * Store for actions, used to set specific actions values when making actions. - * @type {object} + * D3 selection of the root. + * @type {Selection} */ - this.actions = { - selection: { - current: null, - style: '2px solid hsl(205, 100%, 50%)', - offset: '3px', - }, - linkCreation: { - source: null, - target: null, - creating: false, - }, - drag: { - offsetX: 0, - offsetY: 0, - state: false, - target: null, - }, - zoom: { - scale: 1, - translate: { - x: 0, - y: 0, - }, - }, - }; - } - - /** - * Convert screen coordinates into a given svg referential. - * @param {number} screenX - Screen x coordinate. - * @param {number} screenY - Screen y coordinate. - * @param {SVGSVGElement} [svg] - SVG referential. - * @returns {DOMPoint} The transformed coordinates. - */ - screenToSVG(screenX, screenY, svg = null) { - const localSvg = svg || this.svg.node(); - const pivotPoint = new DOMPoint(screenX, screenY); - - return pivotPoint.matrixTransform(localSvg.getScreenCTM().inverse()); - } - - /** - * Convert svg coordinates into screen coordinates. - * @param {number} svgX - SVG x coordinate. - * @param {number} svgY - SVG y coordinate. - * @param {SVGSVGElement} [svg] - SVG referential. - * @returns {DOMPoint} The transformed coordinates. - */ - SVGToScreen(svgX, svgY, svg = null) { - const localSvg = svg || this.svg.node(); - const pivotPoint = new DOMPoint(svgX, svgY); - - return pivotPoint.matrixTransform(localSvg.getScreenCTM()); - } - - /** - * Compute a coefficient representing how tall a component will be based on its children's layout. - * @param {Node} item - The component to check. - * @returns {number} The coefficient. - * @private - */ - __getVerticalCoefficient(item) { - const lineLength = this.getLineLengthForDepth( - item.depth, - item.parent?.data?.definition?.childrenPerLine, - ); - - if (item.children?.length > 0) { - const childHeights = Math.ceil( - item.children - .filter((child) => child?.data?.definition?.isContainer) - .reduce( - (acc, child) => acc + this.__getVerticalCoefficient(child), - 0, - ), - ); - const localChildValue = item.children - .filter((child) => !(child.data?.definition?.isContainer)) - .reduce((acc, child) => acc + child.value, 0); - - return localChildValue - / lineLength - + childHeights - + (item.data?.definition?.isContainer ? 1 : 0); - } - - return (lineLength === Infinity ? 1 : item.value - / lineLength) - + (item.data?.definition?.isContainer ? 1 : 0); - } - - /** - * Get the maximum line length for a given depth. - * @param {number} depth - The depth to check. - * @param {boolean} [lineLengthOverride] - Override if parent is tagged as a workflow - * @returns {number} The maximum length at that depth. - */ - getLineLengthForDepth(depth, lineLengthOverride = null) { - return lineLengthOverride - || this.lineLengthPerDepth[Math.min(depth, this.lineLengthPerDepth.length - 1)]; + this.root = null; } /** - * Apply the disabled style to all elements matching the selector. - * @param {string} [selector] - CSS selector string. + * Initialize drawing context. + * @public */ - setDisabledStyle(selector = '.component') { - const localSelector = `#${this.rootId} ${selector || '.component'}`; + initDrawingContext() { + this.viewPort = d3.select(`#${this.viewPortId}`); + this.root = this.viewPort + .selectAll('#root') + .data(this.__formatComponentDataset()) + .join('svg') + .attr('id', 'root') + .attr('width', '100%') + .attr('height', '100%') + .attr('overflow', 'visible'); - d3.selectAll(localSelector) - .classed('disabled', true); - } + const componentDrawingContext = this.root.append('g').attr('class', 'components'); - /** - * Remove the disabled style from previously disabled components. - */ - unsetAllDisabledStyles() { - d3.selectAll(`#${this.rootId} .disabled`) - .classed('disabled', false); + this.root.append('g').attr('class', 'links'); + this.componentRenderer.context = componentDrawingContext; + this.componentRenderer.resources = this.resources; } - /** - * Handles dragging a component across the screen and return the element it will be dropped on. - * @param {Element} draggedElement - The DOM element being dragged. - * @param {DragEvent} event - The emitted drag event. - * @returns {Element} The element to drop the dragged element onto. - */ - dragHandler(draggedElement, event) { - this.hideActionMenu(); - - const dropTarget = document - .elementsFromPoint(event.sourceEvent.x, event.sourceEvent.y) - .find((element) => event.subject.data.id !== element.dataset.parentId && ( - element.classList.contains('container-background') - || element.classList.contains('container') - )); - - const target = d3.select(`#${event.subject.data.id}`).attr('cursor', 'grabbing'); - - d3.select('#root-components') - .append(() => target.node()); - - const rootSVGPoint = this.screenToSVG( - event.sourceEvent.clientX, - event.sourceEvent.clientY, - this.svg.select('.container').node(), - ); - - d3.select(draggedElement) - .attr( - 'transform', - event.subject.transform = `translate(${rootSVGPoint.x - this.actions.drag.offsetX}, - ${rootSVGPoint.y - this.actions.drag.offsetY})`, - ); - - event.subject.x = rootSVGPoint.x - this.actions.drag.offsetX; - event.subject.y = rootSVGPoint.y - this.actions.drag.offsetY; - if (event.subject.data.definition) { - const forbiddenTypes = event.subject.data.definition.parentTypes - .map((type) => `:not(.${type})`) - .join(''); + groupNodesByDepth() { + const nodes = d3.selectAll('.component'); + const maxDepth = d3.max(nodes.data(), (d) => d.depth); + const groupedNodes = []; - this.setDisabledStyle(`.component:not(#${event.subject.data.id})${forbiddenTypes}`); + for (let i = maxDepth - 1; i > 0; i -= 1) { + groupedNodes.push(nodes.filter((d) => d.depth === i)); } - this.drawLinks(); - - return dropTarget; + return groupedNodes; } /** - * Create and return d3 drag behaviour. - * @returns {Function} D3 drag behaviour. + * Draw. + * @public */ - setupDragBehavior() { - let dropTarget = null; - let itemWasDragged = false; - const dragHandler = this.dragHandler.bind(this); - - return d3.drag() - .subject((event) => { - const target = document - .elementsFromPoint(event.sourceEvent.x, event.sourceEvent.y) - .find((element) => element.classList.contains('component-hitbox')); - const targetData = d3.select(target); - - return targetData.datum(); - }) - .on('start', (event) => { - this.actions.drag.offsetX = event.x - event.subject.x0; - this.actions.drag.offsetY = event.y - event.subject.y0; - }) - .on('drag', function dragged(event) { - dropTarget = dragHandler(this, event); - itemWasDragged = true; - }) - .on('end', (event) => { - if (itemWasDragged) { - this.handleDropEvent(event, dropTarget); - } - }); + draw() { + this.__drawingComponents(); + this.registerComponentsDrawOption(); } /** - * Starting from a given node, recursively mark all parent nodes as needing a resize. - * @param {Node} node - The node to start from. + * Drawing components. * @private */ - __markAsNeedingResize(node) { - if (node?.data.drawOption) { - node.data.drawOption.needsResizing = true; - } - if (node.parent) { - this.__markAsNeedingResize(node.parent); - } + __drawingComponents() { + this.componentRenderer.render(); } /** - * Update component hierarchy and re-render. - * @param {DragEvent} event - D3's drag event. - * @param {Element} dropTarget - The element on which the dragged component was dropped. - */ - handleDropEvent(event, dropTarget) { - const origParent = this.pluginData.getComponentById(event.subject.parent.data.id); - const target = dropTarget ? d3.select(dropTarget) : null; - - if (target === origParent - || (origParent?.id === target?.datum().data?.id - && !origParent?.definition?.preventChildrenMovement)) { - const { x, y } = event; - - event.subject.data.drawOption.x = x - this.actions.drag.offsetX; - event.subject.data.drawOption.y = y - this.actions.drag.offsetY; - - this.pluginData.emitEvent({ - type: 'Drawer', - action: 'move', - status: 'success', - components: [event.subject.data.id], - }); - } else { - if (event.subject.parent) { - this.__markAsNeedingResize(event.subject.parent); - } - event.subject.data.drawOption = null; - - if (target) { - this.changeParent(target, event); - } else { - event.subject.data.removeAllReferenceAttributes(); - - this.pluginData.emitEvent({ - type: 'Drawer', - action: 'update', - status: 'success', - components: [event.subject.data.id], - }); - } - } - - this.draw(this.rootId); - } - - /** - * Change the event subject's parent to the target component. - * @param {Selection} target - Where the dragged element was dropped. - * @param {DragEvent} event - D3's drag event. + * Format component dataset to d3 hierarchy. + * @returns {object} - Formated component dataset. + * @private */ - changeParent(target, event) { - const parentId = target.attr('data-parentId'); - const newParent = this.pluginData.getComponentById(parentId); - const newParentNode = d3.select(`#${parentId}`).datum(); - const isValid = newParent.definition.childrenTypes.includes(event.subject.data.definition.type); - - if (isValid) { - event.subject.data.setReferenceAttribute(newParent); - this.__markAsNeedingResize(newParentNode); - - if (newParent?.definition?.displayType === 'workflow') { - const newInboundComponent = this.findInsertionPosition(newParentNode, event); + __formatComponentDataset() { + const fromRootComponentDataset = { + id: 'root', + name: '', + children: this.pluginData.components, + }; - if (newInboundComponent) { - this.pluginData - .insertComponentAfter( - event.subject.data.id, - newInboundComponent.data?.id, - ); - } else if (newParentNode.children?.length > 0) { - this.pluginData - .insertComponentBefore( - event.subject.data.id, - newParentNode.children[0].data?.id, - ); - } + const formatedComponentDataset = d3.hierarchy(fromRootComponentDataset, (data) => { + if (data.id === 'root') { + return data.children; } - } - this.pluginData.emitEvent({ - type: 'Drawer', - action: isValid ? 'update' : 'move', - status: 'success', - components: [event.subject.data.id], + return this.pluginData.getChildren(data.id); }); - } - /** - * Find after which component the dragged component should be placed in a container. - * @param {Node} parentNode - The destination container. - * @param {DragEvent} event - The drag event. - * @returns {Component} - The component that will be directly before the dropped component. - */ - findInsertionPosition(parentNode, event) { - const xDelta = parentNode.x0 - event.subject.parent.x0; - const yDelta = parentNode.y0 - event.subject.parent.y0; - const adjustedEventX = event.x - xDelta; - const adjustedEventY = event.y - yDelta; - - if (!parentNode.children) { - return null; - } - - const sameLineComponents = parentNode.children - .filter((component) => component.data?.id !== event.subject?.data?.id) - .filter((component) => component.y0 <= adjustedEventY - && component.y1 >= adjustedEventY); - - if (sameLineComponents.length > 0) { - const bracketingComponents = sameLineComponents.reduce((targetInfo, component) => { - const distance = adjustedEventX - component.x1; - - if (distance > 0 && distance < targetInfo.distanceLeft) { - targetInfo.distanceLeft = distance; - targetInfo.componentLeft = component; - } else if (distance <= 0 && Math.abs(distance) < targetInfo.distanceRight) { - targetInfo.distanceRight = Math.abs(distance); - targetInfo.componentRight = component; - } - - return targetInfo; - }, { - distanceLeft: Infinity, - distanceRight: Infinity, - componentLeft: null, - componentRight: null, - }); - - this.__fillMissingBracket(parentNode, bracketingComponents, event.subject); - - return this.__isInverted( - parentNode, - bracketingComponents.componentLeft, - bracketingComponents.componentRight, - ) - ? bracketingComponents.componentRight - : bracketingComponents.componentLeft; - } - const { component: returnComponent } = parentNode.children - .reduce((targetInfo, component) => { - const distance = adjustedEventY - component.y1; - - if (distance > 0 && distance <= targetInfo.distance) { - targetInfo = { distance, component }; - } - - return targetInfo; - }, { distance: Infinity, component: null }); - - return returnComponent; - } - - /** - * Fill left bracket if missing due to vertical layout. - * @param {Node} parentNode - The parent node - * @param {object} bracketingComponents - the components we want to drop the subject between. - * @param {Node} subject - The component being dropped. - * @private - */ - __fillMissingBracket(parentNode, bracketingComponents, subject) { - if (parentNode.children?.length > 1 - && !bracketingComponents.componentLeft - && bracketingComponents.componentRight) { - const subjectIndex = parentNode.children - .findIndex((component) => component.data?.id === subject?.data?.id); - const rightIndex = parentNode.children - .findIndex( - (component) => component.data?.id === bracketingComponents.componentRight.data?.id, - ); - const newLeftIndex = subjectIndex === rightIndex - 1 ? rightIndex - 2 : rightIndex - 1; + formatedComponentDataset.children = formatedComponentDataset.children + .filter((component) => ( + this.__discriminateComponentsWithouthParent().includes(component.data.id) + )); - if (newLeftIndex >= 0) { - bracketingComponents.componentLeft = parentNode.children[newLeftIndex]; - } - } + return [formatedComponentDataset]; } /** - * Check if two components are being rendered right to left. - * @param {Node} parentNode - The parent component - * @param {Node} componentLeft - The left hand component - * @param {Node} componentRight - the right hand component - * @returns {boolean} - true if the right hand component has a lower index than the left hand one + * Get components without parent. + * @returns {Component.id[]} - Components without parent. * @private */ - __isInverted( - parentNode, - componentLeft, - componentRight, - ) { - const leftIndex = parentNode.children - .findIndex( - (component) => component.data.id === componentLeft?.data?.id, - ); - const rightIndex = parentNode.children - .findIndex( - (component) => component.data.id === componentRight?.data?.id, - ); - - return !!((leftIndex === -1 && rightIndex === (parentNode.children.length - 1)) - || (leftIndex >= 0 && rightIndex >= 0 && leftIndex > rightIndex)); - } - - /** - * Create a new svg to render the models in, or fetch an existing one. - */ - createRenderingContext() { - const contextIsPresent = !d3.select(`#${this.rootId}>svg`).empty(); - - if (!contextIsPresent) { - this.svg = d3.select(`#${this.rootId}`) - .append('svg') - .attr('preserveAspectRatio', 'xMinYMin meet') - .style('font', '10px sans-serif') - .attr('height', '100%') - .attr('width', '100%'); - this.svg.append('g') - .attr('class', 'container'); - this.svg.append('defs'); - this.__initializeArrowMarker(); - } else { - this.svg = d3.select(`#${this.rootId}`) - .select('svg'); - } - } + __discriminateComponentsWithouthParent() { + let componentWithParent = []; - /** - * Draws all Components and ComponentLinks in the parentId Element. - * @param {string} rootId - Id of the container where you want to draw. - * @param {boolean} readOnly - Make the draw read-only. - */ - draw(rootId, readOnly) { - const id = this.pluginData.emitEvent({ - type: 'Drawer', - action: 'write', - status: 'running', - data: { - rootId, - }, + this.pluginData.components.forEach((component) => { + componentWithParent = componentWithParent.concat(this.pluginData.getChildren(component.id)); }); - this.rootId = rootId; - this.createRenderingContext(); - - this.__unselectComponent(); - - this.drawComponents(readOnly); - - this.drawLinks(readOnly); - - this.setViewPortAction(readOnly); - - d3.select('body') - .on('keyup', (event) => { - const currentSelection = this.actions.selection.current; - - if (event.key === 'Delete' && currentSelection) { - if (currentSelection.__class === 'Component') { - this.removeComponentHandler(); - } else if (currentSelection.__class === 'Link') { - this.removeLinkHandler(); - } - } - }); - - if (readOnly) { - const { - width, - height, - x, - y, - } = document.querySelector(`#${this.rootId} svg`).getBBox(); - - d3.select(`#${this.rootId} svg`).attr('viewBox', `${x} ${y} ${width} ${height}`); - } - - this.pluginData.emitEvent({ id, status: 'success' }); + return this.pluginData.components + .filter((component) => !componentWithParent.includes(component)) + .map((component) => component.id); } /** - * Handle component click event. Set selected style on it. - * @param {PointerEvent} event - The click event. + * Get node position. + * @param {Component.id} componentId - Component Id. + * @returns {object} - Node position. */ - clickHandler(event) { - event.stopPropagation(); - this.__selectComponent(d3.select(event.currentTarget)); - } - - /** - * Render components in model view. - * @param {boolean} readOnly - Draw read-only components. - */ - drawComponents(readOnly) { - this.shadowRoot = { children: this.pluginData.components, id: '__shadowRoot', name: '' }; - const groupedNodes = this.buildTree(); - const clicked = this.clickHandler.bind(this); - const drag = this.setupDragBehavior(); - const node = this.svg - .select('.container') - .selectAll('g') - .data(groupedNodes, (data) => data) - .join('g') - .attr('id', ([data]) => data) - .selectAll('g') - .data(([, data]) => data) - .join('g') - .attr('id', ({ data }) => data.id) - .on('click', readOnly ? null : clicked) - .call(readOnly ? () => {} : drag) - .attr('x', ({ x0 }) => x0) - .attr('y', ({ y0 }) => y0) - .attr('transform', ({ x0, y0 }) => `translate(${x0},${y0})`); - - node - .filter(({ data }) => data.id !== '__shadowRoot') - .attr('class', ({ data }) => `component - component-${data.definition.model} - ${data.definition.type}`) - .html(({ data }) => nunjucks.renderString( - this.resources.models[data.definition.model], - { - ...data, - hasError: data.hasError(), - getAttribute: (name) => data.attributes.find((attribute) => attribute.name === name), - }, - )) - .select('svg') - .attr('id', ({ data }) => `svg-${data.id}`) - .attr('height', (component) => { - const { manuallyResized, height } = component.data.drawOption; - - return manuallyResized ? height : this.getComponentHeight(component); - }) - .attr('width', (component) => { - const { manuallyResized, width } = component.data.drawOption; - - return manuallyResized ? width : this.getComponentWidth(component); - }); - - node.select('.component-icon') - .html(({ data }) => this.resources.icons[data.definition.icon]); - - node.select('rect') - .filter((d) => d.data?.definition?.isContainer) - .attr('height', (component) => { - const { manuallyResized, height } = component.data.drawOption; - - return manuallyResized ? height : this.getComponentHeight(component); - }) - .attr('width', (component) => { - const { manuallyResized, width } = component.data.drawOption; - - return manuallyResized ? width : this.getComponentWidth(component); - }); - - node.select('.component-container') - .attr('height', (component) => { - const { manuallyResized, height } = component.data.drawOption; - - return (manuallyResized ? height : this.getComponentHeight(component)) - - this.minHeight - this.margin; - }) - .attr('width', (component) => { - const { manuallyResized, width } = component.data.drawOption; - - return (manuallyResized ? width : this.getComponentWidth(component)) - 2 * this.margin; - }) - .attr('x', () => this.margin) - .filter(({ children }) => children) - .append(({ data }) => d3.select(`#group-${data.id}`).node()); + getNodePosition(componentId) { + const component = this.pluginData.components.find((data) => data.id === componentId); - node.select('.component-container>rect').attr('data-parentId', ({ data }) => data.id); - } - - /** - * Initialize component height and width then store them in its drawOptions. - * @param {Node} component - The component to initialize the values for. - */ - initializeComponentDrawOptions(component) { - /* - component.depth and component.height are set by d3 and represent the position of the node in - the hierarchy: - - height: how many layers exist below this node - - depth: how deep in the tree the node is - */ - const horizontalCoefficient = Math.min( - component.value, - this.getLineLengthForDepth(component.depth, component.data.definition?.childrenPerLine), - ); - const verticalCoefficient = Math.ceil(this.__getVerticalCoefficient(component)); - - const width = (horizontalCoefficient * (this.minWidth + 2 * this.margin)) - + (component.height * 2 * this.padding) - + (horizontalCoefficient - 1) - * (this.padding + 2 * this.margin); - - const height = (verticalCoefficient * this.minHeight) - + (component.height * this.padding) - + (verticalCoefficient - 1) - * (this.padding + this.margin); - - if (!component.data.drawOption || component.parent?.data?.definition?.preventChildrenMovement) { - component.data.drawOption = new ComponentDrawOption({ - needsPositioning: true, - width, - height, - }); - } else if (!component.data.drawOption.manuallyResized) { - component.data.drawOption.width = width; - component.data.drawOption.height = height; - } - } - - /** - * Build d3 hierarchy and treemap layout. - * @returns {Array} The nodes grouped by parent. - */ - buildTree() { - const treemapLayout = d3.treemap() - .size([this.width, this.height]) - .tile((data) => { - const newComponents = data - .children - .filter((child) => !child.data.drawOption - || data.data?.definition?.preventChildrenMovement); - const existingComponents = data - .children - .filter((child) => child.data.drawOption - && !(data.data?.definition?.preventChildrenMovement)); - - newComponents.forEach((component) => this.initializeComponentDrawOptions(component)); - - const lines = this.__buildLines(existingComponents.concat(newComponents), data.depth); - - this.setupTiles(lines.map((line) => { - line.items = line.items.filter((item) => item); - - return line; - }), data.data?.definition?.displayType === 'workflow'); - // TODO save/load coordinates - }) - .round(true); - const rootNode = d3.hierarchy( - this.shadowRoot, - ({ id }) => this.pluginData.getChildren(id === '__shadowRoot' ? null : id), - ); - - rootNode - .count(); - - treemapLayout(rootNode); - - return d3.groups( - rootNode, - ({ parent }) => (parent - && parent.data.id !== '__shadowRoot' - ? `group-${parent.data.id}` - : 'root-components'), - ).filter(([data]) => data !== 'root-__shadowRoot'); - } - - /** - * Get the most appropriate anchor point for a link towards the given target. - * @param {Selection} sourceSelection - The source D3 selection object. - * @param {Selection} targetSelection - The target D3 selection object. - * @returns {number[] | null} - Tuple representing x,y coordinates, - * null if lacking source and/or target. Format required by d3. - */ - getAnchorPoint(sourceSelection, targetSelection) { - if (sourceSelection.empty() || targetSelection.empty()) { - return null; - } - - const sourceCoords = sourceSelection.node().getBoundingClientRect(); - const sourceCenter = this.getSelectionCenter(sourceSelection); - const targetCenter = this.getSelectionCenter(targetSelection); - - const angle = this.getBearing( - this.screenToSVG(sourceCenter.x, sourceCenter.y, this.svg.select('.container').node()), - this.screenToSVG(targetCenter.x, targetCenter.y, this.svg.select('.container').node()), - ); - - const topAnchor = { - y: sourceCoords.top, - x: sourceCoords.x + (sourceCoords.width / 2), - }; - const bottomAnchor = { - y: sourceCoords.bottom, - x: sourceCoords.x + (sourceCoords.width / 2), - }; - const leftAnchor = { - x: sourceCoords.left, - y: sourceCoords.top + (sourceCoords.height / 2), - }; - const rightAnchor = { - x: sourceCoords.right, - y: sourceCoords.top + (sourceCoords.height / 2), + return { + x: component.drawOption.x, + y: component.drawOption.y, }; - let anchorPoint; - - if (angle < 45 || angle >= 315) { - anchorPoint = bottomAnchor; - } else if (angle >= 45 && angle < 135) { - anchorPoint = rightAnchor; - } else if (angle >= 135 && angle < 225) { - anchorPoint = topAnchor; - } else { - anchorPoint = leftAnchor; - } - - const { x, y } = this.screenToSVG(anchorPoint.x, anchorPoint.y); - - return [x, y]; - } - - /** - * Initialize arrow marker for links. - * @private - */ - __initializeArrowMarker() { - const definitions = this.pluginData.getUsedLinkDefinitions(); - const arrows = this.svg.select('defs').selectAll('arrow') - .data( - definitions, - (data) => `${data.attributeRef}-${data.sourceRef}-${data.targetRef}`, - ) - .join('marker') - .attr('class', 'arrow'); - - arrows - .attr('id', (data) => `${data.attributeRef}-${data.sourceRef}-${data.targetRef}-arrow`) - .attr('refX', (data) => data.marker.refX) - .attr('refY', (data) => data.marker.refY) - .attr('markerWidth', (data) => data.marker.width) - .attr('markerHeight', (data) => data.marker.height) - .attr('orient', (data) => data.marker.orient) - .append('path') - .attr('d', (data) => data.marker.path) - .attr('fill', (data) => data.color); } - /** - * Render links in model view. - * @param {boolean} readOnly - Draw read-only links. - */ - drawLinks(readOnly) { - const pluginLinks = this.pluginData.getLinks(); - - if (!pluginLinks) { - return; - } - - const links = this.svg - .selectAll('.link'); - - links.data(pluginLinks, (data) => data) - .join('path') - .filter(({ source, target }) => !d3.select(`#${source}`).empty() - && !d3.select(`#${target}`).empty()) - .classed('link', true) - .attr('d', (link) => { - const generator = this.getLinkGenerator(link); - - return generator(link); - }) - .attr('id', ({ definition, source, target }) => ( - `link-${definition.sourceRef}-${definition.attributeRef}-${source}-${target}` - )) - .attr('fill', 'none') - .attr('stroke', (link) => link.definition.color) - .attr('stroke-width', (link) => link.definition.width * this.actions.zoom.scale) - .attr('stroke-dasharray', (link) => ( - !link.definition.dashStyle - ? 'none' - : link.definition.dashStyle.map((value) => value * this.actions.zoom.scale) - )) - .attr('marker-start', (data) => { - const { attributeRef, sourceRef, targetRef } = data.definition; - - return data.definition.type === 'Reverse' - ? `url(#${attributeRef}-${sourceRef}-${targetRef}-arrow)` - : 'none'; - }) - .attr('marker-end', (data) => { - const { attributeRef, sourceRef, targetRef } = data.definition; - - return data.definition.type !== 'Reverse' - ? `url(#${attributeRef}-${sourceRef}-${targetRef}-arrow)` - : 'none'; - }) - .attr('cursor', readOnly ? 'default' : 'pointer') - .on('click', (event) => (readOnly ? null : this.clickHandler(event))); - - links.raise(); - } - - /** - * Get the coordinates for a given selection's center. - * @param {Selection} selection - The selection to find the center for. - * @returns {object} Position of selection. - */ - getSelectionCenter(selection) { - const box = selection.node().getBoundingClientRect(); + getNodeSize(componentId) { + const node = d3.select(`#${componentId}`).select('.model'); return { - x: box.left + (box.width / 2), - y: box.top + (box.height / 2), + width: parseInt(node.attr('width'), 10), + height: parseInt(node.attr('height'), 10), }; } - /** - * Get the angle (in degrees) between two points. - * 0 = pointB is directly below. - * 180 = pointB is directly above. - * @param {object} pointA - The point to get the bearing from. - * @param {object} pointB - The point to get the bearing to. - * @returns {number} The bearing. - */ - getBearing(pointA, pointB) { - const distanceXBA = pointB.x - pointA.x; - const distanceYBA = pointB.y - pointA.y; - const x = distanceXBA / (Math.sqrt(distanceXBA ** 2 + distanceYBA ** 2)); - const y = distanceYBA / (Math.sqrt(distanceXBA ** 2 + distanceYBA ** 2)); - - return ((Math.atan2(x, y) * (180 / Math.PI)) + 360) % 360; - } - - /** - * Build a new d3 link generator for a ComponentLink - * @param {ComponentLink} link - The link to build the generator for. - * @returns {object} A d3 link generator. - */ - getLinkGenerator(link) { - const source = d3.select(`#${link.source}`); - const target = d3.select(`#${link.target}`); - - const sourceAnchor = this.getAnchorPoint(source, target); - const targetAnchor = this.getAnchorPoint(target, source); - - const sourceCenter = this.getSelectionCenter(source); - const targetCenter = this.getSelectionCenter(target); - - const angle = this.getBearing( - this.screenToSVG(sourceCenter.x, sourceCenter.y, this.svg.select('.container').node()), - this.screenToSVG(targetCenter.x, targetCenter.y, this.svg.select('.container').node()), - ); - - let curve; - - if (angle < 45 || angle >= 315 || (angle >= 135 && angle < 225)) { - curve = d3.curveBumpY; - } else { - curve = d3.curveBumpX; - } - - return d3.link(curve) - .source(() => sourceAnchor) - .target(() => targetAnchor); - } - - /** - * Compute the component's height then store it in its drawOptions. - * @param {Node} component - The component to get the height for. - * @returns {number} The computed height. - */ - getComponentHeight(component) { - if (component.id === '__shadowRoot') { - return 0; - } - - const containerSpacing = this.minHeight + this.padding + this.margin; - const childHeights = component.children - ? component.children.map(({ y1 }) => y1 + containerSpacing) - : [0]; - - component.data.drawOption.height = (Math.max( - this.minHeight + (component.data.definition.isContainer * containerSpacing), - ...childHeights, - )); - - return component.data.drawOption.height; - } - - /** - * Compute the component's width then store it in its drawOptions. - * @param {Node} component - The component to get the width for. - * @returns {number} The computed width. - */ - getComponentWidth(component) { - if (component.id === '__shadowRoot') { - return 0; - } - - const childWidths = component.children ? component.children.map(({ x1 }) => x1) : [0]; - - component.data.drawOption.width = Math.max(this.minWidth, ...childWidths) - + (!!(component.children) * (this.padding + this.margin)); - - return component.data.drawOption.width; - } - - /** - * Compute the dimension of every component. - * @param {Array} lines - Rows of components. - * @param {boolean} [invertEven] - Layout even line components right to left. - */ - setupTiles(lines, invertEven = false) { - let previousTallestItem = { x1: 0, y1: 0 }; - - lines - .forEach((line) => { - line.items = line.items - .map((item) => { - if (!item.data.drawOption) { - item.data.drawOption = new ComponentDrawOption({ - needsPositioning: true, - needsResizing: true, - }); - } - - return item; - }) - .map((item) => { - if (item.data.drawOption.needsResizing) { - this.initializeComponentDrawOptions(item); - item.data.drawOption.needsResizing = false; - } - - return item; - }) - .sort((itemA, itemB) => { - if (itemA.data.drawOption.needsPositioning && !itemB.data.drawOption.needsPositioning) { - return 1; - } - - if (!itemA.data.drawOption.needsPositioning - && !itemB.data.drawOption.needsPositioning) { - return itemA.data.drawOption.x - itemB.data.drawOption.x; - } - - return 0; - }); - /* .reduceRight((acc, item) => { - acc[invertEven && lineIndex % 2 ? 'push' : 'unshift'](item); - - return acc; - }, []) */ - }); - const rightClamp = Math.max(...lines.map( - (line) => (line.items.reduce((acc, item) => acc + item.data.drawOption.width, 0) - + (line.items.length + 1) * this.padding), - )); - - lines - .forEach((line, lineIndex) => { - let prevItem = { - x1: 0, - x0: rightClamp, - y0: line.band + this.padding, - }; - - line.items.forEach((item) => { - if (item.data.drawOption.needsPositioning) { - item.data.drawOption.x = invertEven && lineIndex % 2 - ? prevItem.x0 - item.data.drawOption.width - this.padding - : prevItem.x1 + this.padding; - item.data.drawOption.y = previousTallestItem.y1 + this.padding; - item.data.drawOption.needsPositioning = false; - } - - item.x0 = item.data.drawOption.x; - item.y0 = item.data.drawOption.y; - prevItem = item; - - item.x1 = item.x0 + item.data.drawOption.width; - item.y1 = item.y0 + item.data.drawOption.height; - }); - - if (line.items.length > 0) { - const maxLineValue = Math.max(...line.items.map((item) => item.value)); - - previousTallestItem = line.items.find((item) => item.value === maxLineValue); - } - }); - } - - /** - * Build and fill the layout lines for a Node. - * @param {Node[]} children - The Node's children to build lines with. - * @param {number} depth - The Node's depth. - * @returns {Array} A list of lines. - * @private - */ - __buildLines(children, depth) { - let lines = []; - let activeLineIndex = 0; - let activeLine = lines[activeLineIndex]; - - children.forEach((child) => { - lines = lines.sort((la, lb) => la.band - lb.band); - - if (child.data.drawOption && !child.data.drawOption.needsPositioning) { - activeLineIndex = lines.findIndex( - (line) => line.band === Math.floor(child.data.drawOption.y / 100) * 100, - ); - - if (activeLineIndex === -1) { - lines.push({ - total: 0, - band: Math.floor(child.data.drawOption.y / 100) * 100, - items: [], - }); - activeLineIndex = lines.length - 1; - } - } else { - activeLineIndex = 0; - - while (activeLineIndex < lines.length - && lines[activeLineIndex].items.length >= this.getLineLengthForDepth( - depth, - child.parent?.data?.definition?.childrenPerLine, - )) { - activeLineIndex += 1; - } - - if (activeLineIndex === lines.length) { - lines.push({ - total: 0, - band: activeLineIndex > 0 ? lines[activeLineIndex - 1].band + 100 : 0, - items: [], - }); - } - } - - activeLine = lines[activeLineIndex]; - activeLine.total += child.value; - activeLine.items.push(child); - }); - - return lines.sort((la, lb) => la.band - lb.band); - } - - /** - * Set actions on viewport. - * @param {boolean} readOnly - Disable viewport action. - */ - setViewPortAction(readOnly) { - this.svg.on('click', () => { - this.__unselectComponent(); - this.cancelLinkCreationInteraction(); - }); - - if (readOnly) { - return; - } - - const drawLinks = this.drawLinks.bind(this); - - this.svg.call(d3 - .zoom() - .on('zoom', (event) => { - this.svg.select('.container').attr('transform', event.transform); - this.actions.zoom.scale = event.transform.k; - this.actions.zoom.translate.x = event.transform.x; - this.actions.zoom.translate.y = event.transform.y; - - drawLinks(); - })); - } - - /** - * Action to unselect current element. - * If no element is selected, does nothing. - * @private - */ - __unselectComponent() { - if (this.actions.selection.current) { - const selectedComponent = d3.select(`#${this.rootId} .selected`); - - if (selectedComponent.empty()) { - this.actions.selection.current = null; - - return; - } - - if (selectedComponent.classed('component')) { - selectedComponent - .classed('selected', false) - .select('.template') - .style('outline', ''); - } else { - selectedComponent - .classed('selected', false) - .style('outline', ''); - } - - this.actions.selection.current = null; - this.hideActionMenu(); - this.hideResizer(); - } - } - - /** - * Unselects current selected element and selects a new one. - * @param {Selection} targetSelection - Component or link to select. - * @private - */ - __selectComponent(targetSelection) { - const currentComponent = targetSelection.datum().__class === 'Link' - ? targetSelection.datum() - : targetSelection.datum().data; - const sameElementClicked = this.actions.selection.current === currentComponent; - - if (this.actions.linkCreation.creating) { - if (targetSelection.node().classList.contains('disabled')) { - return; - } - - this.actions.linkCreation.target = currentComponent; - this.createLink(); - } else { - this.__unselectComponent(); - - if (sameElementClicked) { - this.pluginData.emitEvent({ - type: 'Drawer', - action: 'select', - status: 'success', - components: [currentComponent.id], - data: { - isSelected: false, - }, - }); - - return; - } - - targetSelection - .classed('selected', true); - - if (targetSelection.classed('component')) { - targetSelection - .select('.template') - .style('outline', this.actions.selection.style) - .style('outline-offset', this.actions.selection.offset); - } else { - targetSelection - .style('outline', this.actions.selection.style) - .style('outline-offset', this.actions.selection.offset); - } - - this.actions.selection.current = currentComponent; - - if (this.events?.SelectEvent && currentComponent.__class === 'Component') { - this.events.SelectEvent.next(currentComponent); - } - - this.initializeActionMenu(targetSelection); - - if (targetSelection.datum().data && targetSelection.datum()?.data.definition.isContainer) { - this.initializeResizer(targetSelection); - } - - this.pluginData.emitEvent({ - type: 'Drawer', - action: 'select', - status: 'success', - components: [currentComponent.id], - data: { - isSelected: true, - }, - }); - } - } - - /** - * Create a link between the previously selected source and destination. - * @param {string} componentId - Component id. - */ - createLink(componentId) { - const { source, target } = this.actions.linkCreation; - const activeLinkType = this.pluginData.definitions.links - .find((definition) => definition.sourceRef === source.definition.type - && definition.targetRef === target.definition.type); - - const newLink = new ComponentLink({ - source: source.id, - target: target.id, - definition: activeLinkType, - }); - - this.actions.linkCreation.source.setLinkAttribute(newLink); - - this.pluginData.emitEvent({ - type: 'Drawer', - action: 'add', - status: 'success', - components: [componentId], - links: [newLink], - }); - - this.cancelLinkCreationInteraction(); - - this.drawLinks(); - } - - /** - * Initialize the action menu for a given target. - * @param {Selection} targetSelection - D3 selection of the target object. - */ - initializeActionMenu(targetSelection) { - const actionMenu = this.svg - .select('.container') - .append('svg') - .attr('id', 'action-menu'); - - const actions = this.getMenuActions(targetSelection); - - const linkableList = targetSelection.datum().data?.getDefinedAttributesByType('Link'); - - const zoomTransform = d3.zoomTransform(this.svg.select('.container').node()); - - actionMenu - .append('rect') - .attr('fill', 'lightgrey') - .attr('width', this.actionMenuButtonSize * actions.length) - .attr('height', this.actionMenuButtonSize) - .attr('rx', 5); - - const { bottom, width, left } = targetSelection.node().getBoundingClientRect(); - const { x, y } = this.screenToSVG( - (left + (width / 2)) - ((this.actionMenuButtonSize * actions.length) / 2) * zoomTransform.k, - bottom + 20, - this.svg.select('.container').node(), - ); - - actionMenu - .attr('x', x) - .attr('y', y); - - const buttons = actionMenu - .selectAll('svg') - .data(actions) - .join('svg') - .attr('id', (data) => data.id) - .attr('width', this.actionMenuButtonSize) - .attr('height', this.actionMenuButtonSize) - .attr('x', (_data, index) => (this.actionMenuButtonSize * index)) - .attr('preserveAspectRatio', 'xMinYMin meet') - .attr('cursor', (d) => ((d.id === 'create-linkable-component' || d.id === 'create-link') - && linkableList.length === 0 ? 'not-allowed' : 'pointer')) - .on('click', (event, data) => { - event.stopPropagation(); - const handler = data.handler.bind(this); - - handler(event, data); - }); - - buttons - .append('rect') - .classed('bg-button', true) - .attr('fill', 'lightgrey') - .attr('rx', 5) - .style('width', this.actionMenuButtonSize) - .style('height', this.actionMenuButtonSize); - - buttons - .on('mouseenter', function onHover() { - d3.select(this) - .select('.bg-button') - .attr('fill', (data) => ( - (data.id === 'create-linkable-component' || data.id === 'create-link') - && linkableList.length === 0 ? 'lightgrey' : 'grey' - )); - }) - .on('mouseleave', function onLeave() { - d3.select(this) - .select('.bg-button') - .attr('fill', 'lightgrey'); - }); - - buttons - .append('g') - .attr('x', 0) - .attr('y', 0) - .html((d) => d.icon) - .select('svg') - .attr('opacity', (data) => ( - (data.id === 'create-linkable-component' || data.id === 'create-link') - && linkableList.length === 0 ? 0.2 : 1 - )) - .attr('width', '80%') - .attr('height', '80%') - .attr('x', '10%') - .attr('y', '10%'); - - actionMenu.selectAll('button') - .style('width', '30px') - .style('height', '30px') - .style('border', 'none'); - } - - /** - * Initialize resizer button when container component is selected. - * @param {Selection} targetSelection - D3 selection of the target object. - */ - initializeResizer(targetSelection) { - const { - top, left, width, height, - } = targetSelection.node().getBoundingClientRect(); - const { x: x1, y: y1 } = this.screenToSVG( - left + width, - top + height, - this.svg.select('.container').node(), - ); - - const hitSize = 10; - - const resizer = this.svg.select('.container') - .append('g') - .attr('id', 'resizer') - .attr('fill', '#B5B5B5'); - - resizer.append('circle') - .classed('resize-hit', true) - .attr('cursor', 'nwse-resize') - .attr('cx', x1) - .attr('cy', y1) - .attr('r', hitSize) - .call(d3.drag() - .on('drag', (event) => { - this.hideActionMenu(); - - const component = d3.select(`#svg-${this.actions.selection.current.id}`); - const componentContainer = component.select('.component-container'); - const componentW = parseInt(component.attr('width'), 10); - const componentH = parseInt(component.attr('height'), 10); - const containerW = parseInt(componentContainer.attr('width'), 10); - const containerH = parseInt(componentContainer.attr('height'), 10); - - const hit = d3.select('.resize-hit'); - const hitX = parseInt(hit.attr('cx'), 10); - const hitY = parseInt(hit.attr('cy'), 10); - - hit.attr('cx', hitX + event.dx); - hit.attr('cy', hitY + event.dy); - - component - .attr('width', componentW + event.dx) - .attr('height', componentH + event.dy); - - component - .select('.component-hitbox') - .attr('width', componentW + event.dx) - .attr('height', componentH + event.dy); - - component - .select('.component-container') - .attr('width', containerW + event.dx) - .attr('height', containerH + event.dy); - }) - .on('end', () => { - const component = this.actions.selection.current; - const componentSvg = d3.select(`#svg-${component.id}`); - const componentW = parseInt(componentSvg.attr('width'), 10); - const componentH = parseInt(componentSvg.attr('height'), 10); - - component.drawOption.width = componentW; - component.drawOption.height = componentH; - component.drawOption.manuallyResized = true; - - this.draw(this.rootId); - - this.pluginData.emitEvent({ - type: 'Drawer', - action: 'resize', - status: 'success', - components: [component.id], - }); - })); - } - - /** - * Initialize the linkable components creation menu. - * @param {ComponentDefinition[]} definitions - List of component definitions. - */ - initializeCreateLinkableComponentMenu(definitions) { - d3.select('#linkable-menu')?.remove(); - - let maxTextWidth = 0; - const buttonPadding = 5; - const iconSize = 20; - const actionMenu = document.querySelector('#action-menu'); - const menu = this.svg.select('.container') - .append('svg') - .attr('id', 'linkable-menu'); - - menu - .append('rect') - .attr('rx', 5) - .attr('fill', 'lightgrey') - .attr('height', '100%') - .attr('width', '100%'); - - const buttons = menu - .selectAll('.linkable-button') - .data(definitions) - .join('svg') - .classed('linkable-button', true); - - buttons.attr('class', (data) => `linkable-button ${data.type}`); - - buttons - .attr('width', '100%') - .attr('rx', 5) - .attr('width', '100%') - .attr('height', 30) - .attr('y', (_data, index) => (index * 30)); - - buttons - .append('rect') - .attr('rx', 5) - .attr('fill', 'lightgrey') - .attr('height', '100%') - .attr('width', '100%'); - - buttons - .append('svg') - .html((data) => this.resources.icons[data.icon]) - .attr('x', buttonPadding) - .attr('y', buttonPadding) - .attr('width', iconSize) - .attr('height', iconSize) - // eslint-disable-next-line prefer-arrow-callback - .attr('viewBox', function setViewbox() { - const icon = d3.select(this).select('svg'); - const width = icon.attr('width').replace('px', ''); - const height = icon.attr('height').replace('px', ''); - - return `0 0 ${width} ${height}`; - }) - .attr('background-color', 'white'); - - buttons - .append('text') - .attr('x', (buttonPadding * 2) + iconSize) - .attr('y', 18) - .text((data) => data.type); - - // eslint-disable-next-line prefer-arrow-callback - buttons.selectAll('text').each(function getTextWidth() { - const { width } = this.getBBox(); - - if (width > maxTextWidth) { - maxTextWidth = width; - } - }); - - menu - .attr('width', maxTextWidth + iconSize + (buttonPadding * 3)) - .attr('height', (definitions.length * 30)) - // eslint-disable-next-line prefer-arrow-callback - .attr('x', function xPos() { - return parseInt(actionMenu.getAttribute('x'), 10) - + (actionMenu.getBBox().width / 2) - - (parseInt(this.getAttribute('width'), 10) / 2); - }) - .attr('y', (parseInt(actionMenu.getAttribute('y'), 10) - + (actionMenu.getBBox().height) + 10)); - - buttons - .on('mouseenter', function onHover() { - d3.select(this) - .select('rect') - .attr('fill', 'grey') - .attr('cursor', 'pointer'); - }) - .on('mouseleave', function onLeave() { - d3.select(this) - .select('rect') - .attr('fill', 'lightgrey') - .attr('cursor', 'default'); - }) - .on('click', (event, data) => { - this.actions.linkCreation.source = this.actions.selection.current; - - const componentId = this.pluginData.addComponent(data); - const component = this.pluginData.getComponentById(componentId); - - component.path = this.actions.linkCreation.source.path; - - this.draw(this.rootId); - this.actions.linkCreation.target = d3.select(`#${componentId}`).datum().data; - this.createLink(componentId); - }); - } - - /** - * Initialize the link creation menu. - */ - startLinkCreationInteraction() { - if (this.actions.selection.current) { - const source = this.pluginData.getComponentById(this.actions.selection.current.id); - const allowedLinkTargets = source.getDefinedAttributesByType('Link'); - const forbiddenTypes = allowedLinkTargets - .map((linkTarget) => `:not(.${linkTarget.linkRef})`) - .join(''); - - this.actions.linkCreation.creating = true; - this.actions.linkCreation.source = source; - - this.setDisabledStyle(`.component:not(#${source.id})${forbiddenTypes}`); - } - } - - /** - * Handler for component removal. - * Remove component, emit an event accordingly then draw again. - */ - removeComponentHandler() { - this.pluginData.removeComponentById(this.actions.selection.current.id); - - this.pluginData.emitEvent({ - type: 'Drawer', - action: 'delete', - status: 'success', - components: [this.actions.selection.current.id], - }); - - this.draw(this.rootId); - } - - /** - * Handler for link removal. - * Remove link, emit an event accordingly then draw again. - */ - removeLinkHandler() { - this.pluginData.removeLink(this.actions.selection.current); - - this.pluginData.emitEvent({ - type: 'Drawer', - action: 'delete', - status: 'success', - components: [], - }); - - this.draw(this.rootId); - } - - /** - * Get a list of actions to fill the menu for a given target. - * @param {object} targetSelection - The target object. - * @type {object} - * @property {string} id - Id of the action button. - * @property {string} icon - Icon to display in the action button - * @property {Function} handler - Function called on action click. - * @returns {Array} The list of menu actions. - */ - getMenuActions(targetSelection) { - if (targetSelection.classed('component')) { - return [ - { - id: 'create-linkable-component', - icon: actionIcons.add, - handler() { - const data = targetSelection.datum().data?.getDefinedAttributesByType('Link') - .map((link) => link.linkRef); - const definitions = this.pluginData.definitions.components.filter((definition) => ( - data.includes(definition.type) - )); - - if (definitions.length > 0) { - this.initializeCreateLinkableComponentMenu(definitions); - } - }, - }, - { - id: 'create-link', - icon: actionIcons.link, - handler() { - this.startLinkCreationInteraction(); - }, - }, - { - id: 'remove-component', - icon: actionIcons.trash, - handler: this.removeComponentHandler.bind(this), - }, - ]; - } - - return [ - { - id: 'remove-link', - icon: actionIcons.trash, - handler: this.removeLinkHandler.bind(this), - }, - ]; - } - - /** - * Handle link creation being cancelled. - */ - cancelLinkCreationInteraction() { - this.actions.linkCreation.creating = false; - this.actions.linkCreation.source = null; - this.actions.linkCreation.target = null; - this.unsetAllDisabledStyles(); - } - - /** - * Hide the action menu. - */ - hideActionMenu() { - d3.select('#action-menu').remove(); - d3.select('#linkable-menu').remove(); - } - - /** - * Hide the resizer. - */ - hideResizer() { - d3.select('#resizer').remove(); - } - /** * Reorganize nodes layout algorithmically. * This method does not refresh the view. @@ -1740,5 +202,18 @@ class DefaultDrawer { async arrangeComponentsPosition() { await this.layout.arrangeComponentsPosition(); } + + registerComponentsDrawOption() { + this.pluginData.components.forEach((component) => { + const position = this.getNodePosition(component.id); + const size = this.getNodeSize(component.id); + + component.drawOption.x = position.x; + component.drawOption.y = position.y; + component.drawOption.width = size.width; + component.drawOption.height = size.height; + }); + } } + export default DefaultDrawer; diff --git a/src/draw/DefaultLayout.js b/src/draw/layout/DefaultLayout.js similarity index 100% rename from src/draw/DefaultLayout.js rename to src/draw/layout/DefaultLayout.js diff --git a/src/draw/ElkLayout.js b/src/draw/layout/ElkLayout.js similarity index 94% rename from src/draw/ElkLayout.js rename to src/draw/layout/ElkLayout.js index 89a45200..9d0a46a3 100644 --- a/src/draw/ElkLayout.js +++ b/src/draw/layout/ElkLayout.js @@ -101,10 +101,11 @@ class ElkLayout extends DefaultLayout { /** * Initializes ELK parameters and inherited fields. * @param {DefaultData} pluginData - A graph to be arranged. + * @param {object} [renderer] - Renderer for the components. (use defaults if unsure) * @param {object} [elkParams] - Parameters for the layout algorithm. (use defaults if unsure) * @see Parameters for ELK: {@link https://eclipse.dev/elk/reference/options.html} */ - constructor(pluginData, elkParams = {}) { + constructor(pluginData, renderer = {}, elkParams = {}) { super(pluginData); /** @@ -114,7 +115,6 @@ class ElkLayout extends DefaultLayout { this.elkParams = { // default parameters 'elk.algorithm': 'elk.layered', - 'spacing.baseValue': '50', separateConnectedComponents: 'true', 'elk.layered.cycleBreaking.strategy': 'INTERACTIVE', 'elk.layered.layering.strategy': 'INTERACTIVE', @@ -126,6 +126,11 @@ class ElkLayout extends DefaultLayout { ...elkParams, }; + + this.renderer = { + componentRenderer: null, + ...renderer, + }; } /** @@ -158,10 +163,7 @@ class ElkLayout extends DefaultLayout { // For each parent, from the deepest nodes up to the root, get a layout for its children. return Promise.all( - this.getParentsByDepth(nodes) - .map( - (node) => this.generateELKLayout(node, nodes, links), - ), + this.getParentsByDepth(nodes).map((node) => this.generateELKLayout(node, nodes, links)), ); } @@ -171,7 +173,9 @@ class ElkLayout extends DefaultLayout { * @private */ writeLayout(layout) { - layout.forEach((elkNode) => this.writeSingleDepthLayout(elkNode)); + layout.forEach((elkNode) => { + this.writeSingleDepthLayout(elkNode); + }); } /** @@ -273,7 +277,15 @@ class ElkLayout extends DefaultLayout { })); // Finally calling ELK. - return ElkLayout.elk.layout(graph); + const layout = await ElkLayout.elk.layout(graph); + + this.writeSingleDepthLayout(layout); + this.renderer.componentRenderer.render(layout.id); + if (layout.id !== 'root') { + this.renderer.componentRenderer.setAutomaticlyContainerSize(layout.id); + } + + return layout; } /** diff --git a/src/draw/render/ComponentRenderer.js b/src/draw/render/ComponentRenderer.js new file mode 100644 index 00000000..00b4ddce --- /dev/null +++ b/src/draw/render/ComponentRenderer.js @@ -0,0 +1,116 @@ +import * as d3 from 'd3'; +import { renderString } from 'nunjucks'; + +class ComponentRenderer { + constructor(options = { + gap: 10, + padding: 6, + resources: null, + }) { + /** + * Gap between components. + * @type {number} + * @default 10 + */ + this.gap = options.gap; + + /** + * Space between the component and the border of its container. + * @type {number} + * @default 10 + */ + this.padding = options.padding; + + /** + * Object that contains resources. + * @type {object} + */ + this.resources = options.resources; + + /** + * Drawing context. + * @type {object} + * @default null + * @private + */ + this.drawingContextId = 'root'; + } + + /** + * Check if the data has the option to draw. + * @param {Component} [data] - Data of component. + * @param {string} [option] - Option to check if exists. + * @returns {boolean} - True if the option exists, false otherwise. + * @private + */ + __checkDataDrawingOptionExists(data, option) { + return !(!data.drawOption || !data.drawOption[option]); + } + + /** + * Create models. + * @param {Component} [data] - Data of component. + * @returns {string} - Rendered models. + */ + renderModel(data) { + const { padding } = this; + + return renderString( + this.getModel(data.definition.model), + { + ...data, + padding, + icon: this.resources.icons[data.definition.icon], + hasError: data.hasError(), + hasWidth: this.__checkDataDrawingOptionExists(data, 'width'), + hasHeight: this.__checkDataDrawingOptionExists(data, 'height'), + hasX: this.__checkDataDrawingOptionExists(data, 'x'), + hasY: this.__checkDataDrawingOptionExists(data, 'y'), + getAttribute: (name) => data.attributes.find((attribute) => attribute.name === name), + }, + ); + } + + /** + * Create nodes. + * @param {string} contextId - Id of current context. + * @param {number} [depth] - Depth of current context. + * @private + */ + render(contextId = this.drawingContextId) { + const context = d3.select(`#${contextId}`); + + context.select('.components').selectAll(`.component[depth="${context.datum().depth + 1}"]`) + .data(({ children }) => children) + .join('g') + .attr('id', ({ data }) => data.id) + .attr('class', 'component') + .attr('depth', (data) => data.depth) + .html(({ data }) => this.renderModel(data)) + .filter(({ data, children }) => data.definition.isContainer && !(!children)) + .each(({ data }) => this.render(data.id)); + } + + setAutomaticlyContainerSize(nodeId) { + const node = d3.select(`#${nodeId}`); + const { width, height } = node.select('.components').node().getBoundingClientRect(); + + node.datum().data.drawOption.innerWidth = width; + node.datum().data.drawOption.innerHeight = height; + + node.html(this.renderModel(node.datum().data)); + this.render(nodeId); + } + + /** + * Get model. + * @param {string} [model] - Model name. + * @returns {string} - Model. + * @private + */ + getModel(model) { + return this.resources.models[model]; + } +} + +export default ComponentRenderer; diff --git a/src/models/Component.js b/src/models/Component.js index 4d207829..b19e9ec9 100644 --- a/src/models/Component.js +++ b/src/models/Component.js @@ -1,5 +1,6 @@ import ComponentAttribute from './ComponentAttribute'; import FileInformation from './FileInformation'; +import ComponentDrawOption from './ComponentDrawOption'; /** * A model for modelling tools in Leto Modelizer. @@ -57,7 +58,7 @@ class Component extends FileInformation { * The options used to draw this Component. * @type {ComponentDrawOption} */ - this.drawOption = drawOption || null; + this.drawOption = drawOption || new ComponentDrawOption(); /** * Attributes of Component. * @type {ComponentAttribute[]} diff --git a/src/models/ComponentDrawOption.js b/src/models/ComponentDrawOption.js index d6e7cad1..3fc37845 100644 --- a/src/models/ComponentDrawOption.js +++ b/src/models/ComponentDrawOption.js @@ -59,6 +59,18 @@ class ComponentDrawOption { */ this.height = height || null; + /** + * True width of Component in pixel. + * @type {number} + */ + this.innerWidth = null; + + /** + * True height of Component in pixel. + * @type {number} + */ + this.innerHeight = null; + /** * True if the component needs to be resized * @type {boolean} diff --git a/src/models/DefaultPlugin.js b/src/models/DefaultPlugin.js index 77df3aa7..4fe46a06 100644 --- a/src/models/DefaultPlugin.js +++ b/src/models/DefaultPlugin.js @@ -90,13 +90,18 @@ class DefaultPlugin { this.__drawer.resources = resources; } + /** + * Init drawing context of plugin. + */ + initDrawingContext() { + this.__drawer.initDrawingContext(); + } + /** * Draws all data in the html element defined by the id. - * @param {string} id - Html id, without '#'. - * @param {boolean} readOnly - Make the draw read-only. */ - draw(id, readOnly) { - this.__drawer.draw(id, readOnly); + draw() { + this.__drawer.draw(); } /**