First cut of SVG rendering components
These still need work, but they're functional enough to render a diagram
This commit is contained in:
parent
3fdc74bdf2
commit
82b780e9c3
@ -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",
|
||||
|
@ -44,5 +44,10 @@ exports[`App rendering 1`] = `
|
||||
Sample warning message
|
||||
</p>
|
||||
</Message>
|
||||
<div
|
||||
className="render"
|
||||
>
|
||||
Testing image
|
||||
</div>
|
||||
</React.Fragment>
|
||||
`;
|
||||
|
@ -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'
|
||||
};
|
||||
|
||||
const downloadUrls = [
|
||||
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 handleSubmit = ({ expr, syntax}) => console.log(syntax, expr); // eslint-disable-line no-console
|
||||
handleSubmit = ({expr, syntax}) => {
|
||||
console.log(syntax, expr); // eslint-disable-line no-console
|
||||
}
|
||||
|
||||
const App = () => <React.Fragment>
|
||||
render() {
|
||||
const { downloadUrls, syntaxes, image } = this.state;
|
||||
|
||||
return <React.Fragment>
|
||||
<Form
|
||||
syntaxes={ syntaxes }
|
||||
downloadUrls={ downloadUrls }
|
||||
permalinkUrl="#permalink"
|
||||
onSubmit={ handleSubmit }/>
|
||||
onSubmit={ this.handleSubmit }/>
|
||||
<Message type="error" heading="Sample Error">
|
||||
<p>Sample error message</p>
|
||||
</Message>
|
||||
<Message type="warning" heading="Sample Warning">
|
||||
<p>Sample warning message</p>
|
||||
</Message>
|
||||
</React.Fragment>;
|
||||
<div className={ style.render }>
|
||||
{ renderImage(image) }
|
||||
</div>
|
||||
</React.Fragment>;
|
||||
}
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
17
src/components/App/style.css
Normal file
17
src/components/App/style.css
Normal file
@ -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;
|
||||
}
|
||||
}
|
@ -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(
|
||||
<App/>
|
||||
);
|
||||
|
83
src/components/SVG/Base.js
Normal file
83
src/components/SVG/Base.js
Normal file
@ -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;
|
84
src/components/SVG/Box.js
Normal file
84
src/components/SVG/Box.js
Normal file
@ -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 <React.Fragment>
|
||||
<rect { ...rectProps } ></rect>
|
||||
{ label && <text { ...textProps }>{ label }</text> }
|
||||
<g transform={ contentTransform }>
|
||||
{ React.cloneElement(React.Children.only(children), {
|
||||
ref: this.containedRef
|
||||
}) }
|
||||
</g>
|
||||
</React.Fragment>;
|
||||
}
|
||||
}
|
||||
|
||||
Box.propTypes = {
|
||||
padding: PropTypes.number,
|
||||
useAnchors: PropTypes.bool,
|
||||
style: PropTypes.object,
|
||||
radius: PropTypes.number,
|
||||
label: PropTypes.string,
|
||||
children: PropTypes.node
|
||||
};
|
||||
|
||||
export default Box;
|
107
src/components/SVG/HorizontalLayout.js
Normal file
107
src/components/SVG/HorizontalLayout.js
Normal file
@ -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.Fragment>
|
||||
<path d={ connectorPaths } style={ style.connectors }></path>
|
||||
{ React.Children.map(children, (child, i) => (
|
||||
<g transform={ childTransforms.get(i) }>
|
||||
{ React.cloneElement(child, {
|
||||
ref: this.childRef(i)
|
||||
}) }
|
||||
</g>
|
||||
))}
|
||||
</React.Fragment>;
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalLayout.propTypes = {
|
||||
spacing: PropTypes.number,
|
||||
withConnectors: PropTypes.bool,
|
||||
children: PropTypes.oneOfType([
|
||||
PropTypes.arrayOf(PropTypes.node),
|
||||
PropTypes.node
|
||||
]).isRequired
|
||||
};
|
||||
|
||||
export default HorizontalLayout;
|
103
src/components/SVG/Image.js
Normal file
103
src/components/SVG/Image.js
Normal file
@ -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 = `<rdf:rdf>
|
||||
<cc:license rdf:about="http://creativecommons.org/licenses/by/3.0/">
|
||||
<cc:permits rdf:resource="http://creativecommons.org/ns#Reproduction"></cc:permits>
|
||||
<cc:permits rdf:resource="http://creativecommons.org/ns#Distribution"></cc:permits>
|
||||
<cc:requires rdf:resource="http://creativecommons.org/ns#Notice"></cc:requires>
|
||||
<cc:requires rdf:resource="http://creativecommons.org/ns#Attribution"></cc:requires>
|
||||
<cc:permits rdf:resource="http://creativecommons.org/ns#DerivativeWorks"></cc:permits>
|
||||
</cc:license>
|
||||
</rdf:rdf>`;
|
||||
|
||||
/** @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 <svg { ...svgProps }>
|
||||
<metadata dangerouslySetInnerHTML={{ __html: metadata }}></metadata>
|
||||
<g transform={ `translate(${ padding } ${ padding })` }>
|
||||
{ React.cloneElement(React.Children.only(children), {
|
||||
ref: this.containedRef
|
||||
}) }
|
||||
</g>
|
||||
</svg>;
|
||||
}
|
||||
}
|
||||
|
||||
Image.propTypes = {
|
||||
onRender: PropTypes.func,
|
||||
padding: PropTypes.number,
|
||||
children: PropTypes.node
|
||||
};
|
||||
|
||||
export default Image;
|
144
src/components/SVG/Loop.js
Normal file
144
src/components/SVG/Loop.js
Normal file
@ -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 <React.Fragment>
|
||||
<path d={ loopPaths } style={ style.connectors }></path>
|
||||
{ label && <text { ...textProps }>{ label }</text> }
|
||||
<g transform={ `translate(${ this.contentOffset.x } ${ this.contentOffset.y })` }>
|
||||
{ React.cloneElement(React.Children.only(children), {
|
||||
ref: this.containedRef
|
||||
}) }
|
||||
</g>
|
||||
</React.Fragment>;
|
||||
}
|
||||
}
|
||||
|
||||
Loop.propTypes = {
|
||||
skip: PropTypes.bool,
|
||||
repeat: PropTypes.bool,
|
||||
greedy: PropTypes.bool,
|
||||
label: PropTypes.string,
|
||||
children: PropTypes.node.isRequired
|
||||
};
|
||||
|
||||
export default Loop;
|
46
src/components/SVG/Pin.js
Normal file
46
src/components/SVG/Pin.js
Normal file
@ -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 <circle { ...circleProps }></circle>;
|
||||
}
|
||||
}
|
||||
|
||||
Pin.propTypes = {
|
||||
radius: PropTypes.number
|
||||
};
|
||||
|
||||
export default Pin;
|
51
src/components/SVG/Text.js
Normal file
51
src/components/SVG/Text.js
Normal file
@ -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 <text { ...textProps }>
|
||||
{ children }
|
||||
</text>;
|
||||
}
|
||||
}
|
||||
|
||||
Text.propTypes = {
|
||||
style: PropTypes.object,
|
||||
transform: PropTypes.string,
|
||||
children: PropTypes.oneOfType([
|
||||
PropTypes.arrayOf(PropTypes.node),
|
||||
PropTypes.node
|
||||
]).isRequired
|
||||
};
|
||||
|
||||
export default Text;
|
149
src/components/SVG/VerticalLayout.js
Normal file
149
src/components/SVG/VerticalLayout.js
Normal file
@ -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.Fragment>
|
||||
<path d={ connectorPaths } style={ style.connectors }></path>
|
||||
{ React.Children.map(children, (child, i) => (
|
||||
<g transform={ childTransforms.get(i) }>
|
||||
{ React.cloneElement(child, {
|
||||
ref: this.childRef(i)
|
||||
}) }
|
||||
</g>
|
||||
)) }
|
||||
</React.Fragment>;
|
||||
}
|
||||
}
|
||||
|
||||
VerticalLayout.propTypes = {
|
||||
spacing: PropTypes.number,
|
||||
withConnectors: PropTypes.bool,
|
||||
children: PropTypes.oneOfType([
|
||||
PropTypes.arrayOf(PropTypes.node),
|
||||
PropTypes.node
|
||||
]).isRequired
|
||||
};
|
||||
|
||||
export default VerticalLayout;
|
41
src/components/SVG/index.js
Normal file
41
src/components/SVG/index.js
Normal file
@ -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;
|
103
src/components/SVG/path.js
Normal file
103
src/components/SVG/path.js
Normal file
@ -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;
|
43
src/components/SVG/style.js
Normal file
43
src/components/SVG/style.js
Normal file
@ -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
|
||||
}
|
||||
};
|
138
src/devel.js
Normal file
138
src/devel.js
Normal file
@ -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
|
||||
};
|
13
yarn.lock
13
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"
|
||||
|
Loading…
Reference in New Issue
Block a user