From 82b780e9c360a43ebcf36625075058d207868178 Mon Sep 17 00:00:00 2001
From: Jeff Avallone
Date: Fri, 16 Feb 2018 19:04:23 -0500
Subject: [PATCH] First cut of SVG rendering components
These still need work, but they're functional enough to render a diagram
---
package.json | 3 +
src/components/App/__snapshots__/test.js.snap | 5 +
src/components/App/index.js | 58 ++++---
src/components/App/style.css | 17 ++
src/components/App/test.js | 4 +
src/components/SVG/Base.js | 83 ++++++++++
src/components/SVG/Box.js | 84 ++++++++++
src/components/SVG/HorizontalLayout.js | 107 +++++++++++++
src/components/SVG/Image.js | 103 ++++++++++++
src/components/SVG/Loop.js | 144 +++++++++++++++++
src/components/SVG/Pin.js | 46 ++++++
src/components/SVG/Text.js | 51 ++++++
src/components/SVG/VerticalLayout.js | 149 ++++++++++++++++++
src/components/SVG/index.js | 41 +++++
src/components/SVG/path.js | 103 ++++++++++++
src/components/SVG/style.js | 43 +++++
src/devel.js | 138 ++++++++++++++++
yarn.lock | 13 +-
18 files changed, 1169 insertions(+), 23 deletions(-)
create mode 100644 src/components/App/style.css
create mode 100644 src/components/SVG/Base.js
create mode 100644 src/components/SVG/Box.js
create mode 100644 src/components/SVG/HorizontalLayout.js
create mode 100644 src/components/SVG/Image.js
create mode 100644 src/components/SVG/Loop.js
create mode 100644 src/components/SVG/Pin.js
create mode 100644 src/components/SVG/Text.js
create mode 100644 src/components/SVG/VerticalLayout.js
create mode 100644 src/components/SVG/index.js
create mode 100644 src/components/SVG/path.js
create mode 100644 src/components/SVG/style.js
create mode 100644 src/devel.js
diff --git a/package.json b/package.json
index 0ddd68e..d2216cc 100644
--- a/package.json
+++ b/package.json
@@ -36,6 +36,7 @@
"plugins": [
"transform-runtime",
"transform-class-properties",
+ "transform-object-rest-spread",
"syntax-dynamic-import"
]
},
@@ -120,6 +121,7 @@
"babel-loader": "^7.1.2",
"babel-plugin-syntax-dynamic-import": "^6.18.0",
"babel-plugin-transform-class-properties": "^6.24.1",
+ "babel-plugin-transform-object-rest-spread": "^6.26.0",
"babel-plugin-transform-runtime": "^6.23.0",
"babel-preset-env": "^1.6.1",
"babel-preset-react": "^6.24.1",
@@ -142,6 +144,7 @@
"i18next": "^10.3.0",
"i18next-browser-languagedetector": "^2.1.0",
"identity-obj-proxy": "^3.0.0",
+ "immutable": "^3.8.2",
"jest": "^22.2.2",
"npm-run-all": "^4.1.2",
"postcss-cssnext": "^3.1.0",
diff --git a/src/components/App/__snapshots__/test.js.snap b/src/components/App/__snapshots__/test.js.snap
index cc58273..c2aa245 100644
--- a/src/components/App/__snapshots__/test.js.snap
+++ b/src/components/App/__snapshots__/test.js.snap
@@ -44,5 +44,10 @@ exports[`App rendering 1`] = `
Sample warning message
+
+ Testing image
+
`;
diff --git a/src/components/App/index.js b/src/components/App/index.js
index 9fda3e8..2d76b43 100644
--- a/src/components/App/index.js
+++ b/src/components/App/index.js
@@ -1,32 +1,46 @@
import React from 'react';
+import style from './style.css';
+
import Form from 'components/Form';
import Message from 'components/Message';
+import renderImage from 'components/SVG';
+import { syntaxes, demoImage } from 'devel';
-const syntaxes = {
- js: 'JavaScript',
- pcre: 'PCRE'
-};
+class App extends React.PureComponent {
+ state = {
+ syntaxes,
+ image: demoImage,
+ downloadUrls: [
+ { url: '#svg', filename: 'image.svg', type: 'image/svg+xml', label: 'Download SVG' },
+ { url: '#png', filename: 'image.png', type: 'image/png', label: 'Download PNG' }
+ ]
+ }
-const downloadUrls = [
- { url: '#svg', filename: 'image.svg', type: 'image/svg+xml', label: 'Download SVG' },
- { url: '#png', filename: 'image.png', type: 'image/png', label: 'Download PNG' }
-];
+ handleSubmit = ({expr, syntax}) => {
+ console.log(syntax, expr); // eslint-disable-line no-console
+ }
-const handleSubmit = ({ expr, syntax}) => console.log(syntax, expr); // eslint-disable-line no-console
+ render() {
+ const { downloadUrls, syntaxes, image } = this.state;
-const App = () =>
-
-
- Sample error message
-
-
- Sample warning message
-
-;
+ return
+
+
+ Sample error message
+
+
+ Sample warning message
+
+
+ { renderImage(image) }
+
+ ;
+ }
+}
export default App;
diff --git a/src/components/App/style.css b/src/components/App/style.css
new file mode 100644
index 0000000..cecae30
--- /dev/null
+++ b/src/components/App/style.css
@@ -0,0 +1,17 @@
+@import url('../../globals.css');
+
+.render {
+ display: flex;
+ justify-content: center;
+ width: 100%;
+ background: var(--color-white);
+ box-sizing: border-box;
+ overflow: auto;
+ margin: var(--spacing-margin) 0;
+
+ & svg {
+ display: block;
+ transform: scaleZ(1); /* Move to separate render layer in Chrome */
+ flex-shrink: 0;
+ }
+}
diff --git a/src/components/App/test.js b/src/components/App/test.js
index 02dbd93..0ed05a8 100644
--- a/src/components/App/test.js
+++ b/src/components/App/test.js
@@ -1,10 +1,14 @@
+jest.mock('components/SVG');
+
import React from 'react';
import { shallow } from 'enzyme';
import App from 'components/App';
+import renderImage from 'components/SVG';
describe('App', () => {
test('rendering', () => {
+ renderImage.mockReturnValue('Testing image');
const component = shallow(
);
diff --git a/src/components/SVG/Base.js b/src/components/SVG/Base.js
new file mode 100644
index 0000000..b7c1f91
--- /dev/null
+++ b/src/components/SVG/Base.js
@@ -0,0 +1,83 @@
+import React from 'react';
+import { Map } from 'immutable';
+
+class Base extends React.PureComponent {
+ _currentBBox() {
+ return this.tempBBox ? this.tempBBox : (this.state || {}).bbox;
+ }
+
+ setBBox(box, recalculate = {}) {
+ let bbox = this._currentBBox() || Map({ width: 0, height: 0});
+
+ bbox = bbox.merge(box);
+
+ if (!bbox.has('axisY') || recalculate.axisY) {
+ bbox = bbox.set('axisY', bbox.get('height') / 2);
+ }
+
+ if (!bbox.has('axisX1') || recalculate.axisX1) {
+ bbox = bbox.set('axisX1', 0);
+ }
+
+ if (!bbox.has('axisX2') || recalculate.axisX2) {
+ bbox = bbox.set('axisX2', bbox.get('width'));
+ }
+
+ this.tempBBox = bbox; // Want to get the updated bbox while setState is pending
+ this.setState({ bbox }, () => {
+ this.tempBBox = null;
+ });
+ }
+
+ getBBox() {
+ const bbox = this._currentBBox() || Map();
+ return {
+ width: 0,
+ height: 0,
+ axisY: 0,
+ axisX1: 0,
+ axisX2: 0,
+ ...bbox.toJS()
+ };
+ }
+
+ async doPreReflow() {
+ const components = this.preReflow();
+
+ // No child components
+ if (components === undefined) {
+ return true;
+ }
+
+ // List of child components
+ if (components.map) {
+ const componentsReflowed = await Promise.all(components.map(c => c.doReflow()));
+
+ return componentsReflowed.reduce((memo, value) => memo || value, false);
+ }
+
+ // One child component
+ return components.doReflow();
+ }
+
+ async doReflow() {
+ const oldBBox = this._currentBBox();
+ const shouldReflow = await this.doPreReflow();
+
+ if (shouldReflow) {
+ this.reflow();
+ }
+
+ return this._currentBBox() !== oldBBox;
+ }
+
+ preReflow() {
+ // Implemented in subclass, return array of components to reflow before this
+ }
+
+ reflow() {
+ // Implemented in subclasses
+ }
+}
+
+export default Base;
diff --git a/src/components/SVG/Box.js b/src/components/SVG/Box.js
new file mode 100644
index 0000000..a3bd49f
--- /dev/null
+++ b/src/components/SVG/Box.js
@@ -0,0 +1,84 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import Base from './Base';
+import style from './style';
+
+/** @extends React.PureComponent */
+class Box extends Base {
+ static defaultProps = {
+ padding: 5
+ }
+
+ preReflow() {
+ return this.contained;
+ }
+
+ reflow() {
+ return new Promise(resolve => {
+ const { padding, useAnchors } = this.props;
+ const box = this.contained.getBBox();
+ const labelBox = this.label ? this.label.getBBox() : { width: 0, height: 0};
+
+ this.setBBox({
+ width: Math.max(box.width + 2 * padding, labelBox.width),
+ height: box.height + 2 * padding + labelBox.height,
+ axisY: (useAnchors ? box.axisY : box.height / 2) + padding + labelBox.height,
+ axisX1: useAnchors ? box.axisX1 + padding : 0,
+ axisX2: useAnchors ? box.axisX2 + padding : box.width + 2 * padding
+ });
+
+ this.setState({
+ width: this.getBBox().width,
+ height: box.height + 2 * padding,
+ contentTransform: `translate(${ padding } ${ padding + labelBox.height })`,
+ rectTransform: `translate(0 ${ labelBox.height })`,
+ labelTransform: `translate(0 ${ labelBox.height })`
+ }, resolve);
+ });
+ }
+
+ containedRef = contained => this.contained = contained
+
+ labelRef = label => this.label = label
+
+ render() {
+ const { style: propStyle, radius, label, children } = this.props;
+ const { width, height, labelTransform, rectTransform, contentTransform } = this.state || {};
+
+ const rectProps = {
+ style: propStyle,
+ width,
+ height,
+ rx: radius,
+ ry: radius,
+ transform: rectTransform
+ };
+ const textProps = {
+ transform: labelTransform,
+ style: style.infoText,
+ ref: this.labelRef
+ };
+
+ return
+
+ { label && { label } }
+
+ { React.cloneElement(React.Children.only(children), {
+ ref: this.containedRef
+ }) }
+
+ ;
+ }
+}
+
+Box.propTypes = {
+ padding: PropTypes.number,
+ useAnchors: PropTypes.bool,
+ style: PropTypes.object,
+ radius: PropTypes.number,
+ label: PropTypes.string,
+ children: PropTypes.node
+};
+
+export default Box;
diff --git a/src/components/SVG/HorizontalLayout.js b/src/components/SVG/HorizontalLayout.js
new file mode 100644
index 0000000..1d89bc2
--- /dev/null
+++ b/src/components/SVG/HorizontalLayout.js
@@ -0,0 +1,107 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { List } from 'immutable';
+
+import Base from './Base';
+import style from './style';
+import Path from './path';
+
+/** @extends React.PureComponent */
+class HorizontalLayout extends Base {
+ static defaultProps = {
+ withConnectors: false,
+ spacing: 10
+ }
+
+ constructor(props) {
+ super(props);
+ this.state = {
+ childTransforms: List()
+ };
+ }
+
+ updateChildTransforms(childBoxes) {
+ return childBoxes.reduce((transforms, box, i) => (
+ transforms.set(i, `translate(${ box.offsetX } ${ box.offsetY })`)
+ ), this.state.childTransforms);
+ }
+
+ updateConnectorPaths(childBoxes) {
+ let last = childBoxes[0];
+
+ return childBoxes.slice(1).reduce((path, box) => {
+ try {
+ return path
+ .moveTo({ x: last.offsetX + last.axisX2, y: this.getBBox().axisY })
+ .lineTo({ x: box.offsetX + box.axisX1 });
+ }
+ finally {
+ last = box;
+ }
+ }, new Path()).toString();
+ }
+
+ preReflow() {
+ return this.children;
+ }
+
+ reflow() {
+ if (this.children.length === 0) {
+ return;
+ }
+
+ return new Promise(resolve => {
+ const { spacing, withConnectors } = this.props;
+
+ const childBoxes = this.children.map(child => child.getBBox());
+ const verticalCenter = childBoxes.reduce((center, box) => Math.max(center, box.axisY), 0);
+ const width = childBoxes.reduce((width, box) => width + box.width, 0) + (childBoxes.length - 1) * spacing;
+ const height = childBoxes.reduce((ascHeight, box) => Math.max(ascHeight, box.axisY), 0) +
+ childBoxes.reduce((decHeight, box) => Math.max(decHeight, box.height - box.axisY), 0);
+ this.setBBox({ width, height, axisY: verticalCenter }, { axisX1: true, axisX2: true });
+
+ let offset = 0;
+ childBoxes.forEach(box => {
+ box.offsetX = offset;
+ box.offsetY = this.getBBox().axisY - box.axisY;
+ offset += box.width + spacing;
+ });
+
+ this.setState({
+ childTransforms: this.updateChildTransforms(childBoxes),
+ connectorPaths: withConnectors ? this.updateConnectorPaths(childBoxes) : ''
+ }, resolve);
+ });
+ }
+
+ childRef = i => child => this.children[i] = child
+
+ render() {
+ const { children } = this.props;
+ const { childTransforms, connectorPaths } = this.state;
+
+ this.children = [];
+
+ return
+
+ { React.Children.map(children, (child, i) => (
+
+ { React.cloneElement(child, {
+ ref: this.childRef(i)
+ }) }
+
+ ))}
+ ;
+ }
+}
+
+HorizontalLayout.propTypes = {
+ spacing: PropTypes.number,
+ withConnectors: PropTypes.bool,
+ children: PropTypes.oneOfType([
+ PropTypes.arrayOf(PropTypes.node),
+ PropTypes.node
+ ]).isRequired
+};
+
+export default HorizontalLayout;
diff --git a/src/components/SVG/Image.js b/src/components/SVG/Image.js
new file mode 100644
index 0000000..58af442
--- /dev/null
+++ b/src/components/SVG/Image.js
@@ -0,0 +1,103 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import Base from './Base';
+import style from './style';
+
+const namespaceProps = {
+ 'xmlns': 'http://www.w3.org/2000/svg',
+ 'xmlns:cc': 'http://creativecommons.org/ns#',
+ 'xmlns:rdf': 'http://www.w3.org/1999/02/22-rdf-syntax-ns#'
+};
+const metadata = `
+
+
+
+
+
+
+
+`;
+
+/** @extends React.PureComponent */
+class Image extends Base {
+ static defaultProps = {
+ padding: 10
+ }
+
+ publishedMarkup = ''
+
+ state = {
+ width: 0,
+ height: 0
+ }
+
+ componentDidMount() {
+ this.doReflow().then(() => this.publishMarkup());
+ }
+
+ componentDidUpdate() {
+ this.doReflow().then(() => this.publishMarkup());
+ }
+
+ publishMarkup() {
+ const { onRender } = this.props;
+ const markup = this.svg.outerHTML;
+
+ if (onRender && this.publishedMarkup !== markup) {
+ this.publishedMarkup = markup;
+ onRender(this.svg.outerHTML);
+ }
+ }
+
+ preReflow() {
+ return this.contained;
+ }
+
+ reflow() {
+ return new Promise(resolve => {
+ const { padding } = this.props;
+ const box = this.contained.getBBox();
+
+ this.setState({
+ width: Math.round(box.width + 2 * padding),
+ height: Math.round(box.height + 2 * padding)
+ }, resolve);
+ });
+ }
+
+ containedRef = contained => this.contained = contained
+
+ svgRef = svg => this.svg = svg
+
+ render() {
+ const { width, height } = this.state;
+ const { padding, children } = this.props;
+
+ const svgProps = {
+ width,
+ height,
+ viewBox: [0, 0, width, height].join(' '),
+ style: style.image,
+ ref: this.svgRef,
+ ...namespaceProps
+ };
+
+ return ;
+ }
+}
+
+Image.propTypes = {
+ onRender: PropTypes.func,
+ padding: PropTypes.number,
+ children: PropTypes.node
+};
+
+export default Image;
diff --git a/src/components/SVG/Loop.js b/src/components/SVG/Loop.js
new file mode 100644
index 0000000..fc82b2a
--- /dev/null
+++ b/src/components/SVG/Loop.js
@@ -0,0 +1,144 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import Base from './Base';
+import style from './style';
+import Path from './path';
+
+const skipPath = (box, greedy) => {
+ const vert = Math.max(0, box.axisY - 10);
+ const horiz = box.width - 10;
+
+ let path = new Path({ relative: true });
+
+ if (!greedy) {
+ path
+ .moveTo({ x: 10, y: box.axisY + box.offsetY - 15, relative: false })
+ .lineTo({ x: 5, y: 5 })
+ .moveTo({ x: -5, y: -5 })
+ .lineTo({ x: -5, y: 5 });
+ }
+
+ return path
+ .moveTo({ x: 0, y: box.axisY + box.offsetY, relative: false })
+ .quadraticCurveTo({ cx: 10, cy: 0, x: 10, y: -10 })
+ .lineTo({ y: -vert })
+ .quadraticCurveTo({ cx: 0, cy: -10, x: 10, y: -10 })
+ .lineTo({ x: horiz })
+ .quadraticCurveTo({ cx: 10, cy: 0, x: 10, y: 10 })
+ .lineTo({ y: vert })
+ .quadraticCurveTo({ cx: 0, cy: 10, x: 10, y: 10 });
+};
+
+const repeatPath = (box, greedy) => {
+ const vert = box.height - box.axisY - 10;
+
+ let path = new Path({ relative: true });
+
+ if (greedy) {
+ path
+ .moveTo({ x: box.offsetX + box.width + 10, y: box.axisY + box.offsetY + 15, relative: false })
+ .lineTo({ x: 5, y: -5 })
+ .moveTo({ x: -5, y: 5 })
+ .lineTo({ x: -5, y: -5 });
+ }
+
+ return path
+ .moveTo({ x: box.offsetX, y: box.axisY + box.offsetY, relative: false })
+ .quadraticCurveTo({ cx: -10, cy: 0, x: -10, y: 10 })
+ .lineTo({ y: vert })
+ .quadraticCurveTo({ cx: 0, cy: 10, x: 10, y: 10 })
+ .lineTo({ x: box.width })
+ .quadraticCurveTo({ cx: 10, cy: 0, x: 10, y: -10 })
+ .lineTo({ y: -vert })
+ .quadraticCurveTo({ cx: 0, cy: -10, x: -10, y: -10 });
+};
+
+/** @extends React.PureComponent */
+class Loop extends Base {
+ get contentOffset() {
+ const { skip, repeat } = this.props;
+
+ if (skip) {
+ return { x: 15, y: 10 };
+ } else if (repeat) {
+ return { x: 10, y: 0 };
+ } else {
+ return { x: 0, y: 0 };
+ }
+ }
+
+ preReflow() {
+ return this.contained;
+ }
+
+ reflow() {
+ return new Promise(resolve => {
+ const { skip, repeat, greedy } = this.props;
+ const box = this.contained.getBBox();
+ const labelBox = this.label ? this.label.getBBox() : { width: 0, height: 0 };
+
+ let height = box.height + labelBox.height;
+ if (skip) {
+ height += 10;
+ }
+ if (repeat) {
+ height += 10;
+ }
+
+ this.setBBox({
+ width: box.width + this.contentOffset.x * 2,
+ height,
+ axisY: box.axisY + this.contentOffset.y,
+ axisX1: box.axisX1 + this.contentOffset.x,
+ axisX2: box.axisX2 + this.contentOffset.x
+ });
+
+ box.offsetX = this.contentOffset.x;
+ box.offsetY = this.contentOffset.y;
+
+ this.setState({
+ labelTransform: `translate(${ this.getBBox().width - labelBox.width - 10 } ${ this.getBBox().height + 2 })`,
+ loopPaths: [
+ skip && skipPath(box, greedy),
+ repeat && repeatPath(box, greedy)
+ ].filter(Boolean).join('')
+ }, resolve);
+ });
+ }
+
+ containedRef = contained => this.contained = contained
+
+ labelRef = label => this.label = label
+
+ render() {
+ const { label, children } = this.props;
+ const { loopPaths, labelTransform } = this.state || {};
+
+ const textProps = {
+ transform: labelTransform,
+ style: style.infoText,
+ ref: this.labelRef
+ };
+
+ return
+
+ { label && { label } }
+
+ { React.cloneElement(React.Children.only(children), {
+ ref: this.containedRef
+ }) }
+
+ ;
+ }
+}
+
+Loop.propTypes = {
+ skip: PropTypes.bool,
+ repeat: PropTypes.bool,
+ greedy: PropTypes.bool,
+ label: PropTypes.string,
+ children: PropTypes.node.isRequired
+};
+
+export default Loop;
diff --git a/src/components/SVG/Pin.js b/src/components/SVG/Pin.js
new file mode 100644
index 0000000..8f7ab80
--- /dev/null
+++ b/src/components/SVG/Pin.js
@@ -0,0 +1,46 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import Base from './Base';
+import style from './style';
+
+/** @extends React.PureComponent */
+class Pin extends Base {
+ static defaultProps = {
+ radius: 5
+ }
+
+ reflow() {
+ return new Promise(resolve => {
+ const { radius } = this.props;
+
+ this.setBBox({
+ width: radius * 2,
+ height: radius * 2
+ });
+
+ this.setState({
+ transform: `translate(${ radius } ${ radius })`
+ }, resolve);
+ });
+ }
+
+ render() {
+ const { radius } = this.props;
+ const { transform } = this.state || {};
+
+ const circleProps = {
+ r: radius,
+ style: style.pin,
+ transform
+ };
+
+ return ;
+ }
+}
+
+Pin.propTypes = {
+ radius: PropTypes.number
+};
+
+export default Pin;
diff --git a/src/components/SVG/Text.js b/src/components/SVG/Text.js
new file mode 100644
index 0000000..516014a
--- /dev/null
+++ b/src/components/SVG/Text.js
@@ -0,0 +1,51 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import Base from './Base';
+import style from './style';
+
+/** @extends React.PureComponent */
+class Text extends Base {
+ reflow() {
+ return new Promise(resolve => {
+ const box = this.text.getBBox();
+
+ this.setBBox({
+ width: box.width,
+ height: box.height
+ });
+
+ this.setState({
+ transform: `translate(${-box.x} ${-box.y})`
+ }, resolve);
+ });
+ }
+
+ textRef = text => this.text = text
+
+ render() {
+ const { transform } = this.state || {};
+ const { style: styleProp, children } = this.props;
+
+ const textProps = {
+ style: { ...style.text, ...styleProp },
+ transform,
+ ref: this.textRef
+ };
+
+ return
+ { children }
+ ;
+ }
+}
+
+Text.propTypes = {
+ style: PropTypes.object,
+ transform: PropTypes.string,
+ children: PropTypes.oneOfType([
+ PropTypes.arrayOf(PropTypes.node),
+ PropTypes.node
+ ]).isRequired
+};
+
+export default Text;
diff --git a/src/components/SVG/VerticalLayout.js b/src/components/SVG/VerticalLayout.js
new file mode 100644
index 0000000..f092fbd
--- /dev/null
+++ b/src/components/SVG/VerticalLayout.js
@@ -0,0 +1,149 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { List } from 'immutable';
+
+import Base from './Base';
+import style from './style';
+import Path from './path';
+
+const connectorMargin = 20;
+
+/** @extends React.PureComponent */
+class VerticalLayout extends Base {
+ static defaultProps = {
+ withConnectors: false,
+ spacing: 10
+ }
+
+ constructor(props) {
+ super(props);
+ this.state = {
+ childTransforms: List()
+ };
+ }
+
+ updateChildTransforms(childBoxes) {
+ return childBoxes.reduce((transforms, box, i) => (
+ transforms.set(i, `translate(${ box.offsetX } ${ box.offsetY })`)
+ ), this.state.childTransforms);
+ }
+
+ makeCurve(box) {
+ const thisBox = this.getBBox();
+ const distance = Math.abs(box.offsetY + box.axisY - thisBox.axisY);
+
+ if (distance >= 15) {
+ const curve = (box.axisY + box.offsetY > thisBox.axisY) ? 10 : -10;
+
+ return new Path()
+ // Left
+ .moveTo({ x: 10, y: box.axisY + box.offsetY - curve })
+ .quadraticCurveTo({ cx: 0, cy: curve, x: 10, y: curve, relative: true })
+ .lineTo({ x: box.offsetX + box.axisX1 })
+ // Right
+ .moveTo({ x: thisBox.width - 10, y: box.axisY + box.offsetY - curve })
+ .quadraticCurveTo({ cx: 0, cy: curve, x: -10, y: curve, relative: true })
+ .lineTo({ x: box.offsetX + box.axisX2 });
+ } else {
+ const anchor = box.offsetY + box.axisY - thisBox.axisY;
+
+ return new Path()
+ // Left
+ .moveTo({ x: 0, y: thisBox.axisY })
+ .cubicCurveTo({ cx1: 15, cy1: 0, cx2: 10, cy2: anchor, x: 20, y: anchor, relative: true })
+ .lineTo({ x: box.offsetX + box.axisX1 })
+ // Right
+ .moveTo({ x: thisBox.width, y: thisBox.axisY })
+ .cubicCurveTo({ cx1: -15, cy1: 0, cx2: -10, cy2: anchor, x: -20, y: anchor, relative: true })
+ .lineTo({ x: box.offsetX + box.axisX2 });
+ }
+ }
+
+ makeSide(box) {
+ const thisBox = this.getBBox();
+ const distance = Math.abs(box.offsetY + box.axisY - thisBox.axisY);
+
+ if (distance >= 15) {
+ const shift = (box.offsetY + box.axisY > thisBox.axisY) ? 10 : -10;
+ const edge = box.offsetY + box.axisY - shift;
+
+ return new Path()
+ // Left
+ .moveTo({ x: 0, y: thisBox.axisY })
+ .quadraticCurveTo({ cx: 10, cy: 0, x: 10, y: shift, relative: true })
+ .lineTo({ y: edge })
+ // Right
+ .moveTo({ x: thisBox.width, y: thisBox.axisY })
+ .quadraticCurveTo({ cx: -10, cy: 0, x: -10, y: shift, relative: true })
+ .lineTo({ y: edge });
+ }
+ }
+
+ preReflow() {
+ return this.children;
+ }
+
+ reflow() {
+ if (this.children.length === 0) {
+ return Promise.resolve();
+ }
+
+ return new Promise(resolve => {
+ const { spacing, withConnectors } = this.props;
+
+ const childBoxes = this.children.map(child => child.getBBox());
+ const horizontalCenter = childBoxes.reduce((center, box) => Math.max(center, box.width / 2), 0);
+ const margin = withConnectors ? connectorMargin : 0;
+ const width = childBoxes.reduce((width, box) => Math.max(width, box.width), 0) + 2 * margin;
+ const height = childBoxes.reduce((height, box) => height + box.height, 0) + (childBoxes.length - 1) * spacing;
+ this.setBBox({ width, height }, { axisY: true, axisX1: true, axisX2: true });
+
+ let offset = 0;
+ childBoxes.forEach(box => {
+ box.offsetX = horizontalCenter - box.width / 2 + margin;
+ box.offsetY = offset;
+ offset += spacing + box.height;
+ });
+
+ this.setState({
+ childTransforms: this.updateChildTransforms(childBoxes),
+ connectorPaths: withConnectors ? [
+ ...childBoxes.map(box => this.makeCurve(box)),
+ this.makeSide(childBoxes[0]),
+ this.makeSide(childBoxes[childBoxes.length - 1])
+ ].join('') : ''
+ }, resolve);
+ });
+ }
+
+ childRef = i => child => this.children[i] = child
+
+ render() {
+ const { children } = this.props;
+ const { childTransforms, connectorPaths } = this.state;
+
+ this.children = [];
+
+ return
+
+ { React.Children.map(children, (child, i) => (
+
+ { React.cloneElement(child, {
+ ref: this.childRef(i)
+ }) }
+
+ )) }
+ ;
+ }
+}
+
+VerticalLayout.propTypes = {
+ spacing: PropTypes.number,
+ withConnectors: PropTypes.bool,
+ children: PropTypes.oneOfType([
+ PropTypes.arrayOf(PropTypes.node),
+ PropTypes.node
+ ]).isRequired
+};
+
+export default VerticalLayout;
diff --git a/src/components/SVG/index.js b/src/components/SVG/index.js
new file mode 100644
index 0000000..324a557
--- /dev/null
+++ b/src/components/SVG/index.js
@@ -0,0 +1,41 @@
+import React from 'react';
+
+import Box from './Box';
+import HorizontalLayout from './HorizontalLayout';
+import Image from './Image';
+import Loop from './Loop';
+import Pin from './Pin';
+import Text from './Text';
+import VerticalLayout from './VerticalLayout';
+
+const nodeTypes = {
+ Box,
+ HorizontalLayout,
+ Image,
+ Loop,
+ Pin,
+ Text,
+ VerticalLayout
+};
+
+const renderChildren = children => {
+ if (!children || children.length === 0) {
+ return;
+ }
+
+ return children.length === 1 ?
+ renderImage(children[0]) :
+ children.map(renderImage);
+};
+
+const renderImage = (node, key) => {
+ if (typeof node === 'string') {
+ return node;
+ }
+
+ const { type, props, children } = node;
+
+ return React.createElement(nodeTypes[type], { key, ...props }, renderChildren(children));
+};
+
+export default renderImage;
diff --git a/src/components/SVG/path.js b/src/components/SVG/path.js
new file mode 100644
index 0000000..7cde0e5
--- /dev/null
+++ b/src/components/SVG/path.js
@@ -0,0 +1,103 @@
+class Path {
+ constructor({ relative } = {}) {
+ this.relative = relative || false;
+ this.currentPosition = { x: 0, y: 0 };
+ this.lastStartPosition = { x: 0, y: 0 };
+ this.pathParts = [];
+ }
+
+ moveTo({ x, y, relative }) {
+ relative = relative === undefined ? this.relative : relative;
+ const command = relative ? 'm' : 'M';
+
+ if (relative) {
+ this.currentPosition = {
+ x: x + this.currentPosition.x,
+ y: y + this.currentPosition.y
+ };
+ } else {
+ this.currentPosition = { x, y };
+ }
+
+ this.pathParts.push(`${command}${x},${y}`);
+ this.lastStartPosition = { ...this.currentPosition };
+
+ return this;
+ }
+
+ lineTo({ x, y, relative }) {
+ relative = relative === undefined ? this.relative : relative;
+ if (x !== undefined && (y === undefined || y === this.currentPosition.y)) {
+ const command = relative ? 'h' : 'H';
+ this.pathParts.push(`${command}${x}`);
+ this.currentPosition.x = relative ? this.currentPosition.x + x : x;
+ } else if (y !== undefined && (x === undefined || x === this.currentPosition.x)) {
+ const command = relative ? 'v' : 'V';
+ this.pathParts.push(`${command}${y}`);
+ this.currentPosition.y = relative ? this.currentPosition.y + y : y;
+ } else {
+ const command = relative ? 'l' : 'L';
+ this.pathParts.push(`${command}${x},${y}`);
+ this.currentPosition.x = relative ? this.currentPosition.x + x : x;
+ this.currentPosition.y = relative ? this.currentPosition.y + y : y;
+ }
+
+ return this;
+ }
+
+ closePath() {
+ this.pathParts.push('Z');
+ this.currentPosition = { ...this.lastStartPosition };
+ return this;
+ }
+
+ cubicCurveTo({ cx1, cy1, cx2, cy2, x, y, relative }) {
+ relative = relative === undefined ? this.relative : relative;
+ if (cx1 === undefined || cy1 === undefined) {
+ const command = relative ? 's' : 'S';
+ this.pathParts.push(`${command}${cx2},${cy2} ${x},${y}`);
+ } else {
+ const command = relative ? 'c' : 'C';
+ this.pathParts.push(`${command}${cx1},${cy1} ${cx2},${cy2} ${x},${y}`);
+ }
+
+ this.currentPosition.x = relative ? this.currentPosition.x + x : x;
+ this.currentPosition.y = relative ? this.currentPosition.y + y : y;
+
+ return this;
+ }
+
+ quadraticCurveTo({ cx, cy, x, y, relative }) {
+ relative = relative === undefined ? this.relative : relative;
+ if (cx === undefined || cy === undefined) {
+ const command = relative ? 't' : 'T';
+ this.pathParts.push(`${command} ${x},${y}`);
+ } else {
+ const command = relative ? 'q' : 'Q';
+ this.pathParts.push(`${command}${cx},${cy} ${x},${y}`);
+ }
+
+ this.currentPosition.x = relative ? this.currentPosition.x + x : x;
+ this.currentPosition.y = relative ? this.currentPosition.y + y : y;
+
+ return this;
+ }
+
+ arcTo({ rx, ry, rotation, arc, sweep, x, y, relative }) {
+ relative = relative === undefined ? this.relative : relative;
+ const command = relative ? 'a' : 'A';
+
+ this.pathParts.push(`${command}${rx},${ry} ${rotation} ${arc} ${sweep},${x},${y}`);
+
+ this.currentPosition.x = relative ? this.currentPosition.x + x : x;
+ this.currentPosition.y = relative ? this.currentPosition.y + y : y;
+
+ return this;
+ }
+
+ toString() {
+ return this.pathParts.join('');
+ }
+}
+
+export default Path;
diff --git a/src/components/SVG/style.js b/src/components/SVG/style.js
new file mode 100644
index 0000000..e55de45
--- /dev/null
+++ b/src/components/SVG/style.js
@@ -0,0 +1,43 @@
+// Styles are in JS instead of CSS so they will be inlined as attributes
+// instead of served as a CSS file. This is so styles are included in
+// downloaded SVG files.
+
+//const green = '#bada55';
+const brown = '#6b6659';
+//const tan = '#cbcbba';
+const black = '#000';
+const white = '#fff';
+//const red = '#b3151a';
+//const orange = '#fa0';
+
+const fontFamily = 'Arial';
+const fontSize = '16px';
+const fontSizeSmall = '12px';
+
+const strokeBase = {
+ strokeWidth: '2px',
+ stroke: black
+};
+
+export default {
+ image: {
+ backgroundColor: white
+ },
+ connectors: {
+ fillOpacity: 0,
+ ...strokeBase
+ },
+ text: {
+ fontSize: fontSize,
+ fontFamily: fontFamily
+ },
+ infoText: {
+ fontSize: fontSizeSmall,
+ fontFamily: fontFamily,
+ dominantBaseline: 'text-after-edge'
+ },
+ pin: {
+ fill: brown,
+ ...strokeBase
+ }
+};
diff --git a/src/devel.js b/src/devel.js
new file mode 100644
index 0000000..3171ca3
--- /dev/null
+++ b/src/devel.js
@@ -0,0 +1,138 @@
+// Data used during development.
+// Once everything is built, this file will go away
+
+const syntaxes = {
+ js: 'JavaScript',
+ pcre: 'PCRE'
+};
+
+const demoImage = {
+ type: 'Image',
+ children: [
+ {
+ type: 'HorizontalLayout',
+ props: {
+ withConnectors: true
+ },
+ children: [
+ {
+ type: 'Pin'
+ },
+ {
+ type: 'VerticalLayout',
+ props: {
+ withConnectors: true
+ },
+ children: [
+ {
+ type: 'Loop',
+ props: {
+ skip: false,
+ repeat: true
+ },
+ children: [
+ {
+ type: 'Box',
+ props: {
+ style: { fill: '#bada55' },
+ radius: 3
+ },
+ children: [
+ {
+ type: 'Text',
+ children: [
+ 'Demo Text'
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ type: 'Loop',
+ props: {
+ skip: true,
+ repeat: false
+ },
+ children: [
+ {
+ type: 'Box',
+ props: {
+ style: { fill: '#bada55' },
+ radius: 3
+ },
+ children: [
+ {
+ type: 'Text',
+ children: [
+ 'Demo Text'
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ type: 'Loop',
+ props: {
+ skip: true,
+ repeat: true,
+ greedy: true
+ },
+ children: [
+ {
+ type: 'Box',
+ props: {
+ style: { fill: '#bada55' },
+ radius: 3
+ },
+ children: [
+ {
+ type: 'Text',
+ children: [
+ 'Demo Text'
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ type: 'Loop',
+ props: {
+ skip: true,
+ repeat: true,
+ label: 'Loop label'
+ },
+ children: [
+ {
+ type: 'Box',
+ props: {
+ style: { fill: '#bada55' },
+ radius: 3
+ },
+ children: [
+ {
+ type: 'Text',
+ children: [
+ 'Demo Text'
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ type: 'Pin'
+ }
+ ]
+ }
+ ]
+};
+
+export {
+ syntaxes,
+ demoImage
+};
diff --git a/yarn.lock b/yarn.lock
index 2982caf..fa32320 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -650,7 +650,7 @@ babel-plugin-syntax-jsx@^6.3.13, babel-plugin-syntax-jsx@^6.8.0:
version "6.18.0"
resolved "https://registry.yarnpkg.com/babel-plugin-syntax-jsx/-/babel-plugin-syntax-jsx-6.18.0.tgz#0af32a9a6e13ca7a3fd5069e62d7b0f58d0d8946"
-babel-plugin-syntax-object-rest-spread@^6.13.0:
+babel-plugin-syntax-object-rest-spread@^6.13.0, babel-plugin-syntax-object-rest-spread@^6.8.0:
version "6.13.0"
resolved "https://registry.yarnpkg.com/babel-plugin-syntax-object-rest-spread/-/babel-plugin-syntax-object-rest-spread-6.13.0.tgz#fd6536f2bce13836ffa3a5458c4903a597bb3bf5"
@@ -858,6 +858,13 @@ babel-plugin-transform-flow-strip-types@^6.22.0:
babel-plugin-syntax-flow "^6.18.0"
babel-runtime "^6.22.0"
+babel-plugin-transform-object-rest-spread@^6.26.0:
+ version "6.26.0"
+ resolved "https://registry.yarnpkg.com/babel-plugin-transform-object-rest-spread/-/babel-plugin-transform-object-rest-spread-6.26.0.tgz#0f36692d50fef6b7e2d4b3ac1478137a963b7b06"
+ dependencies:
+ babel-plugin-syntax-object-rest-spread "^6.8.0"
+ babel-runtime "^6.26.0"
+
babel-plugin-transform-react-display-name@^6.23.0:
version "6.25.0"
resolved "https://registry.yarnpkg.com/babel-plugin-transform-react-display-name/-/babel-plugin-transform-react-display-name-6.25.0.tgz#67e2bf1f1e9c93ab08db96792e05392bf2cc28d1"
@@ -3761,6 +3768,10 @@ image-size@^0.5.0:
version "0.5.5"
resolved "https://registry.yarnpkg.com/image-size/-/image-size-0.5.5.tgz#09dfd4ab9d20e29eb1c3e80b8990378df9e3cb9c"
+immutable@^3.8.2:
+ version "3.8.2"
+ resolved "https://registry.yarnpkg.com/immutable/-/immutable-3.8.2.tgz#c2439951455bb39913daf281376f1530e104adf3"
+
import-local@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/import-local/-/import-local-1.0.0.tgz#5e4ffdc03f4fe6c009c6729beb29631c2f8227bc"