├── .github └── workflows │ └── workflow.yml ├── .gitignore ├── .htmlnanorc ├── .nvmrc ├── .vscode └── launch.json ├── commitlint.config.js ├── demo ├── demo.css ├── demo.ts ├── index.html └── util │ ├── handle-key-events.ts │ ├── on-state-change.tsx │ └── undo-redo.ts ├── img └── demo.mov.gif ├── package.json ├── project.code-workspace ├── readme.md ├── renovate.json ├── src ├── dom.ts ├── dragger.ts ├── index.ts ├── interfaces.ts ├── point-maths.ts └── snapper.ts ├── test └── index.ts ├── testem.yml ├── tsconfig.json ├── tsconfig.prod.json ├── webpack.config.js └── yarn.lock /.github/workflows/workflow.yml: -------------------------------------------------------------------------------- 1 | name: main 2 | on: [push] 3 | jobs: 4 | run: 5 | name: run 6 | runs-on: ${{ matrix.operating-system }} 7 | strategy: 8 | matrix: 9 | operating-system: [ubuntu-latest] 10 | steps: 11 | - uses: actions/checkout@v4 12 | - name: setup-node 13 | uses: actions/setup-node@master 14 | with: 15 | node-version: 12.x 16 | - name: install 17 | run: yarn 18 | - name: format check 19 | run: yarn format --check 20 | - name: lint 21 | run: yarn lint 22 | - name: build 23 | run: yarn build -p tsconfig.prod.json 24 | - name: test 25 | run: yarn test 26 | - name: commitlint 27 | uses: wagoid/commitlint-github-action@v5.4.5 28 | - name: setting git values 29 | run: | 30 | git config --local user.email "cdaringe@cdaringe.com" 31 | git config --local user.name "cdaringe" 32 | - name: release 33 | env: 34 | GH_TOKEN: ${{ secrets.GH_TOKEN }} 35 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 36 | run: | 37 | if [[ "$GITHUB_REF" = "refs/heads/master" ]]; then 38 | GITHUB_TOKEN=$GH_TOKEN npx semantic-release --verbose 39 | yarn build:demo 40 | else 41 | echo "skipping release on branch $GITHUB_REF" 42 | fi 43 | - name: gh-pages 44 | uses: JamesIves/github-pages-deploy-action@releases/v3 45 | if: github.ref == 'refs/heads/master' 46 | with: 47 | ACCESS_TOKEN: ${{ secrets.GH_TOKEN }} 48 | BASE_BRANCH: master 49 | BRANCH: gh-pages 50 | CLEAN: true 51 | FOLDER: dist 52 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .cache 3 | dist 4 | test.bundle.js 5 | *.log 6 | bundle.test.js 7 | dist 8 | demo/**/*.js 9 | demo/**/*.jsx 10 | src/**/*.js 11 | src/**/*.jsx 12 | test/**/*.js 13 | test/**/*.jsx 14 | **/*.map 15 | **/*d.ts 16 | -------------------------------------------------------------------------------- /.htmlnanorc: -------------------------------------------------------------------------------- 1 | { 2 | "minifySvg": false 3 | } 4 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 18.19.0 2 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node", 9 | "request": "attach", 10 | "name": "attach", 11 | "port": 9229, 12 | "skipFiles": [ 13 | "/**" 14 | ] 15 | }, 16 | { 17 | "type": "node", 18 | "request": "launch", 19 | "name": "Launch Program", 20 | "skipFiles": [ 21 | "/**" 22 | ], 23 | "program": "${workspaceFolder}/src/index.html" 24 | }, 25 | 26 | ] 27 | } 28 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = {extends: ['@commitlint/config-conventional']} 2 | -------------------------------------------------------------------------------- /demo/demo.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: 'Ubuntu Mono', Arial, Helvetica, sans-serif; 3 | background: linear-gradient(#3f51b56b, #00bcd494); 4 | height: 100vh; 5 | } 6 | 7 | #demo { 8 | box-shadow: black 0 0 3px -1px; 9 | display: block; 10 | margin: 1em auto; 11 | } 12 | #controls { 13 | display: block; 14 | max-width: 600px; 15 | margin: auto; 16 | } 17 | #controls button { 18 | display: block; 19 | } 20 | #state { 21 | position: fixed; 22 | right: 10px; 23 | top: 10px; 24 | z-index: -1; 25 | } 26 | #state ul { 27 | list-style: none; 28 | padding-left: 0; 29 | } 30 | path { 31 | fill: none; 32 | stroke: #000; 33 | stroke-width: 1.5px; 34 | } 35 | 36 | line { 37 | fill: none; 38 | stroke: red; 39 | stroke-width: 1.5px; 40 | } 41 | 42 | circle { 43 | fill: red; 44 | } 45 | 46 | circle:hover { 47 | fill: darkred; 48 | } 49 | 50 | .snapper { 51 | fill: blue; 52 | } 53 | 54 | rect { 55 | fill: none; 56 | cursor: crosshair; 57 | pointer-events: all; 58 | } 59 | -------------------------------------------------------------------------------- /demo/demo.ts: -------------------------------------------------------------------------------- 1 | import './demo.css' 2 | import { bindKeyEvents } from './util/handle-key-events' 3 | import { fromPoints } from '../src/' 4 | import { onStateChange } from './util/on-state-change' 5 | import { Point } from '../src/interfaces' 6 | import d3 = require('d3') 7 | 8 | // prep some data 9 | const points: Point[] = [ 10 | [10, 80], 11 | [100, 100], 12 | [200, 30], 13 | [300, 50], 14 | [400, 40], 15 | [500, 80] 16 | ] 17 | 18 | // build a svg 19 | const height = 300 20 | const width = 600 21 | const svg$ = d3 22 | .select((document.getElementById('demo') as unknown) as SVGSVGElement) 23 | .attr('width', width) 24 | .attr('height', height) 25 | 26 | const { 27 | path$, // d3 path selection 28 | nodes, 29 | undo: onUndo, 30 | disableEditing, 31 | enableEditing, 32 | setNodeVisibility, 33 | snapper, 34 | render 35 | } = fromPoints({ 36 | onStateChange: () => renderUi(), 37 | points, 38 | svg$, 39 | transformLine: line => { 40 | return isCurvedLineMode ? line.curve(d3.curveCatmullRom.alpha(0.9)) : line 41 | } 42 | }) 43 | 44 | // ^ focus on ^ 45 | // below is for setting up the demo controls and outputs :) 46 | let isNodesVisibile = true 47 | let isCurvedLineMode = true 48 | const renderUi = () => { 49 | onStateChange({ 50 | nodes, 51 | onShowNodes: () => { 52 | isNodesVisibile = !isNodesVisibile 53 | setNodeVisibility(isNodesVisibile) 54 | }, 55 | onDisableEditing: () => { 56 | disableEditing() 57 | renderUi() 58 | }, 59 | onEnableEditing: () => { 60 | enableEditing() 61 | renderUi() 62 | }, 63 | onToggleLineMode: () => { 64 | isCurvedLineMode = !isCurvedLineMode 65 | render() 66 | }, 67 | snapperState: snapper.state 68 | }) 69 | } 70 | renderUi() 71 | bindKeyEvents({ onUndo }) 72 | -------------------------------------------------------------------------------- /demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /demo/util/handle-key-events.ts: -------------------------------------------------------------------------------- 1 | import { isUndo } from './undo-redo' 2 | 3 | export const bindKeyEvents = ({ onUndo }: { onUndo: () => void }) => { 4 | window.onkeydown = (evt: KeyboardEvent) => { 5 | if (isUndo(evt, 'mac')) onUndo() 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /demo/util/on-state-change.tsx: -------------------------------------------------------------------------------- 1 | import { MetaNode } from '../../src/interfaces' 2 | import * as React from 'react' 3 | import ReactDom from 'react-dom' 4 | import { SnapperState } from '../../src/snapper' 5 | 6 | let isPaintingState = false 7 | 8 | export type OnStateChange = { 9 | onDisableEditing: () => void 10 | onEnableEditing: () => void 11 | onToggleLineMode: () => void 12 | onShowNodes: () => void 13 | nodes: MetaNode[] 14 | snapperState: SnapperState 15 | } 16 | export const onStateChange = (opts: OnStateChange) => { 17 | if (isPaintingState) return 18 | isPaintingState = true 19 | window.requestAnimationFrame(() => { 20 | render(opts) 21 | isPaintingState = false 22 | }) 23 | } 24 | 25 | export const render = ({ 26 | nodes, 27 | onDisableEditing, 28 | onEnableEditing, 29 | onShowNodes, 30 | onToggleLineMode, 31 | snapperState 32 | }: OnStateChange) => { 33 | const toFourChars = (num: number) => { 34 | const rounded = Math.floor(num).toString() 35 | return rounded + ' '.repeat(4 - rounded.length) 36 | } 37 | ReactDom.render( 38 |
39 |

controls

40 | 41 | 42 | 43 | 44 |

press cmd+z or ctrl+z to use the undo feature

45 | {/* */} 46 |
47 |
{JSON.stringify(snapperState, null, 2)}
48 |
    49 | {nodes.map((node, i) => ( 50 |
  • 54 | } 55 | /> 56 | ))} 57 |
58 |
59 |
, 60 | self.document.getElementById('controls')! 61 | ) 62 | } 63 | -------------------------------------------------------------------------------- /demo/util/undo-redo.ts: -------------------------------------------------------------------------------- 1 | export type Style = 'mac' | 'windows' 2 | 3 | export function check (ev: KeyboardEvent, style: Style, shift: boolean) { 4 | const macAllow = !style || style === 'mac' 5 | const winAllow = !style || style === 'windows' 6 | const code = ev.keyCode || ev.which 7 | 8 | if (code !== 122 && code !== 90) return false 9 | if (macAllow && ev.metaKey && shift && !ev.ctrlKey && !ev.altKey) return true 10 | if (winAllow && ev.ctrlKey && shift && !ev.metaKey && !ev.altKey) return true 11 | return false 12 | } 13 | 14 | export const isUndo = function (ev: KeyboardEvent, style: Style) { 15 | return check(ev, style, !ev.shiftKey) 16 | } 17 | 18 | export const isRedo = function (ev: KeyboardEvent, style: Style) { 19 | return check(ev, style, ev.shiftKey) 20 | } 21 | -------------------------------------------------------------------------------- /img/demo.mov.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cdaringe/d3-svg-path-editor/7ba9a913b3a60c476848d76f5d887c07b48afbe8/img/demo.mov.gif -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@dino-dna/d3-svg-path-editor", 3 | "version": "0.0.0-semantic-release", 4 | "license": "MIT", 5 | "main": "./src/index.js", 6 | "private": false, 7 | "publishConfig": { 8 | "access": "public" 9 | }, 10 | "files": [ 11 | "src" 12 | ], 13 | "devDependencies": { 14 | "@commitlint/cli": "16.3.0", 15 | "@commitlint/config-conventional": "16.2.4", 16 | "@types/blue-tape": "0.1.36", 17 | "@types/d3": "5.16.7", 18 | "@types/lodash": "4.14.189", 19 | "@types/react-dom": "18.2.18", 20 | "@typescript-eslint/eslint-plugin": "3.10.1", 21 | "@typescript-eslint/parser": "3.10.1", 22 | "blue-tape": "1.0.0", 23 | "d3": "5.16.0", 24 | "del-cli": "4.0.1", 25 | "gh-pages": "5.0.0", 26 | "husky": "7.0.4", 27 | "lint-staged": "12.5.0", 28 | "parcel-bundler": "1.12.5", 29 | "perish": "1.0.3", 30 | "phantomjs-prebuilt": "2.1.16", 31 | "prettier-standard": "16.4.1", 32 | "react": "18.2.0", 33 | "react-dom": "18.2.0", 34 | "rollup": "2.79.1", 35 | "rollup-plugin-typescript": "1.0.1", 36 | "standardx": "6.0.0", 37 | "testem": "3.11.0", 38 | "typescript": "3.9.10", 39 | "webpack": "4.47.0", 40 | "webpack-cli": "4.10.0" 41 | }, 42 | "scripts": { 43 | "build": "tsc", 44 | "bundle:test": "webpack --mode=development", 45 | "clean": "del '{src,demo,test}/**/*.{js,jsx,d.ts}' && rm -rf .cache", 46 | "format": "prettier-standard '{demo,src,test,scripts}/**/*.{js,jsx,ts,tsx}'", 47 | "lint": "standardx '{demo,src,test,scripts}/**/*.{js,jsx,ts,tsx}' --fix", 48 | "build:demo": "parcel build demo/index.html --public-url '.'", 49 | "demo": "parcel demo/index.html", 50 | "test": "testem ci" 51 | }, 52 | "husky": { 53 | "hooks": { 54 | "pre-commit": "lint-staged" 55 | } 56 | }, 57 | "lint-staged": { 58 | "{src,test,scripts}/**/*.{js,jsx,ts,tsx}": [ 59 | "yarn format", 60 | "yarn lint", 61 | "git add" 62 | ] 63 | }, 64 | "eslintConfig": { 65 | "rules": { 66 | "no-unused-vars": 0 67 | } 68 | }, 69 | "standardx": { 70 | "env": [ 71 | "browser" 72 | ], 73 | "parser": "@typescript-eslint/parser", 74 | "plugins": [ 75 | "@typescript-eslint/eslint-plugin" 76 | ], 77 | "ignore": [ 78 | "**/*.d.ts" 79 | ] 80 | }, 81 | "peerDependencies": { 82 | "d3": "*" 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /project.code-workspace: -------------------------------------------------------------------------------- 1 | { 2 | "folders": [ 3 | { 4 | "path": "." 5 | } 6 | ], 7 | "settings": { 8 | "files.exclude": { 9 | "**/.git": true, 10 | "**/.svn": true, 11 | "**/.hg": true, 12 | "**/CVS": true, 13 | "**/.DS_Store": true, 14 | "**/*.d.ts": true, 15 | "**/*.js": true, 16 | "**/*.jsx": true 17 | }, 18 | "typescript.tsdk": "node_modules/typescript/lib" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # @dino-dna/d3-svg-path-editor 2 | 3 | create an editable svg [path](https://developer.mozilla.org/en-US/docs/Web/SVG/Element/path). a library to enable in-app path editing 4 | 5 | ![main](https://github.com/cdaringe/d3-svg-path-editor/workflows/main/badge.svg) 6 | [![JavaScript Style Guide](https://img.shields.io/badge/code_style-standard-brightgreen.svg)](https://standardjs.com) 7 | [![semantic-release](https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079.svg)](https://github.com/semantic-release/semantic-release) 8 | [![TypeScript package](https://img.shields.io/badge/typings-included-blue.svg)](https://www.typescriptlang.org) 9 | 10 | 11 |
12 | 13 |
14 | 15 | see the [live, interactive demo](https://cdaringe.github.io/d3-svg-path-editor/) 16 | 17 | ## install 18 | 19 | `npm install --save @dino-dna/d3-svg-path-editor d3` 20 | 21 | on the topic of d3 and bundle performance, some minimal subset of d3 packages must be installed and added onto the `d3` instance. we use d3-line, d3-selection, and d3 events. `d3` experts are welcome to add documentation on how to optimize web builds here for d3 bundles! 22 | 23 | ## usage 24 | 25 | create a path from a set of points and d3 svg instance 26 | 27 | ```tsx 28 | import { fromPoints } from '@dino-dna/d3-svg-path-editor' 29 | const points: [number, number][] = [[0, 0], [10, 10]] 30 | const svg$ = d3.select((document.getElementById('my_svg')) as SVGSVGElement) 31 | const { 32 | path$, // d3 path selection 33 | nodes, // abstract point datas. mutable 34 | undo, // undo last edits 35 | disableEditing, // call to disable editing 36 | enableEditing, // call to enable editing 37 | render, // call to trigger a manual/forced rerender. useful if state changes externally 38 | setNodeVisibility, // call to show/hide nodes 39 | snapper // new node creator (snapper) instance 40 | } = fromPoints({ 41 | // # required 42 | points, 43 | svg$, 44 | // # optional 45 | // onStateChange: nodes => { ... }, 46 | // testCanEditNode: node => boolean // control node edit-ability 47 | // transformLine: d3Line => d3Line // e.g. line => line.curve(d3.curveCatmullRom.alpha(0.9)) 48 | }) 49 | ``` 50 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "config:base" 4 | ], 5 | "automerge": true 6 | } 7 | -------------------------------------------------------------------------------- /src/dom.ts: -------------------------------------------------------------------------------- 1 | import { D3Circle, D3Path, Point, D3Selection, D3Line } from './interfaces' 2 | import d3 = require('d3') 3 | 4 | export type AppendCircleOptions = { 5 | node$: d3.Selection 6 | r?: number 7 | x: number 8 | y: number 9 | } 10 | 11 | export type SetCircleOptions = Pick & { 12 | node$: D3Circle 13 | } 14 | 15 | export const circle = { 16 | append: ({ x, y, r, node$ }: AppendCircleOptions) => 17 | circle.set({ x, y, r, node$: node$.append('circle') }), 18 | set: ({ x, y, r, node$ }: SetCircleOptions) => 19 | node$ 20 | .attr('cx', x) 21 | .attr('cy', y) 22 | .attr('r', r || 10) 23 | } 24 | 25 | export type LineTransform = (line: D3Line, points: Point[]) => D3Line 26 | 27 | export const path = { 28 | renderLine: ( 29 | path$: D3Path, 30 | points: Point[], 31 | withLineTransform?: LineTransform 32 | ) => { 33 | let line = d3.line() 34 | if (withLineTransform) line = withLineTransform(line, points) 35 | return path$.attr('d', line(points)!) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/dragger.ts: -------------------------------------------------------------------------------- 1 | import { D3Selection, NodeMove } from './interfaces' 2 | import d3 = require('d3') 3 | 4 | export const createNodeDragger = ({ 5 | node$, 6 | onDrag, 7 | onDragEnd, 8 | onDragStart 9 | }: { 10 | node$: D3Selection 11 | onDrag: (opts: NodeMove) => void 12 | onDragEnd: () => void 13 | onDragStart: () => void 14 | }) => { 15 | ;(node$ as any).call( 16 | d3 17 | .drag() 18 | .on('start', d => { 19 | node$.attr('stroke', 'black') 20 | onDragStart() 21 | }) 22 | .on('drag', d => { 23 | const { x, y } = d3.event 24 | onDrag({ x, y }) 25 | }) 26 | .on('end', d => { 27 | node$.attr('stroke', null) 28 | onDragEnd() 29 | }) 30 | ) 31 | } 32 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { circle, path, LineTransform } from './dom' 2 | import { createNodeDragger } from './dragger' 3 | import { createNodeSnapper } from './snapper' 4 | import { D3Path, Point, MetaNode, D3SVG } from './interfaces' 5 | import { getPointInsertionIndex } from './point-maths' 6 | export * from './interfaces' 7 | 8 | export const toPointRef = ([x, y]: Point) => `${x.toFixed(3)}_${y.toFixed(3)}` 9 | 10 | export const toMetaNodes: (points: Point[]) => MetaNode[] = points => 11 | points.map(point => ({ point })) 12 | 13 | type TestCanEditNode = (node?: MetaNode, i?: number) => boolean 14 | 15 | export function renderNodes (opts: { 16 | getNodeEditTest: () => TestCanEditNode | undefined 17 | nodes: MetaNode[] 18 | onAddHistory: () => void 19 | onStateChange: OnStateChange 20 | path$: D3Path 21 | render: () => void 22 | svg$: D3SVG 23 | transformLine?: LineTransform 24 | }) { 25 | const { 26 | svg$, 27 | path$, 28 | nodes, 29 | render: rerender, 30 | onAddHistory, 31 | onStateChange, 32 | getNodeEditTest, 33 | transformLine 34 | } = opts 35 | nodes.forEach(function renderNode (mp, i) { 36 | const { 37 | point: [x, y] 38 | } = mp 39 | if (!mp.node) { 40 | const node$ = circle.append({ x, y, node$: svg$ }) 41 | createNodeDragger({ 42 | node$, 43 | onDragStart: () => { 44 | mp.node!.dragStartPoint = mp.point 45 | }, 46 | onDrag: ({ x, y }) => { 47 | const canEdit = getNodeEditTest() 48 | if (canEdit) { 49 | const ithNode = nodes.findIndex(node => node === mp) 50 | if (!canEdit(mp, ithNode)) return 51 | } 52 | mp.isDirty = true 53 | mp.point = [x, y] 54 | rerender() 55 | onStateChange(nodes) 56 | }, 57 | onDragEnd: () => { 58 | const currentPoint = mp.point 59 | // dangerously swap old point value in just for a hot second, 60 | // as the drag operation has already updated our point array and 61 | // we want to update history reflecting the start point 62 | mp.point = mp.node!.dragStartPoint! 63 | onAddHistory() 64 | mp.point = currentPoint 65 | delete mp.node!.dragStartPoint 66 | // phew! sorry. that was gross. 67 | } 68 | }) 69 | mp.node = { 70 | node$ 71 | } 72 | } 73 | if (!mp.isDirty) return 74 | circle.set({ x, y, node$: mp.node.node$ }) 75 | mp.isDirty = false 76 | }) 77 | path.renderLine( 78 | path$, 79 | nodes.map(node => node.point), 80 | transformLine 81 | ) 82 | onStateChange(nodes) 83 | } 84 | 85 | export type OnStateChange = (nodes: MetaNode[]) => void 86 | 87 | export type FromPoints = { 88 | /** 89 | * number of history entries to maintain for undo support 90 | * set to 0 to effectively disable 91 | */ 92 | historySize?: number 93 | onStateChange?: OnStateChange 94 | points: Point[] 95 | svg$: D3SVG 96 | path$?: D3Path 97 | testCanEditNode?: TestCanEditNode 98 | transformLine?: LineTransform 99 | } 100 | export const fromPoints = (opts: FromPoints) => { 101 | const { 102 | historySize = 25, 103 | onStateChange = () => {}, 104 | path$: userPath$, 105 | points, 106 | svg$, 107 | testCanEditNode: userTestCanEditNode, 108 | transformLine 109 | } = opts 110 | let internalCanEditNode: undefined | TestCanEditNode 111 | let nodes = toMetaNodes(points) 112 | const path$ = 113 | userPath$ || 114 | svg$ 115 | .append('path') 116 | .attr('stroke', 'black') 117 | .attr('fill', 'none') 118 | 119 | // bind node snapper 120 | const snapper = createNodeSnapper({ 121 | onAddNode: ({ length, point }) => { 122 | const insertIndex = getPointInsertionIndex({ 123 | pathDistance: length, 124 | pathNode: path$.node()!, 125 | point, 126 | points: nodes.map(node => node.point) 127 | }) 128 | nodes.splice(insertIndex, 0, { point }) 129 | onAddHistory() 130 | render() 131 | }, 132 | svg$, 133 | path$ 134 | }) 135 | 136 | // setup undo support 137 | const history: any = [] 138 | const onAddHistory = () => { 139 | onStateChange(nodes) 140 | if (historySize === 0) return 141 | history.push(JSON.stringify(nodes.map(node => node.point))) 142 | if (history.length > historySize) history.shift() 143 | } 144 | const onUndo = () => { 145 | if (!history.length) return 146 | nodes.map(mn => mn.node && mn.node.node$.remove()) 147 | nodes = toMetaNodes(JSON.parse(history.pop()!)) 148 | render() 149 | } 150 | 151 | // edit toggling 152 | const setNodeVisibility = (isVis?: boolean) => 153 | nodes.forEach( 154 | ({ node }) => node && node.node$.attr('opacity', isVis ? 1 : 0) 155 | ) 156 | const disableEditing = () => { 157 | setNodeVisibility(false) 158 | internalCanEditNode = () => false 159 | snapper.disable() 160 | } 161 | const enableEditing = () => { 162 | setNodeVisibility(true) 163 | internalCanEditNode = undefined 164 | snapper.enable() 165 | } 166 | 167 | // go 168 | const render = () => 169 | window.requestAnimationFrame(() => 170 | renderNodes({ 171 | nodes, 172 | onAddHistory, 173 | onStateChange, 174 | path$, 175 | render, 176 | svg$, 177 | getNodeEditTest: () => userTestCanEditNode || internalCanEditNode, 178 | transformLine 179 | }) 180 | ) 181 | render() 182 | 183 | // finish 184 | return { 185 | disableEditing, 186 | enableEditing, 187 | nodes, 188 | path$, 189 | setNodeVisibility, 190 | snapper, 191 | undo: onUndo, 192 | render 193 | } 194 | } 195 | -------------------------------------------------------------------------------- /src/interfaces.ts: -------------------------------------------------------------------------------- 1 | export type D3Circle = d3.Selection 2 | export type D3Path = d3.Selection 3 | export type D3Line = d3.Line<[number, number]> 4 | export type D3Selection = d3.Selection 5 | export type D3SVG = d3.Selection 6 | export type Point = [number, number] 7 | 8 | export type MetaNode = { 9 | isDirty?: boolean 10 | node?: { 11 | dragStartPoint?: Point 12 | node$: D3Circle 13 | } 14 | point: Point 15 | } 16 | 17 | export type NodeMove = { x: number; y: number } 18 | -------------------------------------------------------------------------------- /src/point-maths.ts: -------------------------------------------------------------------------------- 1 | import { Point } from './interfaces' 2 | 3 | function distance (p1: Point, p2: Point) { 4 | var dx = p2[0] - p1[0] 5 | var dy = p2[1] - p1[1] 6 | return Math.sqrt(dx * dx + dy * dy) 7 | } 8 | 9 | function distanceRectilinear (p1: Point, p2: Point) { 10 | var dx = p2[0] - p1[0] 11 | var dy = p2[1] - p1[1] 12 | return Math.abs(dx) + Math.abs(dy) 13 | } 14 | 15 | const isNear = (p1: Point, p2: Point, px: number = 1) => 16 | distanceRectilinear(p1, p2) < px 17 | 18 | /** 19 | * to insert point between p1 & p2 20 | * - get initial point 21 | * - find smallest allowable step 22 | * - take that step 23 | * - see if we have a point in range yet 24 | * - if not, repeat 25 | * - on success, assign, get remaining point 26 | */ 27 | export function getPointInsertionIndex ({ 28 | point, 29 | pathDistance, 30 | pathNode, 31 | points 32 | }: { 33 | pathDistance: number 34 | pathNode: SVGPathElement 35 | points: Point[] 36 | point: Point 37 | }): number { 38 | if (!points.length || points.length < 2) throw new Error('not enough points') 39 | const derivedPoint = pathNode.getPointAtLength(pathDistance) 40 | if (!isNear(point, [derivedPoint.x, derivedPoint.y])) { 41 | throw new Error( 42 | [ 43 | 'pathDistance does not produce initial point', 44 | `([${derivedPoint.x}, ${derivedPoint.y}]) equivalent`, 45 | `passed point (${point.toString()})` 46 | ].join(' ') 47 | ) 48 | } 49 | let currentLength = pathDistance 50 | while (true) { 51 | const { x, y } = pathNode.getPointAtLength(currentLength) 52 | const distancesToPoint = points.map(point => distance(point, [x, y])) 53 | const minTravelToNextPoint = Math.min(...distancesToPoint) 54 | if (minTravelToNextPoint < 1) { 55 | return distancesToPoint.findIndex(d => d === minTravelToNextPoint) 56 | } 57 | currentLength += minTravelToNextPoint 58 | } 59 | } 60 | 61 | export function findClosest (pathNode: SVGPathElement, point: Point) { 62 | /* eslint-disable */ 63 | var pathLength = pathNode.getTotalLength() 64 | var precision: number = Math.floor(pathLength / 10) 65 | var bestDistance: number = Infinity 66 | var best: DOMPoint = new DOMPoint(0, 0, 0, 0) 67 | var bestLength: number = 0 68 | 69 | // linear scan for coarse approximation 70 | for ( 71 | var scan, scanLength = 0, scanDistance; 72 | scanLength <= pathLength; 73 | scanLength += precision 74 | ) { 75 | scan = pathNode.getPointAtLength(scanLength) 76 | scanDistance = distance2(scan) 77 | if (scanDistance < bestDistance) { 78 | best = scan 79 | bestLength = scanLength 80 | bestDistance = scanDistance 81 | } 82 | } 83 | 84 | // binary search for precise estimate 85 | precision /= 2 86 | while (precision > 0.5) { 87 | var before, after, beforeLength, afterLength, beforeDistance, afterDistance 88 | if ( 89 | (beforeLength = bestLength - precision) >= 0 && 90 | (beforeDistance = distance2( 91 | (before = pathNode.getPointAtLength(beforeLength)) 92 | )) < bestDistance 93 | ) { 94 | ;(best = before), 95 | (bestLength = beforeLength), 96 | (bestDistance = beforeDistance) 97 | } else if ( 98 | (afterLength = bestLength + precision) <= pathLength && 99 | (afterDistance = distance2( 100 | (after = pathNode.getPointAtLength(afterLength)) 101 | )) < bestDistance 102 | ) { 103 | best = after 104 | bestLength = afterLength 105 | bestDistance = afterDistance 106 | } else { 107 | precision /= 2 108 | } 109 | } 110 | /* eslint-enable */ 111 | return { 112 | pathLength: bestLength, 113 | distance: Math.sqrt(bestDistance), 114 | point: [best.x, best.y] as Point 115 | } 116 | 117 | function distance2 (p: DOMPoint) { 118 | var dx = p.x - point[0] 119 | var dy = p.y - point[1] 120 | return dx * dx + dy * dy 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/snapper.ts: -------------------------------------------------------------------------------- 1 | import { circle } from './dom' 2 | import { D3SVG, D3Path, Point } from './interfaces' 3 | import { findClosest } from './point-maths' 4 | import d3 = require('d3') 5 | 6 | export type PathPoint = { 7 | distance: number 8 | length: number 9 | point: Point 10 | } 11 | 12 | export type SnapperState = { 13 | isEnabled: boolean 14 | pathPoint: null | PathPoint 15 | isNodeAddEnabled: boolean 16 | } 17 | 18 | export type CreateSnapperOpts = { 19 | onAddNode: (pathPoint: PathPoint) => void 20 | path$: D3Path 21 | svg$: D3SVG 22 | } 23 | export const createNodeSnapper = ({ 24 | onAddNode, 25 | path$, 26 | svg$ 27 | }: CreateSnapperOpts) => { 28 | const state: SnapperState = { 29 | isEnabled: true, 30 | pathPoint: null, 31 | isNodeAddEnabled: false 32 | } 33 | const snapperDot = circle.append({ x: -10, y: -10, node$: svg$ }) 34 | snapperDot.attr('class', 'snapper') 35 | const snapperLine = svg$.append('line') 36 | const setSnapperUiVisible = () => { 37 | snapperLine.style('opacity', state.isNodeAddEnabled ? 1 : 0) 38 | snapperDot.style('opacity', state.isNodeAddEnabled ? 1 : 0) 39 | } 40 | const onMouseOverActiveRegion = (pathPoint: PathPoint) => { 41 | state.pathPoint = pathPoint 42 | if (state.isNodeAddEnabled) return // optimization 43 | state.isNodeAddEnabled = true 44 | setSnapperUiVisible() 45 | } 46 | const onMouseOutActiveRegion = () => { 47 | if (!state.isNodeAddEnabled) return // optimization 48 | state.isNodeAddEnabled = false 49 | state.pathPoint = null 50 | setSnapperUiVisible() 51 | } 52 | const snapperMouseHandler = createMouseMoveHandler({ 53 | onMouseOverActiveRegion, 54 | onMouseOutActiveRegion, 55 | path: path$.node()!, 56 | snapperDot, 57 | snapperLine 58 | }) 59 | svg$.on('mousemove', function onMouseMove () { 60 | state.isEnabled && snapperMouseHandler.call(this) 61 | }) 62 | svg$.on('click', () => { 63 | if (!state.isEnabled) return 64 | const { pathPoint, isNodeAddEnabled } = state 65 | if (!pathPoint || !isNodeAddEnabled) return 66 | onAddNode(pathPoint) 67 | }) 68 | return { 69 | state, 70 | disable: () => { 71 | state.isEnabled = false 72 | state.isNodeAddEnabled = false 73 | state.pathPoint = null 74 | setSnapperUiVisible() 75 | }, 76 | enable: () => { 77 | state.isEnabled = true 78 | }, 79 | unsubscribe: () => { 80 | svg$.on('click', null) 81 | svg$.on('mousemove', null) 82 | } 83 | } 84 | } 85 | 86 | /** 87 | * @todo 88 | * this mousehandler gets bound to the whole svg, and gets really slow when there's 89 | * a bunch of points. we need to speed this mofo up. quadtree path trace perhaps? 90 | * but what resolution to add nodes to the tree? 91 | */ 92 | export function createMouseMoveHandler ({ 93 | onMouseOverActiveRegion, 94 | onMouseOutActiveRegion, 95 | path, 96 | snapperLine, 97 | snapperDot 98 | }: { 99 | onMouseOverActiveRegion: (pp: PathPoint) => void 100 | onMouseOutActiveRegion: () => void 101 | path: SVGPathElement 102 | snapperLine: d3.Selection 103 | snapperDot: d3.Selection 104 | }) { 105 | let rendering = false 106 | function updateSnapper (mousePoint: Point) { 107 | // @info 108 | // optimize. findClosest is expensive. queue requests, whilst 109 | // still accepting the _latest_ point for next paint 110 | const nextPoint = mousePoint 111 | if (rendering) return 112 | window.requestAnimationFrame(function getNextSnapperPoint () { 113 | rendering = true 114 | const { distance, point, pathLength: length } = findClosest( 115 | path, 116 | nextPoint 117 | ) 118 | if (distance < 15 || distance > 100) { 119 | rendering = false 120 | return onMouseOutActiveRegion() 121 | } 122 | snapperLine 123 | .attr('x1', point[0]) 124 | .attr('y1', point[1]) 125 | .attr('x2', mousePoint[0]) 126 | .attr('y2', mousePoint[1]) 127 | snapperDot 128 | .attr('cx', point[0]) 129 | .attr('cy', point[1]) 130 | .lower() 131 | onMouseOverActiveRegion({ point, distance, length }) 132 | rendering = false 133 | }) 134 | } 135 | return function onMouseMove (this: any) { 136 | const mousePoint: Point = d3.mouse(this) 137 | return updateSnapper(mousePoint) 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /test/index.ts: -------------------------------------------------------------------------------- 1 | import { fromPoints } from '../src' 2 | import { Point } from '../src/interfaces' 3 | import d3 = require('d3') 4 | import test = require('blue-tape') 5 | 6 | const createTestSvg = () => 7 | d3.select( 8 | d3 9 | .select('body') 10 | .append('svg') 11 | .attr('width', 200) 12 | .attr('height', 200) 13 | .node()! as SVGSVGElement 14 | ) 15 | 16 | function injectTestFromPoints () { 17 | const points: Point[] = [ 18 | [0, 0], 19 | [100, 100] 20 | ] 21 | const svg$ = createTestSvg() 22 | return fromPoints({ points, svg$ }) 23 | } 24 | 25 | test('mod - fromPoints', t => { 26 | const { path$ } = injectTestFromPoints() 27 | t.ok(path$) 28 | t.equal(path$.attr('stroke'), 'black', 'path should be black') 29 | t.equal(path$.attr('fill'), 'none', 'path should not be filled') 30 | t.end() 31 | }) 32 | 33 | test('mod - fromPoints', t => { 34 | const { path$ } = injectTestFromPoints() 35 | t.ok(path$) 36 | t.equal(path$.attr('stroke'), 'black', 'path should be black') 37 | t.equal(path$.attr('fill'), 'none', 'path should not be filled') 38 | t.end() 39 | }) 40 | -------------------------------------------------------------------------------- /testem.yml: -------------------------------------------------------------------------------- 1 | before_tests: yarn bundle:test 2 | disable_watching: true 3 | framework: tap 4 | serve_files: 5 | - bundle.test.js 6 | tap_quiet_logs: false 7 | launch_in_dev: 8 | - Chrome 9 | launch_in_ci: 10 | - phantomjs 11 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "module": "commonjs", 5 | "jsx": "react", 6 | "strict": true, 7 | "esModuleInterop": true, 8 | "forceConsistentCasingInFileNames": true 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /tsconfig.prod.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "target": "es5", 5 | "sourceMap": true, 6 | "declaration": true 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | entry: './test/index.js', 3 | output: { 4 | path: __dirname, 5 | filename: 'bundle.test.js' 6 | }, 7 | node: { 8 | fs: 'empty' 9 | }, 10 | mode: 'development' 11 | }; 12 | --------------------------------------------------------------------------------