diff --git a/samples/spring-flo-sample/README.md b/samples/spring-flo-sample/README.md
new file mode 100644
index 0000000..6995b7b
--- /dev/null
+++ b/samples/spring-flo-sample/README.md
@@ -0,0 +1,62 @@
+# A sample app for spring-flo
+
+This is a very basic example of spring-flo usage.
+
+# Running the sample
+
+A basic Spring Boot app is used to serve the sample. Launch it with:
+
+ mvn spring-boot:run
+
+then open http://localhost:8080 then you can type text into the box
+(e.g. "filewatch > ftp") or drag elements from the palette and link them
+together to build a pipeline.
+
+# Understanding the sample
+
+Spring Flo allows simultaneous synchronization between some textual domain
+specific language (DSL) and the graphical representation. You can type in the
+text version or you can interactively build the graph version. Any custom
+usage of spring-flo therefore needs three things:
+
+- a metamodel of the domain. What are the 'things' you are connecting in your
+ pipeline and what are their properties?
+- a converter from the text form to the graph form.
+- a converter from the graph form to the text form.
+
+The same here includes a very basic domain that you can easily customize:
+
+- there are a handful of nodes with basic properties. These are defined in
+ `metamodel-sample.json` - that is a JSON array of the modules supported.
+- the DSL form is `module --key=value --key=value | nextModule --key=value`.
+- the conversion from text to graph is done in `text-to-graph.js`
+- the conversion from graph to text is done in `graph-to-text.js`
+
+Note the convertors (and the DSL) do not describe or support linking multiple nodes
+to a single node, but spring-flo supports that, you just need to enhance the
+convertors and have a representation in the DSL.
+
+If you wanted to use this sample in your domain, perhaps you would enhance `index.html`
+to include some kind of 'execute' button that used the text representation to
+drive some other (perhaps backend) API.
+
+The included `index.html` has basic buttons that exercise some of the control features
+of flo - for example turning off the palette and switching it to a read-only mode (for
+use when embedding flo into something that should just be showing a read only view of a
+pipeline).
+
+# Navigating the sample
+
+In order to keep the project to a single build file, spring-flo is referenced
+through the maven pom using webjars dependencies.
+
+- `index.html` the single HTML file that embeds sping-flo
+- `main.js` the launch config file for requirejs, referenced from index.html
+- `sample-app.js` setup the angular app with custom services
+- `metamodel-service.js` the custom metamodel service for this usage of spring-flo
+- `editor-service.js` the custom editor service for this usage of spring-flo
+- `render-service.js` the custom render service for this usage of spring-flo
+- `metamodel-sample.json` a basic json description of the domain
+- `graph-to-text.js` convert from the graph form to our custom DSL text
+- `text-to-graph.js` convert from the custom DSL text to our graph form
+
diff --git a/samples/spring-flo-sample/pom.xml b/samples/spring-flo-sample/pom.xml
new file mode 100755
index 0000000..e19da96
--- /dev/null
+++ b/samples/spring-flo-sample/pom.xml
@@ -0,0 +1,115 @@
+
+
+ 4.0.0
+
+ org.springframework
+ spring-flo-sample
+ 0.0.1.BUILD-SNAPSHOT
+ jar
+
+ spring-flo-sample
+ Spring Flo Sample
+
+
+ org.springframework.boot
+ spring-boot-starter-parent
+ 1.3.2.RELEASE
+
+
+
+
+
+
+
+ org.webjars.bower
+ codemirror
+ 5.1.0
+
+
+ org.webjars.bower
+ angular
+ 1.3.8
+
+
+ org.webjars.bower
+ jshint
+ 2.8.0
+
+
+ org.webjars.bower
+ requirejs
+ 2.1.18
+
+
+ org.webjars.bower
+ jquery
+ 2.2.0
+
+
+ org.webjars.bower
+ lodash
+ 3.10.1
+
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-web
+
+
+ org.webjars
+ webjars-locator
+ 0.31
+
+
+ org.webjars.bower
+ requirejs-domready
+ 2.0.1
+
+
+ org.webjars.bower
+ requirejs-text
+ 2.0.15
+
+
+ org.webjars.bower
+ jointjs
+ 0.9.7
+
+
+ org.webjars.bower
+ json5
+ 0.4.0
+
+
+ org.webjars.bower
+ spring-flo
+ 0.5.0
+
+
+ org.webjars.bower
+ joint
+
+
+
+
+
+
+ UTF-8
+ org.springframework.flo.Application
+ 1.7
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+
+
+
+
+
diff --git a/samples/spring-flo-sample/src/main/java/org/springframework/flo/Application.java b/samples/spring-flo-sample/src/main/java/org/springframework/flo/Application.java
new file mode 100755
index 0000000..2497283
--- /dev/null
+++ b/samples/spring-flo-sample/src/main/java/org/springframework/flo/Application.java
@@ -0,0 +1,33 @@
+/*
+ * Copyright 2016 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.springframework.flo;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+/**
+ *
+ * @author Andy Clement
+ * @author Alex Boyko
+ */
+@SpringBootApplication
+public class Application {
+
+ public static void main(String[] args) {
+ SpringApplication.run(Application.class, args);
+ }
+
+}
diff --git a/samples/spring-flo-sample/src/main/resources/application.properties b/samples/spring-flo-sample/src/main/resources/application.properties
new file mode 100644
index 0000000..e69de29
diff --git a/samples/spring-flo-sample/src/main/resources/static/css/sample.css b/samples/spring-flo-sample/src/main/resources/static/css/sample.css
new file mode 100644
index 0000000..5127332
--- /dev/null
+++ b/samples/spring-flo-sample/src/main/resources/static/css/sample.css
@@ -0,0 +1,141 @@
+
+.header {
+ font-weight: 400;
+ font-family: "Varela Round",sans-serif;
+ font-size: 36px;
+ color: #eeeeee;
+ padding: 2px;
+ background-color: #34302d;
+ border: none;
+ border-top: 4px solid #6db33f;
+ z-index: 1;
+}
+
+body {
+ background-color: #eeeeee;
+ -webkit-user-select: none;
+ -khtml-user-select: none;
+ -moz-user-select: none;
+ -o-user-select: none;
+ user-select: none;
+}
+
+.control-button {
+ width: 16px;
+ height: 16px;
+}
+
+.header-small {
+ font: 300 24px "Helvetica Neue";
+}
+
+pre {
+ font-size: 18px;
+}
+
+.border-selected {
+ stroke: #34302d;
+ stroke-width: 3;
+}
+
+.controls {
+ border-radius: 2px;
+ border: solid;
+ border-color: #6db33f;
+ padding: 5px;
+ margin-top: 3px;
+ background-color: #eeeeee;
+ border-width: 1px;
+}
+
+.button {
+ background-color: #34302d;
+ background-image: none;
+ border-radius: 2px;
+ color: #f1f1f1;
+ font-size: 14px;
+ line-height: 14px;
+ font-family: Montserrat,sans-serif;
+ border: 2px solid #6db33f;
+ padding: 5px 20px;
+ text-shadow: none;
+}
+
+.button span {
+ background-color: #34302d;
+ background-image: none;
+ border-radius: 2px;
+ color: #f1f1f1;
+ font-size: 14px;
+ line-height: 14px;
+ font-family: Montserrat,sans-serif;
+ border: 2px solid #6db33f;
+ padding: 5px 20px;
+ text-shadow: none;
+}
+
+.button input {
+ background-color: #34302d;
+ background-image: none;
+ color: #f1f1f1;
+ font-size: 14px;
+ font-family: Montserrat,sans-serif;
+ text-shadow: none;
+ border: 0px;
+ text-align:right;
+}
+
+button.on {
+ background-color: #5fa134;
+}
+
+.flow-definition-container {
+ border: 1px solid;
+ border-color: #6db33f;
+ border-radius: 2px;
+ margin-top: 3px;
+ background-color: #ffffff;
+ font-family: monospace;
+ z-index: 2;
+ width:100%;
+ height:100px;
+ -webkit-box-sizing: border-box;
+ -moz-box-sizing: border-box;
+ box-sizing: border-box;
+}
+
+textarea:input {
+ outline: none;
+ border: 1px solid #6db33f;
+}
+
+textarea:input:focus {
+ outline: none;
+ border: 1px solid #000000;
+}
+
+.flow-definition {
+ border: 5px;
+ height:100%;
+ width:100%;
+ font-size: 16px;
+ resize: none;
+ -webkit-box-sizing: border-box;
+ -moz-box-sizing: border-box;
+ box-sizing: border-box;
+}
+
+/* The text label on the nodes */
+.label {
+ font-family: 'Lucida Console';
+ font-size: 12px;
+}
+
+/* The class for the 'icon/unicode_char' on the nodes */
+.label2 {
+ font-size: 18px;
+}
+
+[ng\:cloak], [ng-cloak], [data-ng-cloak], [x-ng-cloak], .ng-cloak, .x-ng-cloak {
+ display: none !important;
+}
diff --git a/samples/spring-flo-sample/src/main/resources/static/icons/delete.svg b/samples/spring-flo-sample/src/main/resources/static/icons/delete.svg
new file mode 100644
index 0000000..5b3f988
--- /dev/null
+++ b/samples/spring-flo-sample/src/main/resources/static/icons/delete.svg
@@ -0,0 +1,4 @@
+
diff --git a/samples/spring-flo-sample/src/main/resources/static/icons/error.svg b/samples/spring-flo-sample/src/main/resources/static/icons/error.svg
new file mode 100644
index 0000000..c6c8388
--- /dev/null
+++ b/samples/spring-flo-sample/src/main/resources/static/icons/error.svg
@@ -0,0 +1,12 @@
+
+
+
diff --git a/samples/spring-flo-sample/src/main/resources/static/icons/rotate.svg b/samples/spring-flo-sample/src/main/resources/static/icons/rotate.svg
new file mode 100644
index 0000000..9248e1b
--- /dev/null
+++ b/samples/spring-flo-sample/src/main/resources/static/icons/rotate.svg
@@ -0,0 +1,15 @@
+
+
+
+
diff --git a/samples/spring-flo-sample/src/main/resources/static/index.html b/samples/spring-flo-sample/src/main/resources/static/index.html
new file mode 100755
index 0000000..f1a4ef4
--- /dev/null
+++ b/samples/spring-flo-sample/src/main/resources/static/index.html
@@ -0,0 +1,51 @@
+
+
+
+ Spring Flo Sample
+
+
+
+
+
+
+
+
+ Spring Flo Sample
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/spring-flo-sample/src/main/resources/static/js/editor-service.js b/samples/spring-flo-sample/src/main/resources/static/js/editor-service.js
new file mode 100644
index 0000000..9aaf82a
--- /dev/null
+++ b/samples/spring-flo-sample/src/main/resources/static/js/editor-service.js
@@ -0,0 +1,505 @@
+/*
+ * Copyright 2016 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * @author Alex Boyko
+ * @author Andy Clement
+ */
+define(function(require) {
+ 'use strict';
+
+ var joint = require('joint');
+
+ return ['$log', function($log) {
+
+ function createHandles(flo, createHandle, element) {
+ var bbox = element.getBBox();
+ var pt = bbox.origin().offset(bbox.width + 3, bbox.height + 3);
+ createHandle(element, 'remove', flo.deleteSelectedNode, pt);
+ }
+
+ function validatePort(/*flo, cellView, magnet*/) {
+ return true;
+ }
+
+ function validateLink(flo, cellViewS, magnetS, cellViewT, magnetT/*, end, linkView*/) {
+ // Prevent linking from input ports.
+ if (magnetS && magnetS.getAttribute('type') === 'input') {
+ return false;
+ }
+ // Prevent linking from output ports to input ports within one element.
+ if (cellViewS === cellViewT) {
+ return false;
+ }
+ // Prevent linking to input ports.
+ if (magnetT && magnetT.getAttribute('type') === 'output') {
+ return false;
+ }
+ return cellViewS.model && cellViewT.model && !(cellViewS.model instanceof joint.shapes.flo.ErrorDecoration) && !(cellViewT.model instanceof joint.shapes.flo.ErrorDecoration);
+ }
+
+ function preDelete(flo, cell) {
+ repairDamage(flo, cell);
+ }
+
+ function handleNodeDropping(flo, dragDescriptor) {
+ var relinking = dragDescriptor.context && dragDescriptor.context.palette;
+ var graph = flo.getGraph();
+ var source = dragDescriptor.source ? dragDescriptor.source.cell : undefined;
+ var target = dragDescriptor.target ? dragDescriptor.target.cell : undefined;
+ if (target instanceof joint.dia.Element && target.attr('metadata/name')) {
+ var type = source.attr('metadata/name');
+ if (type === 'tap') {
+ // Fill in the channel on the new node
+ // work out tap name, something like: tap:stream:mystream.filter (filter optional if on head of stream)
+ // 1. work your way back from node that was dropped on to the first node (the one with no further links)
+ var oncell = target;
+ var headerCell = oncell;
+ var incomingLinks = graph.getConnectedLinks(oncell, { inbound: true });
+ while (incomingLinks.length !== 0) {
+ headerCell = graph.getCell(incomingLinks[0].get('source').id);
+ incomingLinks = graph.getConnectedLinks(headerCell, { inbound: true });
+ }
+ var streamname = 'STREAM';
+ if (headerCell.attr('stream-name')) {
+ streamname = headerCell.attr('stream-name');
+ }
+ else {
+ var streamId = headerCell.attr('stream-id');
+ if (streamId) {
+ streamname = 'STREAM'+streamId;
+ } else {
+ streamname = 'STREAM';
+ }
+ headerCell.attr('stream-name',streamname);
+ }
+ var channel = 'tap:stream:'+streamname;
+ var label2 = 'tap:stream:\n' + streamname;
+ if (oncell.id !== headerCell.id) {
+ channel = channel + '.' + oncell.attr('.label/text');
+ label2 = label2 + '.' + oncell.attr('.label/text');
+ }
+ source.attr('.label2/text', label2);
+ source.attr('props/channel', channel);
+ relinking = true;
+ } else {
+ if (dragDescriptor.target.selector === '.output-port') {
+ moveNodeOnNode(flo, source, target, 'right', true);
+ relinking = true;
+ } else if (dragDescriptor.target.selector === '.input-port') {
+ moveNodeOnNode(flo, source, target, 'left', true);
+ relinking = true;
+ }
+ }
+ } else if (target instanceof joint.dia.Link) { // jshint ignore:line
+ moveNodeOnLink(flo, source, target);
+ relinking = true;
+ }
+ // Turn off auto layout
+// if (relinking) {
+// flo.performLayout();
+// }
+ }
+
+ function calculateDragDescriptor(flo, draggedView, targetUnderMouse, point, context) {
+ // check if it's a tap being dragged
+ var source = draggedView.model;
+ if ((targetUnderMouse instanceof joint.dia.Element) && source.attr('metadata/name') === 'tap') { // jshint ignore:line
+ return {
+ context: context,
+ source: {
+ cell: draggedView.model,
+ },
+ target: {
+ cell: targetUnderMouse,
+ }
+ };
+ }
+
+ // Find closest port
+ var range = 30;
+ var graph = flo.getGraph();
+ var paper = flo.getPaper();
+ var closestData;
+ var minDistance = Number.MAX_VALUE;
+ var maxIcomingLinks = draggedView.model.attr('metadata/constraints/maxIncomingLinksNumber');
+ var maxOutgoingLinks = draggedView.model.attr('metadata/constraints/maxOutgoingLinksNumber');
+ var hasIncomingPort = typeof maxIcomingLinks !== 'number' || maxIcomingLinks > 0;
+ var hasOutgoingPort = typeof maxOutgoingLinks !== 'number' || maxOutgoingLinks > 0;
+ if (!hasIncomingPort && !hasOutgoingPort) {
+ return;
+ }
+ var elements = graph.findModelsInArea(joint.g.rect(point.x - range, point.y - range, 2 * range, 2 * range)); // jshint ignore:line
+ if (Array.isArray(elements)) {
+ elements.forEach(function(model) {
+ var view = paper.findViewByModel(model);
+ if (view && view !== draggedView && model instanceof joint.dia.Element) { // jshint ignore:line
+ var targetMaxIcomingLinks = view.model.attr('metadata/constraints/maxIncomingLinksNumber');
+ var targetMaxOutgoingLinks = view.model.attr('metadata/constraints/maxOutgoingLinksNumber');
+ var targetHasIncomingPort = typeof targetMaxIcomingLinks !== 'number' || targetMaxIcomingLinks > 0;
+ var targetHasOutgoingPort = typeof targetMaxOutgoingLinks !== 'number' || targetMaxOutgoingLinks > 0;
+ if (view.model.attr('metadata/constraints/xorSourceSink')) {
+ if (targetHasIncomingPort) {
+ targetHasIncomingPort = targetHasIncomingPort && graph.getConnectedLinks(view.model, { outbound: true }).length === 0;
+ }
+ if (targetHasOutgoingPort) {
+ targetHasOutgoingPort = targetHasOutgoingPort && graph.getConnectedLinks(view.model, { inbound: true }).length === 0;
+ }
+ }
+ if (draggedView.model.attr('metadata/constraints/xorSourceSink')) {
+ if (hasIncomingPort) {
+ targetHasOutgoingPort = targetHasOutgoingPort && graph.getConnectedLinks(view.model, { outbound: true }).length === 0;
+ }
+ if (hasOutgoingPort) {
+ targetHasIncomingPort = targetHasIncomingPort && graph.getConnectedLinks(view.model, { inbound: true }).length === 0;
+ }
+ }
+ view.$('[magnet]').each(function(index, magnet) {
+ var type = magnet.getAttribute('type');
+ if ((type === 'input' && targetHasIncomingPort && hasOutgoingPort) || (type === 'output' && targetHasOutgoingPort && hasIncomingPort)) {
+ var bbox = joint.V(magnet).bbox(false, paper.viewport); // jshint ignore:line
+ var distance = point.distance({
+ x: bbox.x + bbox.width / 2,
+ y: bbox.y + bbox.height / 2
+ });
+ if (distance < range && distance < minDistance) {
+ minDistance = distance;
+ closestData = {
+ context: context,
+ source: {
+ cell: draggedView.model,
+ selector: type === 'output' ? '.input-port' : '.output-port'
+ },
+ target: {
+ cell: model,
+ selector: '.' + type+'-port'
+ },
+ range: minDistance
+ };
+ }
+ }
+ });
+ }
+ });
+ }
+ if (closestData) {
+ return closestData;
+ }
+
+ // Check if drop on a link is allowed
+ if (targetUnderMouse instanceof joint.dia.Link && !(source.attr('metadata/constraints/xorSourceSink') || source.attr('metadata/constraints/maxOutgoingLinksNumber') === 0 || source.attr('metadata/constraints/maxIncomingLinksNumber') === 0) && graph.getConnectedLinks(source).length === 0) { // jshint ignore:line
+ return {
+ context: context,
+ source: {
+ cell: source
+ },
+ target: {
+ cell: targetUnderMouse
+ }
+ };
+ }
+
+ return {
+ context: context,
+ source: {
+ cell: source
+ },
+ };
+ }
+
+ function validateNode(flo, element) {
+ var errors = [];
+ var graph = flo.getGraph();
+ var constraints = element.attr('metadata/constraints');
+ if (constraints) {
+ var incoming = graph.getConnectedLinks(element, {inbound: true});
+ var outgoing = graph.getConnectedLinks(element, {outbound: true});
+ if (typeof constraints.maxIncomingLinksNumber === 'number' || typeof constraints.minIncomingLinksNumber === 'number') {
+ if (typeof constraints.maxIncomingLinksNumber === 'number' && constraints.maxIncomingLinksNumber < incoming.length) {
+ if (constraints.maxIncomingLinksNumber === 0) {
+ errors.push({
+ message: 'Sources must appear at the start of a stream',
+ range: element.attr('range')
+ });
+ } else {
+ errors.push({
+ message: 'Max allowed number of incoming links is ' + constraints.maxIncomingLinksNumber,
+ range: element.attr('range')
+ });
+ }
+ }
+ if (typeof constraints.minIncomingLinksNumber === 'number' && constraints.minIncomingLinksNumber > incoming.length) {
+ errors.push({
+ message: 'Min allowed number of incoming links is ' + constraints.minIncomingLinksNumber,
+ range: element.attr('range')
+ });
+ }
+ }
+ if (typeof constraints.maxOutgoingLinksNumber === 'number' || typeof constraints.minOutgoingLinksNumber === 'number') {
+ if (typeof constraints.maxOutgoingLinksNumber === 'number' && constraints.maxOutgoingLinksNumber < outgoing.length) {
+ if (constraints.maxOutgoingLinksNumber === 0) {
+ errors.push({
+ message: 'Sinks must appear at the end of a stream',
+ range: element.attr('range')
+ });
+ } else {
+ errors.push({
+ message: 'Max allowed number of outgoing links is ' + constraints.maxOutgoingLinksNumber,
+ range: element.attr('range')
+ });
+ }
+ }
+ if (typeof constraints.minOutgoingLinksNumber === 'number' && constraints.minOutgoingLinksNumber > outgoing.length) {
+ errors.push({
+ message: 'Min allowed number of outgoing links is ' + constraints.minOutgoingLinksNumber,
+ range: element.attr('range')
+ });
+ }
+ }
+ if (constraints.xorSourceSink && incoming.length && outgoing.length) {
+ errors.push({
+ message: 'Node can either have incoming or outgoing links, but not both',
+ range: element.attr('range')
+ });
+ }
+ }
+ if (!element.attr('metadata') || element.attr('metadata/unresolved')) {
+ var msg = 'Unknown element \'' + element.attr('metadata/name') + '\'';
+ if (element.attr('metadata/group')) {
+ msg += ' from group \'' + element.attr('metadata/group') + '\'.';
+ }
+ errors.push({
+ message: msg,
+ range: element.attr('range')
+ });
+ }
+
+ // If possible, verify the properties specified match those allowed on this type of element
+ // propertiesRanges are the ranges for each property included the entire '--name=value'.
+ // The format of a range is {'start':{'ch':NNNN,'line':NNNN},'end':{'ch':NNNN,'line':NNNN}}
+ var propertiesRanges = element.attr('propertiesranges');
+ if (propertiesRanges) {
+ var moduleSchema = element.attr('metadata');
+ // Grab the list of supported properties for this module type
+ moduleSchema.get('properties').then(function(moduleSchemaProperties) {
+ if (!moduleSchemaProperties) {
+ moduleSchemaProperties = {};
+ }
+ // Example moduleSchemaProperties:
+ // {"host":{"name":"host","type":"String","description":"the hostname of the mail server","defaultValue":"localhost","hidden":false},
+ // "password":{"name":"password","type":"String","description":"the password to use to connect to the mail server ","defaultValue":null,"hidden":false}
+ var specifiedProperties = element.attr('props');
+ Object.keys(specifiedProperties).forEach(function(propertyName) {
+ if (!moduleSchemaProperties[propertyName]) {
+ // The schema does not mention that property
+ var propertyRange = propertiesRanges[propertyName];
+ if (propertyRange) {
+ errors.push({
+ message: 'unrecognized option \''+propertyName+'\' for module \''+element.attr('metadata/name')+'\'',
+ range: propertyRange
+ });
+ }
+ }
+ });
+ });
+ }
+
+ return errors;
+ }
+
+ function moveNodeOnNode(flo, node, pivotNode, side, shouldRepairDamage) {
+ side = side || 'left';
+ if (canSwap(flo, node, pivotNode, side)) {
+ var link;
+ var i;
+ if (side === 'left') {
+ var sources = [];
+ if (shouldRepairDamage) {
+ /*
+ * Commented out because it doesn't prevent cycles.
+ */
+// if (graph.getConnectedLinks(pivotNode, {inbound: true}).length > 0 || graph.getConnectedLinks(node, {outbound: true}).length > 0) {
+ repairDamage(flo, node);
+// }
+ }
+ var pivotTargetLinks = flo.getGraph().getConnectedLinks(pivotNode, {inbound: true});
+ for (i = 0; i < pivotTargetLinks.length; i++) {
+ link = pivotTargetLinks[i];
+ sources.push(link.get('source').id);
+ link.remove();
+ }
+ for (i = 0; i < sources.length; i++) {
+ flo.createLink({
+ 'id': sources[i],
+ 'selector': '.output-port'
+ }, {
+ 'id': node.id,
+ 'selector': '.input-port'
+ });
+ }
+ flo.createLink({
+ 'id': node.id,
+ 'selector': '.output-port'
+ }, {
+ 'id': pivotNode.id,
+ 'selector': '.input-port'
+ });
+ } else if (side === 'right') {
+ var targets = [];
+ if (shouldRepairDamage) {
+ /*
+ * Commented out because it doesn't prevent cycles.
+ */
+// if (graph.getConnectedLinks(pivotNode, {outbound: true}).length > 0 || graph.getConnectedLinks(node, {inbound: true}).length > 0) {
+ repairDamage(flo, node);
+// }
+ }
+ var pivotSourceLinks = flo.getGraph().getConnectedLinks(pivotNode, {outbound: true});
+ for (i = 0; i < pivotSourceLinks.length; i++) {
+ link = pivotSourceLinks[i];
+ targets.push(link.get('target').id);
+ link.remove();
+ }
+ for (i = 0; i < targets.length; i++) {
+ flo.createLink({
+ 'id': node.id,
+ 'selector': '.output-port'
+ }, {
+ 'id': targets[i],
+ 'selector': '.input-port'
+ });
+ }
+ flo.createLink({
+ 'id': pivotNode.id,
+ 'selector': '.output-port'
+ }, {
+ 'id': node.id,
+ 'selector': '.input-port'
+ });
+ }
+ }
+ }
+
+ function moveNodeOnLink(flo, node, link, shouldRepairDamage) {
+ var source = link.get('source').id;
+ var target = link.get('target').id;
+
+ if (shouldRepairDamage) {
+ repairDamage(flo, node);
+ }
+ link.remove();
+
+ if (source) {
+ flo.createLink({
+ 'id': source,
+ 'selector': '.output-port'
+ }, {
+ 'id': node.id,
+ 'selector': '.input-port'
+ });
+ }
+ if (target) {
+ flo.createLink({
+ 'id': node.id,
+ 'selector': '.output-port'
+ }, {
+ 'id': target,
+ 'selector': '.input-port'
+ });
+ }
+ }
+
+ function repairDamage(flo, node) {
+ /*
+ * remove incoming, outgoing links and cache their sources and targets not equal to current node
+ */
+ var sources = [];
+ var targets = [];
+ var i = 0;
+ var links = flo.getGraph().getConnectedLinks(node);
+ for (i = 0; i < links.length; i++) {
+ var targetId = links[i].get('target').id;
+ var sourceId = links[i].get('source').id;
+ if (targetId === node.id) {
+ links[i].remove();
+ sources.push(sourceId);
+ } else if (sourceId === node.id) {
+ links[i].remove();
+ targets.push(targetId);
+ }
+ }
+ /*
+ * best attempt to connect source and targets bypassing the node
+ */
+ if (sources.length === 1) {
+ var source = sources[0];
+ for (i = 0; i < targets.length; i++) {
+ flo.createLink({
+ 'id': source,
+ 'selector': '.output-port'
+ }, {
+ 'id': targets[i],
+ 'selector': '.input-port'
+ });
+ }
+ } else if (targets.length === 1) {
+ var target = targets[0];
+ for (i = 0; i < sources.length; i++) {
+ flo.createLink({
+ 'id': sources[i],
+ 'selector': '.output-port'
+ }, {
+ 'id': target,
+ 'selector': '.input-port'
+ });
+ }
+ }
+ }
+
+ /**
+ * Check if node being dropped and drop target node next to each other such that they won't be swapped by the drop
+ */
+ function canSwap(flo, dropee, target, side) {
+ var i, targetId, sourceId, noSwap = (dropee.id === target.id);
+ if (dropee === target) {
+ $log.debug('What!??? Dragged == Dropped!!! id = ' + target);
+ }
+ var links = flo.getGraph().getConnectedLinks(dropee);
+ for (i = 0; i < links.length && !noSwap; i++) {
+ targetId = links[i].get('target').id;
+ sourceId = links[i].get('source').id;
+ noSwap = (side === 'left' && targetId === target.id && sourceId === dropee.id) || (side === 'right' && targetId === dropee.id && sourceId === target.id);
+ }
+ return !noSwap;
+ }
+
+ return {
+ 'createHandles': createHandles,
+ 'validatePort': validatePort,
+ 'validateLink': validateLink,
+ 'calculateDragDescriptor': calculateDragDescriptor,
+ 'handleNodeDropping': handleNodeDropping,
+ 'validateNode': validateNode,
+ 'preDelete': preDelete,
+ 'interactive': {
+ 'vertexAdd': false
+ },
+ 'allowLinkVertexEdit': false
+ };
+
+ }];
+
+});
diff --git a/samples/spring-flo-sample/src/main/resources/static/js/graph-to-text.js b/samples/spring-flo-sample/src/main/resources/static/js/graph-to-text.js
new file mode 100644
index 0000000..cb375bb
--- /dev/null
+++ b/samples/spring-flo-sample/src/main/resources/static/js/graph-to-text.js
@@ -0,0 +1,179 @@
+/*
+ * Copyright 2016 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Convert a graph to a text representation.
+ *
+ * @author Alex Boyko
+ * @author Andy Clement
+ */
+define(function() {
+ 'use strict';
+
+ // Graph
+ var g;
+
+ // Number of Links left to visit
+ var numberOfLinksToVisit;
+
+ // Number of nodes left to visit
+ var numberOfNodesToVisit;
+
+ // Map of links left to visit indexed by id
+ var linksToVisit;
+
+ // Map of nodes left to visit indexed by id
+ var nodesToVisit;
+
+ // Map of nodes incoming non-visited links degrees index by node id
+ var nodesInDegrees;
+
+ // Priority:
+ // 1. find links whose source has no other links pointing at it
+ // 2. find links whose source has already been processed (not currently needed in sample DSL since
+ // can't create graphs like that due to metamodel constraints)
+ // 3. find remaining links
+ function nextLink() {
+ var indegree = Number.MAX_INT;
+ var currentBest;
+ for (var id in linksToVisit) {
+ var link = g.getCell(id);
+ var source = g.getCell(link.get('source').id);
+ var currentInDegree = nodesInDegrees[source.get('id')];
+ if (currentInDegree === 0) {
+ return visit(link);
+ } else if (indegree > currentInDegree) {
+ indegree = currentInDegree;
+ currentBest = link;
+ }
+ }
+ if (currentBest) {
+ return visit(currentBest);
+ }
+ }
+
+ function visit(e) {
+ if (e.isLink()) {
+ delete linksToVisit[e.get('id')];
+ nodesInDegrees[e.get('target').id]--;
+ numberOfLinksToVisit--;
+ } else {
+ delete nodesToVisit[e.get('id')];
+ numberOfNodesToVisit--;
+ }
+ return e;
+ }
+
+ function init(graph) {
+ numberOfLinksToVisit = 0;
+ numberOfNodesToVisit = 0;
+ linksToVisit = {};
+ nodesToVisit = {};
+ nodesInDegrees = {};
+ g = graph;
+ g.getElements().forEach(function(element) {
+ if (element.attr('metadata/name')) { // is it a node?
+ nodesToVisit[element.get('id')] = element;
+ var indegree = 0;
+ g.getConnectedLinks(element, {inbound: true}).forEach(function(link) {
+ if (link.get('source') && link.get('source').id && g.getCell(link.get('source').id) &&
+ g.getCell(link.get('source').id).attr('metadata/name')) {
+ linksToVisit[link.get('id')] = link;
+ numberOfLinksToVisit++;
+ indegree++;
+ }
+ });
+ nodesInDegrees[element.get('id')] = indegree;
+ numberOfNodesToVisit++;
+ }
+ });
+ }
+
+ /**
+ * Starts at a link and proceeds down a chain. Converts each node to
+ * text and then joins them with a ' > '.
+ */
+ function chainToText(link) {
+ var text = '';
+ var source = g.getCell(link.get('source').id);
+ text += nodeToText(source, true);
+ while (link) {
+ var target = g.getCell(link.get('target').id);
+ text += ' > ';
+ text += nodeToText(target, false);
+
+ // Find next not visited link to follow
+ link = null;
+ var outgoingLinks = g.getConnectedLinks(target, {outbound: true});
+ for (var i = 0; i < outgoingLinks.length && !link; i++) {
+ if (linksToVisit[outgoingLinks[i].get('id')]) {
+ source = target;
+ link = visit(outgoingLinks[i]);
+ }
+ }
+ }
+ return text;
+ }
+
+ /**
+ * Very basic format. From a node to the text:
+ * "name --key=value --key=value"
+ */
+ function nodeToText(element) {
+ var text = '';
+ var props = element.attr('props');
+ if (!element) {
+ return;
+ }
+ text += element.attr('metadata/name');
+ if (props) {
+ Object.keys(props).forEach(function(propertyName) {
+ text += ' --' + propertyName + '=' + props[propertyName];
+ });
+ }
+ visit(element);
+ return text;
+ }
+
+ function appendChainText(text, chainText) {
+ if (chainText) {
+ if (text) {
+ text += '\n';
+ }
+ text += chainText;
+ }
+ return text;
+ }
+
+ // Translate a graph into a basic string
+ return function(g) {
+ var text = '';
+ var chainText;
+ var id;
+ init(g);
+ while (numberOfLinksToVisit) {
+ chainText = chainToText(nextLink());
+ text = appendChainText(text, chainText);
+ }
+ // Visit all disconnected nodes
+ for (id in nodesToVisit) {
+ chainText = nodeToText(nodesToVisit[id], true);
+ text = appendChainText(text, chainText);
+ }
+ return text;
+ };
+
+});
\ No newline at end of file
diff --git a/samples/spring-flo-sample/src/main/resources/static/js/main.js b/samples/spring-flo-sample/src/main/resources/static/js/main.js
new file mode 100755
index 0000000..5255b3b
--- /dev/null
+++ b/samples/spring-flo-sample/src/main/resources/static/js/main.js
@@ -0,0 +1,83 @@
+/*
+ * Copyright 2016 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * @author Alex Boyko
+ * @author Andy Clement
+ */
+requirejs.config({
+ baseUrl:'js',
+ paths: {
+ joint: '/webjars/jointjs/dist/joint',
+ backbone: '/webjars/backbone/backbone',
+ domReady: '/webjars/requirejs-domready/domReady',
+ angular: '/webjars/angular/angular',
+ jquery: '/webjars/jquery/dist/jquery',
+ bootstrap:'/webjars/bootstrap/bootstrap',
+ lodash: '/webjars/lodash/lodash', // lodash.compat
+ dagre: '/webjars/dagre/dist/dagre.core',
+ graphlib: '/webjars/graphlib/graphlib.core',
+ text : '/webjars/requirejs-text/text',
+ flo : '/webjars/spring-flo/dist/spring-flo',
+ json5 : '/webjars/json5/json5'
+ },
+ map: {
+ '*': {
+ // Backbone requires underscore. This forces requireJS to load lodash instead:
+ 'underscore': 'lodash'
+ }
+ },
+ packages: [
+ {
+ name: 'codemirror',
+ location: '../lib/codemirror',
+ main: 'lib/codemirror'
+ }
+ ],
+ shim: {
+ angular: {
+ deps: ['bootstrap'],
+ exports: 'angular'
+ },
+ bootstrap: {
+ deps: ['jquery']
+ },
+ graphlib: {
+ deps: ['underscore']
+ },
+ dagre: {
+ deps: ['graphlib', 'underscore']
+ },
+ joint: {
+ deps: ['jquery', 'underscore', 'backbone'],
+ },
+ underscore: {
+ exports: '_'
+ },
+ 'flo': {
+ deps: ['angular', 'jquery', 'joint', 'underscore']
+ }
+ }
+});
+
+define(['require','angular'], function (require, angular) {
+ 'use strict';
+ require(['domReady!', 'sample-app'],
+ function (document) {
+ angular.bootstrap(document, ['floSampleApp']);
+ }
+ );
+});
diff --git a/samples/spring-flo-sample/src/main/resources/static/js/metamodel-sample.json b/samples/spring-flo-sample/src/main/resources/static/js/metamodel-sample.json
new file mode 100644
index 0000000..2c39505
--- /dev/null
+++ b/samples/spring-flo-sample/src/main/resources/static/js/metamodel-sample.json
@@ -0,0 +1,46 @@
+[{
+ 'name':'http', 'group':'source', 'description':'Receive HTTP input',
+ 'properties':[
+ { 'id':'port','name':'port', 'defaultValue':'80', 'description':'Port on which to listen' }
+ ],
+ 'constraints':{ 'maxIncomingLinksNumber':0, 'maxOutgoingLinksNumber':1 }
+},{
+ 'name':'rabbit', 'group':'source', 'description':'Receives messages from RabbitMQ',
+ 'properties':[
+ { 'id':'queue','name':'queue', 'description':'the queue(s) from which messages will be received' }
+ ],
+ 'constraints':{ 'maxIncomingLinksNumber':0, 'maxOutgoingLinksNumber':1 }
+},{
+ 'name':'filewatch', 'group':'source', 'description':'Produce messages from the content of files created in a directory',
+ 'properties':[
+ {'id':'dir','name':'dir','description':'the absolute path to monitor for files'}
+ ],
+ 'constraints':{ 'maxIncomingLinksNumber':0, 'maxOutgoingLinksNumber':1 }
+},{
+ 'name':'transform', 'group':'processor', 'description':'Apply an expression to modify incoming messages',
+ 'properties':[
+ { 'id':'expression','name':'expression', 'defaultValue':'payload', 'description':'SpEL expression to apply' }
+ ],
+ 'constraints':{ 'maxIncomingLinksNumber':1, 'maxOutgoingLinksNumber':1 }
+},{
+ 'name':'filter', 'group':'processor', 'description':'Only allow messages through that pass the filter expression',
+ 'properties':[
+ { 'id':'expression','name':'expression', 'defaultValue':'true', 'description':'SpEL expression to use for filtering' }
+ ],
+ 'constraints':{ 'maxIncomingLinksNumber':1, 'maxOutgoingLinksNumber':1 }
+},{
+ 'name':'filesave', 'group':'sink', 'description':'Writes messages to a file',
+ 'properties':[
+ { 'id':'dir','name':'dir', 'description':'Absolute path to directory' },
+ { 'id':'name','name':'name', 'description':'The name of the file to create' }
+ ],
+ 'constraints':{ 'maxIncomingLinksNumber':1, 'maxOutgoingLinksNumber':0 }
+},{
+ 'name':'ftp', 'group':'sink', 'description':'Send messages over FTP',
+ 'properties':[
+ { 'id':'host','name':'host', 'description':'the host name for the FTP server' },
+ { 'id':'port','name':'port', 'description':'The port for the FTP server' },
+ { 'id':'remoteDir','name':'remoteDir', 'description':'The remote directory on the server' },
+ ],
+ 'constraints':{ 'maxIncomingLinksNumber':1, 'maxOutgoingLinksNumber':0 }
+}]
\ No newline at end of file
diff --git a/samples/spring-flo-sample/src/main/resources/static/js/metamodel-service.js b/samples/spring-flo-sample/src/main/resources/static/js/metamodel-service.js
new file mode 100644
index 0000000..609bfe7
--- /dev/null
+++ b/samples/spring-flo-sample/src/main/resources/static/js/metamodel-service.js
@@ -0,0 +1,114 @@
+/*
+ * Copyright 2016 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * @author Alex Boyko
+ * @author Andy Clement
+ */
+define(function(require) {
+ 'use strict';
+
+ require('json5');
+
+ var convertGraphToText = require('graph-to-text');
+ var convertTextToGraph = require('text-to-graph');
+
+ return ['$http', '$q', '$timeout', '$log', 'MetamodelUtils', function($http, $q, $timeout, $log, metamodelUtils) {
+
+ var metamodel;
+
+ // Internally stored metamodel load promise
+ var request;
+
+ /**
+ * Helper that goes from basic JSON to a lazy getter structure. Useful when the
+ * metamodel is 'cheap' to build. If it is costly to discover the actual properties
+ * the getter may be more complex (e.g. make a REST request).
+ */
+ function createMetadata(entry) {
+ var props = {};
+ if (Array.isArray(entry.properties)) {
+ entry.properties.forEach(function(property) {
+ if (!property.id) {
+ property.id = property.name;
+ }
+ props[property.id] = property;
+ });
+ }
+ entry.properties = props;
+ return {
+ name: entry.name,
+ group: entry.group,
+ icon: entry.icon,
+ constraints: entry.constraints,
+ description: entry.description,
+ metadata: entry.metadata,
+ properties: entry.properties,
+ get: function(property) {
+ var deferred = $q.defer();
+ if (entry.hasOwnProperty(property)) {
+ deferred.resolve(entry[property]);
+ } else {
+ deferred.reject();
+ }
+ return deferred.promise;
+ }
+ };
+ }
+
+ function load() {
+ // COULDDO: to cache the result here, check result before doing this processing
+ // and simply return it if it is set. If doing that may want to override refresh
+ // in this service
+ var metamodelData = JSON5.parse(require('text!metamodel-sample.json'));
+ var deferred = $q.defer();
+ var newData = {};
+ metamodelData.forEach(function(data) {
+ var metadata = createMetadata(data);
+ if (!newData[metadata.group]) {
+ newData[metadata.group] = {};
+ }
+ newData[metadata.group][metadata.name] = metadata;
+ });
+ metamodel = newData;
+ deferred.resolve(metamodel);
+ request = deferred.promise;
+ return request;
+ }
+
+ function graphToText(flo, definition) {
+ definition.text = convertGraphToText(flo.getGraph());
+ }
+
+ function textToGraph(flo, definition) {
+ // TODO perhaps push these flo operations into the 'caller' to make this simpler
+ flo.getGraph().clear();
+ load().then(function(metamodel) {
+ convertTextToGraph(definition.text, flo, metamodel, metamodelUtils);
+ flo.performLayout();
+ flo.fitToPage();
+ });
+ }
+
+ return {
+ 'load': load,
+ 'textToGraph': textToGraph,
+ 'graphToText': graphToText
+ };
+
+ }];
+
+});
diff --git a/samples/spring-flo-sample/src/main/resources/static/js/render-service.js b/samples/spring-flo-sample/src/main/resources/static/js/render-service.js
new file mode 100644
index 0000000..cf3b383
--- /dev/null
+++ b/samples/spring-flo-sample/src/main/resources/static/js/render-service.js
@@ -0,0 +1,206 @@
+/*
+ * Copyright 2016 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * @author Alex Boyko
+ * @author Andy Clement
+ */
+define(function(require) {
+ 'use strict';
+
+ var joint = require('joint');
+
+ var dagre = require('dagre');
+
+ var HANDLE_ICON_MAP = {
+ 'remove': 'icons/delete.svg',
+ };
+
+ var DECORATION_ICON_MAP = {
+ 'error': 'icons/error.svg'
+ };
+
+ return ['$log', function($log) {
+
+ function createHandle(kind) {
+ return new joint.shapes.flo.ErrorDecoration({
+ size: {width: 10, height: 10},
+ attrs: {
+ 'image': {
+ 'xlink:href': HANDLE_ICON_MAP[kind]
+ }
+ }
+ });
+ }
+
+ function createDecoration(kind) {
+ return new joint.shapes.flo.ErrorDecoration({
+ size: {width: 16, height: 16},
+ attrs: {
+ 'image': {
+ 'xlink:href': DECORATION_ICON_MAP[kind]
+ }
+ }
+ });
+ }
+
+ function createNode(/*metadata, props*/) {
+ return new joint.shapes.flo.Node();
+ }
+
+ function initializeNewNode(node, context) {
+ var metadata = node.attr('metadata');
+ if (metadata) {
+ node.attr('.label/text', node.attr('metadata/name'));
+ if (node.attr('metadata/constraints/maxIncomingLinksNumber') === 0) {
+ node.attr('.input-port/display','none');
+ }
+ if (node.attr('metadata/constraints/maxOutgoingLinksNumber') === 0) {
+ node.attr('.output-port/display','none');
+ }
+
+ var type = node.attr('metadata/name');
+ if (type === 'tap') {
+ if (!node.attr('props/channel')) {
+ node.attr('props/channel', 'tap:stream:STREAM');
+ }
+ refreshVisuals(node, 'props/channel', context.paper);
+ } else if (type === 'named-channel') {
+ // Default channel for named channel is 'queue:default'
+ if (!node.attr('props/channel')) {
+ node.attr('props/channel', 'queue:default');
+ }
+ refreshVisuals(node, 'props/channel', context.paper);
+ }
+ }
+
+ }
+
+ function createLink() {
+ var link = new joint.shapes.flo.Link(joint.util.deepSupplement({
+ smooth: true,
+ attrs: {
+ '.': {
+ //filter: { name: 'dropShadow', args: { dx: 1, dy: 1, blur: 2 } }
+ },
+ '.connection': { 'stroke-width': 3, 'stroke': 'black', 'stroke-linecap': 'round' },
+ '.marker-arrowheads': { display: 'none' },
+ '.tool-options': { display: 'none' }
+ },
+ }, joint.shapes.flo.Link.prototype.defaults));
+ return link;
+ }
+
+ function isSemanticProperty(propertyPath) {
+ return propertyPath === '.label/text';
+ }
+
+ function refreshVisuals(element, changedPropertyPath/*, paper*/) {
+ var type = element.attr('metadata/name');
+
+ if (type === 'tap' && changedPropertyPath === 'props/channel') {
+ var name = element.attr('props/channel');
+ if (name) {
+ var colon = name.indexOf(':');
+ if (colon !== -1) {
+ colon = name.indexOf(':',colon+1);
+ if (colon !== -1) {
+ var displayValue = name.substring(0,colon) + '\n' + name.substring(colon);
+ element.attr('.label2/text', displayValue);
+ }
+ }
+ } else {
+ element.removeAttr('.label2/text');
+ }
+ }
+
+ if (type === 'named-channel' && changedPropertyPath === 'props/channel') {
+ element.attr('.label2/text', element.attr('props/channel'));
+ }
+ }
+
+ function layout(paper) {
+ var graph = paper.model;
+ var i;
+ var g = new dagre.graphlib.Graph();
+ g.setGraph({});
+ g.setDefaultEdgeLabel(function() {return{};});
+
+ var nodes = graph.getElements();
+ for (i = 0; i < nodes.length; i++) {
+ var node = nodes[i];
+ if (node.get('type') === joint.shapes.flo.NODE_TYPE) {
+ g.setNode(node.id, node.get('size'));
+ }
+ }
+
+ var links = graph.getLinks();
+ for (i = 0; i < links.length; i++) {
+ var link = links[i];
+ if (link.get('type') === joint.shapes.flo.LINK_TYPE) {
+ var options = {
+ minlen: 1.5
+ };
+// if (link.get('labels') && link.get('labels').length > 0) {
+// options.minlen = 1 + link.get('labels').length * 0.5;
+// }
+ g.setEdge(link.get('source').id, link.get('target').id, options);
+ link.set('vertices', []);
+ }
+ }
+
+ g.graph().rankdir = 'LR';
+ dagre.layout(g);
+ g.nodes().forEach(function(v) {
+ var node = graph.getCell(v);
+ if (node) {
+ var bbox = node.getBBox();
+ node.translate(g.node(v).x - bbox.x, g.node(v).y - bbox.y);
+ }
+ });
+ }
+
+ function getLinkAnchorPoint(linkView, view, magnet, reference) {
+ if (magnet) {
+ var type = magnet.getAttribute('type');
+ var bbox = joint.V(magnet).bbox(false, linkView.paper.viewport);
+ var rect = joint.g.rect(bbox);
+ if (type === 'input') {
+ return joint.g.point(rect.x, rect.y + rect.height / 2);
+ } else {
+ return joint.g.point(rect.x + rect.width, rect.y + rect.height / 2);
+ }
+ } else {
+ $log.debug('No magnet!');
+ return reference;
+ }
+ }
+
+ return {
+ 'createHandle': createHandle,
+ 'createDecoration': createDecoration,
+ 'createNode': createNode,
+ 'createLink': createLink,
+ 'initializeNewNode': initializeNewNode,
+ 'isSemanticProperty': isSemanticProperty,
+ 'refreshVisuals': refreshVisuals,
+ 'layout': layout,
+ 'getLinkAnchorPoint': getLinkAnchorPoint
+ };
+
+ }];
+
+});
diff --git a/samples/spring-flo-sample/src/main/resources/static/js/sample-app.js b/samples/spring-flo-sample/src/main/resources/static/js/sample-app.js
new file mode 100644
index 0000000..5500cda
--- /dev/null
+++ b/samples/spring-flo-sample/src/main/resources/static/js/sample-app.js
@@ -0,0 +1,30 @@
+/*
+ * Copyright 2016 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * @author Alex Boyko
+ * @author Andy Clement
+ */
+define(function(require) {
+ 'use strict';
+ var angular = require('angular');
+ require('flo');
+ var app = angular.module('floSampleApp', [ 'spring.flo' ]);
+ app.factory('SampleMetamodelService', require('metamodel-service'));
+ app.factory('SampleRenderService', require('render-service'));
+ app.factory('SampleEditorService', require('editor-service'));
+ return app;
+});
diff --git a/samples/spring-flo-sample/src/main/resources/static/js/text-to-graph.js b/samples/spring-flo-sample/src/main/resources/static/js/text-to-graph.js
new file mode 100644
index 0000000..98b85d4
--- /dev/null
+++ b/samples/spring-flo-sample/src/main/resources/static/js/text-to-graph.js
@@ -0,0 +1,71 @@
+/*
+ * Copyright 2016 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Convert a text representation to a graph.
+ *
+ * @author Alex Boyko
+ * @author Andy Clement
+ */
+define(function() {
+ 'use strict';
+
+ return function(input, flo, metamodel, metamodelUtils) {
+ // input is a string like this (3 nodes: foo, goo and hoo): foo --a=b --c=d > goo --d=e --f=g>hoo
+ var trimmed = input.trim();
+ if (trimmed.length===0) {
+ return;
+ }
+ var lines = trimmed.split('\n');
+ for (var l=0;l');
+ var lastNode = null;
+ for (var e=0;e