├── .gitignore ├── fixtures ├── randomId.ts ├── randomName.ts ├── randomInt.ts ├── randomNode.ts └── randomTree.ts ├── src ├── last.ts ├── index.ts ├── TreeMap.ts ├── TreeRel.ts ├── getNodeCenterX.ts ├── getNodeCenterY.ts ├── getNodeRightX.ts ├── getNodeBottomY.ts ├── getFromMap.ts ├── getNextAfters.ts ├── groupCallback.ts ├── getNextBefores.ts ├── makeRoot.ts ├── Settings.ts ├── defaultSettings.ts ├── TreeNode.ts ├── addGroupTopY.ts ├── addGroupLeftX.ts ├── addGroupRightX.ts ├── addGroupBottomY.ts ├── shiftFromCountour.ts ├── getInitialTargetsShiftLeft.ts ├── getInitialTargetsShiftTop.ts ├── addRootSiblingsPositions.ts ├── addRootSpousesPositions.ts ├── centerSourceToTargets.ts ├── layoutFromMap.ts ├── checkContourOverlap.ts ├── normalizeTree.ts ├── getElements.ts ├── addLevelNodesSizes.ts ├── drillChildren.ts └── drillParents.ts ├── tests ├── TestNode.ts ├── makeRoot.test.ts ├── centerSourceToTargets.test.ts ├── normalizeTree.test.ts └── getInitialTargetsShiftLeft.test.ts ├── babel.config.js ├── tsconfig.json ├── playground ├── flatTree.ts ├── index.html └── source.js ├── package.json ├── README.md └── tree.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | .DS_Store 4 | playground/bundle.js -------------------------------------------------------------------------------- /fixtures/randomId.ts: -------------------------------------------------------------------------------- 1 | let id = 1; 2 | export const randomId = () => { 3 | return id++; 4 | }; 5 | -------------------------------------------------------------------------------- /fixtures/randomName.ts: -------------------------------------------------------------------------------- 1 | export const randomName = () => Math.random().toString(36).substring(4); 2 | -------------------------------------------------------------------------------- /src/last.ts: -------------------------------------------------------------------------------- 1 | export const last = (array: any[]) => { 2 | return array?.[array.length - 1]; 3 | }; 4 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./layoutFromMap"; 2 | export * from "./TreeNode"; 3 | export * from "./TreeMap"; 4 | -------------------------------------------------------------------------------- /tests/TestNode.ts: -------------------------------------------------------------------------------- 1 | import { TreeNode } from "../src/TreeNode"; 2 | 3 | export type TestNode = TreeNode<{}>; 4 | -------------------------------------------------------------------------------- /src/TreeMap.ts: -------------------------------------------------------------------------------- 1 | import { TreeNode } from "TreeNode"; 2 | 3 | export type TreeMap = Record>; 4 | -------------------------------------------------------------------------------- /fixtures/randomInt.ts: -------------------------------------------------------------------------------- 1 | export const randomInt = (min, max) => { 2 | return Math.floor(Math.random() * (max - min + 1) + min); 3 | }; 4 | -------------------------------------------------------------------------------- /src/TreeRel.ts: -------------------------------------------------------------------------------- 1 | import { TreeNode } from "./TreeNode"; 2 | 3 | export type TreeRel = { source: TreeNode; target: TreeNode }; 4 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | ["@babel/preset-env", { targets: { node: "current" } }], 4 | "@babel/preset-typescript", 5 | ], 6 | }; 7 | -------------------------------------------------------------------------------- /src/getNodeCenterX.ts: -------------------------------------------------------------------------------- 1 | import { TreeNode } from "./TreeNode"; 2 | 3 | export const getNodeCenterX = (node: TreeNode) => { 4 | return node.x + node.width / 2; 5 | }; 6 | -------------------------------------------------------------------------------- /src/getNodeCenterY.ts: -------------------------------------------------------------------------------- 1 | import { TreeNode } from "./TreeNode"; 2 | 3 | export const getNodeCenterY = (node: TreeNode) => { 4 | return node.y + node.height / 2; 5 | }; 6 | -------------------------------------------------------------------------------- /src/getNodeRightX.ts: -------------------------------------------------------------------------------- 1 | import { TreeNode } from "./TreeNode"; 2 | 3 | export const getNodeRightX = (node: TreeNode) => { 4 | return node.x + node.width + node.marginRight; 5 | }; 6 | -------------------------------------------------------------------------------- /src/getNodeBottomY.ts: -------------------------------------------------------------------------------- 1 | import { TreeNode } from "./TreeNode"; 2 | 3 | export const getNodeBottomY = (node: TreeNode) => { 4 | return node.y + node.height + node.marginBottom; 5 | }; 6 | -------------------------------------------------------------------------------- /src/getFromMap.ts: -------------------------------------------------------------------------------- 1 | import { TreeMap } from "./TreeMap"; 2 | 3 | export const getFromMap = (ids: string[], map: TreeMap) => { 4 | if (!ids) return; 5 | 6 | return ids.map((id) => map[id]); 7 | }; 8 | -------------------------------------------------------------------------------- /tests/makeRoot.test.ts: -------------------------------------------------------------------------------- 1 | import { defaultSettings } from "../src/defaultSettings"; 2 | import { makeRoot } from "../src/makeRoot"; 3 | 4 | test("makeRoot", () => { 5 | const node = {}; 6 | const root = makeRoot(node, defaultSettings); 7 | 8 | expect(root.isRoot).toBe(true); 9 | }); 10 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": "src", 4 | "declaration": true, 5 | "esModuleInterop": true, 6 | "lib": ["es2016", "dom", "es5"], 7 | "module": "commonjs", 8 | "moduleResolution": "node", 9 | "outDir": "./dist", 10 | "sourceMap": true, 11 | "target": "es5" 12 | }, 13 | "include": ["src/**/*"] 14 | } 15 | -------------------------------------------------------------------------------- /src/getNextAfters.ts: -------------------------------------------------------------------------------- 1 | import { getFromMap } from "./getFromMap"; 2 | import { Settings } from "./Settings"; 3 | import { TreeMap } from "./TreeMap"; 4 | import { TreeNode } from "./TreeNode"; 5 | 6 | export const getNextAfters = ( 7 | node: TreeNode, 8 | map: TreeMap, 9 | settings: Settings 10 | ) => { 11 | return getFromMap(node[settings.nextAfterAccessor], map); 12 | }; 13 | -------------------------------------------------------------------------------- /src/groupCallback.ts: -------------------------------------------------------------------------------- 1 | import { Settings } from "./Settings"; 2 | import { TreeMap } from "./TreeMap"; 3 | import { TreeNode } from "./TreeNode"; 4 | 5 | export const groupCallback = ( 6 | mainNode: TreeNode, 7 | mainNodeCb: (m: TreeNode) => {}, 8 | beforesCb: (b: TreeNode) => {}, 9 | aftersCb: (a: TreeNode) => {}, 10 | settings: Settings, 11 | map: TreeMap 12 | ) => {}; 13 | -------------------------------------------------------------------------------- /src/getNextBefores.ts: -------------------------------------------------------------------------------- 1 | import { getFromMap } from "./getFromMap"; 2 | import { Settings } from "./Settings"; 3 | import { TreeMap } from "./TreeMap"; 4 | import { TreeNode } from "./TreeNode"; 5 | 6 | export const getNextBefores = ( 7 | node: TreeNode, 8 | map: TreeMap, 9 | settings: Settings 10 | ) => { 11 | return getFromMap(node[settings.nextBeforeAccessor], map); 12 | }; 13 | -------------------------------------------------------------------------------- /src/makeRoot.ts: -------------------------------------------------------------------------------- 1 | import { Settings } from "./Settings"; 2 | import { TreeNode } from "./TreeNode"; 3 | 4 | export const makeRoot = (node: T, settings: Settings): TreeNode => { 5 | const root = node as TreeNode; 6 | root.x = settings.rootX; 7 | root.y = settings.rootY; 8 | root.groupTopY = root.y; 9 | root.groupLeftX = root.x; 10 | root.isRoot = true; 11 | 12 | return root; 13 | }; 14 | -------------------------------------------------------------------------------- /src/Settings.ts: -------------------------------------------------------------------------------- 1 | export type Settings = { 2 | clone: boolean; 3 | firstDegreeSpacing: number; 4 | secondDegreeSpacing: number; 5 | enableFlex: boolean; 6 | nextAfterAccessor: string; 7 | nextAfterSpacing: number; 8 | nextBeforeAccessor: string; 9 | nextBeforeSpacing: number; 10 | nodeHeight: number; 11 | nodeWidth: number; 12 | rootX: number; 13 | rootY: number; 14 | sourcesAccessor: string; 15 | sourceTargetSpacing: number; 16 | targetsAccessor: string; 17 | orientation: "vertical" | "horizontal"; 18 | }; 19 | -------------------------------------------------------------------------------- /src/defaultSettings.ts: -------------------------------------------------------------------------------- 1 | import { Settings } from "./Settings"; 2 | 3 | export const defaultSettings: Settings = { 4 | clone: false, 5 | enableFlex: true, 6 | firstDegreeSpacing: 15, 7 | nextAfterAccessor: "spouses", 8 | nextAfterSpacing: 10, 9 | nextBeforeAccessor: "siblings", 10 | nextBeforeSpacing: 10, 11 | nodeHeight: 40, 12 | nodeWidth: 40, 13 | orientation: "vertical", 14 | rootX: 0, 15 | rootY: 0, 16 | secondDegreeSpacing: 20, 17 | sourcesAccessor: "parents", 18 | sourceTargetSpacing: 10, 19 | targetsAccessor: "children", 20 | }; 21 | -------------------------------------------------------------------------------- /src/TreeNode.ts: -------------------------------------------------------------------------------- 1 | export type TreeNode = T & { 2 | groupBottomY: number; 3 | groupLeftX: number; 4 | groupMaxHeight: number; 5 | groupMaxWidth: number; 6 | groupRightX: number; 7 | groupTopY: number; 8 | height: number; 9 | isAncestor?: boolean; 10 | isDescendant?: boolean; 11 | isNext?: boolean; 12 | isNextAfter?: boolean; 13 | isNextBefore?: boolean; 14 | isRoot?: boolean; 15 | isSource?: boolean; 16 | isTarget?: boolean; 17 | marginBottom: number; 18 | marginRight: number; 19 | width: number; 20 | x: number; 21 | y: number; 22 | }; 23 | -------------------------------------------------------------------------------- /tests/centerSourceToTargets.test.ts: -------------------------------------------------------------------------------- 1 | import { TestNode } from "./TestNode"; 2 | import { centerSourceToTargets } from "../src/centerSourceToTargets"; 3 | import { defaultSettings } from "../src/defaultSettings"; 4 | import { TreeMap } from "../src/TreeMap"; 5 | 6 | test("centerSourceToTargets", () => { 7 | const source = { width: 3, x: 0 } as TestNode; 8 | const targets = [ 9 | { x: 0, width: 5, height: 1 }, 10 | { x: 10, width: 10 }, 11 | ] as TestNode[]; 12 | 13 | const map: TreeMap = {}; 14 | 15 | centerSourceToTargets(source, targets, defaultSettings, map); 16 | 17 | expect(source.x).toBe(8.5); 18 | }); 19 | -------------------------------------------------------------------------------- /src/addGroupTopY.ts: -------------------------------------------------------------------------------- 1 | import { Settings } from "./Settings"; 2 | import { TreeMap } from "./TreeMap"; 3 | import { TreeNode } from "./TreeNode"; 4 | import { getFromMap } from "./getFromMap"; 5 | 6 | export function addGroupTopY( 7 | subtree: TreeNode, 8 | settings: Settings, 9 | map: TreeMap 10 | ) { 11 | subtree.groupTopY = subtree.y; 12 | 13 | getFromMap(subtree[settings.nextBeforeAccessor], map)?.forEach((sibling) => { 14 | subtree.groupTopY = Math.min(subtree.groupTopY, sibling.y); 15 | }); 16 | 17 | getFromMap(subtree[settings.nextAfterAccessor], map)?.forEach((partner) => { 18 | subtree.groupTopY = Math.max(subtree.groupTopY, partner.y); 19 | }); 20 | } 21 | -------------------------------------------------------------------------------- /src/addGroupLeftX.ts: -------------------------------------------------------------------------------- 1 | import { Settings } from "./Settings"; 2 | import { TreeMap } from "./TreeMap"; 3 | import { TreeNode } from "./TreeNode"; 4 | import { getFromMap } from "./getFromMap"; 5 | 6 | export function addGroupLeftX( 7 | subtree: TreeNode, 8 | settings: Settings, 9 | map: TreeMap 10 | ) { 11 | subtree.groupLeftX = subtree.x; 12 | 13 | getFromMap(subtree[settings.nextBeforeAccessor], map)?.forEach((sibling) => { 14 | subtree.groupLeftX = Math.min(subtree.groupLeftX, sibling.x); 15 | }); 16 | 17 | getFromMap(subtree[settings.nextAfterAccessor], map)?.forEach((partner) => { 18 | subtree.groupLeftX = Math.min(subtree.groupLeftX, partner.x); 19 | }); 20 | } 21 | -------------------------------------------------------------------------------- /playground/flatTree.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | "-5": { 3 | name: "-5", 4 | }, 5 | "-4": { 6 | name: "-4", 7 | }, 8 | "-3": { 9 | name: "-3", 10 | }, 11 | "-2": { 12 | name: "-2", 13 | parents: [-4, -5], 14 | }, 15 | 1: { 16 | name: "root", 17 | children: [2, 3], 18 | parents: [-2, -3], 19 | }, 20 | 2: { name: "2", siblings: [8], spouses: [7] }, 21 | 3: { name: "3", children: [4, 5], spouses: [6] }, 22 | 4: { name: "4" }, 23 | 5: { name: "5", height: 20, width: 20 }, 24 | 6: { name: "6", spouses: [] }, 25 | 7: { name: "7", height: 60, width: 60 }, 26 | 8: { name: "8" }, 27 | 9: { name: "9" }, 28 | 10: { name: "10" }, 29 | 11: { name: "11" }, 30 | 12: { name: "12" }, 31 | 13: { name: "13" }, 32 | 14: { name: "14" }, 33 | }; 34 | -------------------------------------------------------------------------------- /playground/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Entiflex! 8 | 9 | 10 | 11 | 27 |
28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /src/addGroupRightX.ts: -------------------------------------------------------------------------------- 1 | import { getNodeRightX } from "./getNodeRightX"; 2 | import { Settings } from "./Settings"; 3 | import { TreeMap } from "./TreeMap"; 4 | import { TreeNode } from "./TreeNode"; 5 | import { getFromMap } from "./getFromMap"; 6 | 7 | export function addGroupRightX( 8 | subtree: TreeNode, 9 | settings: Settings, 10 | map: TreeMap 11 | ) { 12 | subtree.groupRightX = getNodeRightX(subtree); 13 | 14 | const siblings = getFromMap(subtree[settings.nextBeforeAccessor], map); 15 | siblings?.forEach((sibling) => { 16 | subtree.groupRightX = Math.max(subtree.groupRightX, getNodeRightX(sibling)); 17 | }); 18 | 19 | const partners = getFromMap(subtree[settings.nextAfterAccessor], map); 20 | partners?.forEach((partner) => { 21 | subtree.groupRightX = Math.max(subtree.groupRightX, getNodeRightX(partner)); 22 | }); 23 | } 24 | -------------------------------------------------------------------------------- /src/addGroupBottomY.ts: -------------------------------------------------------------------------------- 1 | import { Settings } from "./Settings"; 2 | import { TreeMap } from "./TreeMap"; 3 | import { TreeNode } from "./TreeNode"; 4 | import { getFromMap } from "./getFromMap"; 5 | import { getNodeBottomY } from "./getNodeBottomY"; 6 | 7 | export function addGroupBottomY( 8 | subtree: TreeNode, 9 | settings: Settings, 10 | map: TreeMap 11 | ) { 12 | subtree.groupBottomY = getNodeBottomY(subtree); 13 | 14 | const siblings = getFromMap(subtree[settings.nextBeforeAccessor], map); 15 | siblings?.forEach((sibling) => { 16 | subtree.groupBottomY = Math.max( 17 | subtree.groupBottomY, 18 | getNodeBottomY(sibling) 19 | ); 20 | }); 21 | 22 | const partners = getFromMap(subtree[settings.nextAfterAccessor], map); 23 | partners?.forEach((partner) => { 24 | subtree.groupBottomY = Math.max( 25 | subtree.groupBottomY, 26 | getNodeBottomY(partner) 27 | ); 28 | }); 29 | } 30 | -------------------------------------------------------------------------------- /src/shiftFromCountour.ts: -------------------------------------------------------------------------------- 1 | import { TreeNode } from "./TreeNode"; 2 | 3 | export const shiftFromCountour = ( 4 | contourNode: TreeNode, 5 | node: TreeNode 6 | ) => { 7 | const nodeBottomY = node.y + node.height + node.marginBottom; 8 | 9 | const contourNodeEdgeX = 10 | contourNode.x + contourNode.width + contourNode.marginRight; 11 | const contourTopY = contourNode.y; 12 | const contourBottomY = 13 | contourNode.y + contourNode.height + contourNode.marginBottom; 14 | 15 | const coversCountour = node.y <= contourTopY && nodeBottomY >= contourBottomY; 16 | const traspassHorizontal = node.x < contourNodeEdgeX; 17 | 18 | // if (coversCountour) { 19 | // //todo: if it covers the whole box, remove box from contour array 20 | // } 21 | 22 | if ( 23 | traspassHorizontal && 24 | ((node.y > contourTopY && node.y < contourBottomY) || 25 | (nodeBottomY > contourTopY && nodeBottomY < contourBottomY) || 26 | coversCountour) 27 | ) { 28 | const delta = contourNodeEdgeX - node.x; 29 | node.x += delta; 30 | } 31 | }; 32 | -------------------------------------------------------------------------------- /fixtures/randomNode.ts: -------------------------------------------------------------------------------- 1 | import { randomId } from "./randomId"; 2 | import { randomInt } from "./randomInt"; 3 | import { randomName } from "./randomName"; 4 | 5 | export type RandomNode = { 6 | id: number; 7 | depth?: number; 8 | width?: number; 9 | height?: number; 10 | name?: string; 11 | children?: number[]; 12 | parents?: number[]; 13 | siblings?: number[]; 14 | spouses?: number[]; 15 | }; 16 | 17 | export const randomNode = (): RandomNode => ({ 18 | id: randomId(), 19 | width: randomInt(20, 80), 20 | height: randomInt(20, 80), 21 | name: randomName(), 22 | children: [], 23 | parents: [], 24 | siblings: [], 25 | spouses: [], 26 | }); 27 | 28 | // export const randomSides = (): RandomNode[] => { 29 | // return new Array(randomInt(0, 2)).fill(0).map(() => { 30 | // return randomNode({ noPartners: true, noSiblings: true }); 31 | // }); 32 | // }; 33 | 34 | // export const randomTargets = ({ childDepth, parentDepth }): RandomNode[] => { 35 | // return new Array(randomInt(1, 4)).fill(0).map(() => { 36 | // return randomNode({ childDepth, parentDepth }); 37 | // }); 38 | // }; 39 | -------------------------------------------------------------------------------- /tests/normalizeTree.test.ts: -------------------------------------------------------------------------------- 1 | import { TestNode } from "./TestNode"; 2 | import { defaultSettings } from "./../src/defaultSettings"; 3 | import { normalizeTree } from "../src/normalizeTree"; 4 | import { TreeMap } from "../src/TreeMap"; 5 | 6 | describe("normalizeTree", () => { 7 | test("check shift", () => { 8 | const root = { 9 | children: [1], 10 | groupBottomY: 0, 11 | groupLeftX: 0, 12 | groupMaxHeight: 0, 13 | groupMaxWidth: 0, 14 | groupRightX: 0, 15 | groupTopY: 0, 16 | height: 10, 17 | marginBottom: 10, 18 | marginRight: 10, 19 | width: 10, 20 | x: 0, 21 | y: 0, 22 | } as TestNode; 23 | 24 | const map: TreeMap = { 25 | 0: root, 26 | 1: { 27 | groupBottomY: 0, 28 | groupLeftX: 0, 29 | groupMaxHeight: 0, 30 | groupMaxWidth: 0, 31 | groupRightX: 0, 32 | groupTopY: 0, 33 | height: 10, 34 | marginBottom: 10, 35 | marginRight: 10, 36 | width: 10, 37 | x: 0, 38 | y: 0, 39 | }, 40 | }; 41 | 42 | normalizeTree(root, "children", defaultSettings, map); 43 | 44 | expect(map[1].x).toBe(0); 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "entitree-flex", 3 | "version": "0.4.1", 4 | "description": "Flexible Tree layout supporting ancestors, descendants and side nodes in all 4 orientations", 5 | "main": "dist/index.js", 6 | "types": "dist/index.d.ts", 7 | "scripts": { 8 | "browser": "open playground/index.html && watchify playground/source.js -p [ tsify ] -o playground/bundle.js", 9 | "build": "tsc", 10 | "dev": "tsc --watch", 11 | "prepublishOnly": "yarn build", 12 | "test": "jest" 13 | }, 14 | "files": [ 15 | "/dist" 16 | ], 17 | "author": "Orlando Groppo ", 18 | "repository": { 19 | "type": "git", 20 | "url": "https://github.com/codeledge/entitree-flex" 21 | }, 22 | "dependencies": {}, 23 | "devDependencies": { 24 | "@babel/core": "^7.17.9", 25 | "@babel/preset-env": "^7.16.11", 26 | "@babel/preset-typescript": "^7.16.7", 27 | "@svgdotjs/svg.js": "^3.1.2", 28 | "@types/jest": "^27.4.1", 29 | "babel-jest": "^28.0.0", 30 | "jest": "^28.0.0", 31 | "tsify": "^5.0.4", 32 | "typescript": "^4.6.3", 33 | "watchify": "^4.0.0" 34 | }, 35 | "keywords": [ 36 | "tree", 37 | "dendrogram", 38 | "treemap", 39 | "layout", 40 | "graph", 41 | "children", 42 | "parents", 43 | "family", 44 | "pedigree" 45 | ] 46 | } 47 | -------------------------------------------------------------------------------- /src/getInitialTargetsShiftLeft.ts: -------------------------------------------------------------------------------- 1 | import { getNextBefores } from "./getNextBefores"; 2 | import { getNextAfters } from "./getNextAfters"; 3 | import { Settings } from "./Settings"; 4 | import { TreeMap } from "./TreeMap"; 5 | import { TreeNode } from "./TreeNode"; 6 | 7 | // o -> siblings 8 | // p -> Partners 9 | // x -> node 10 | // if 000xpp oooxpp ooxPP 11 | // THE Os and Ps should not be counted! 12 | // because parent will center itself on the REAL children 13 | 14 | export const getInitialTargetsShiftLeft = ( 15 | source: TreeNode, 16 | targets: TreeNode[], 17 | settings: Settings, 18 | map: TreeMap 19 | ) => { 20 | return ( 21 | targets.reduce((totalWidth, target, index) => { 22 | //for the first child, we don't care about the padding (siblings) left 23 | if (index !== 0) { 24 | getNextBefores(target, map, settings)?.forEach((node) => { 25 | totalWidth += node.width + node.marginRight; 26 | }); 27 | } 28 | 29 | //do not add margin from last target 30 | totalWidth += 31 | target.width + (index === targets.length - 1 ? 0 : target.marginRight); 32 | 33 | if (index !== targets.length - 1) { 34 | getNextAfters(target, map, settings)?.forEach((partner) => { 35 | totalWidth += partner.width + partner.marginRight; 36 | }); 37 | } 38 | 39 | return totalWidth; 40 | }, 0) / 41 | 2 - 42 | source.width / 2 43 | ); 44 | }; 45 | -------------------------------------------------------------------------------- /src/getInitialTargetsShiftTop.ts: -------------------------------------------------------------------------------- 1 | import { Settings } from "./Settings"; 2 | import { TreeMap } from "./TreeMap"; 3 | import { TreeNode } from "./TreeNode"; 4 | import { getFromMap } from "./getFromMap"; 5 | 6 | // o -> siblings 7 | // p -> Partners 8 | // x -> node 9 | // if 000xpp oooxpp ooxPP 10 | // THE Os and Ps should not be counted! 11 | //because parent will center itself on the REAL children 12 | 13 | export const getInitialTargetsShiftTop = ( 14 | source: TreeNode, 15 | targets: TreeNode[], 16 | settings: Settings, 17 | map: TreeMap 18 | ) => { 19 | return ( 20 | targets.reduce((totalHeight, target, index) => { 21 | //for the first child, we don't care about the padding (siblings) left 22 | if (index !== 0) { 23 | const siblings = getFromMap(target[settings.nextBeforeAccessor], map); 24 | siblings?.forEach((node) => { 25 | totalHeight += node.height + node.marginBottom; 26 | }); 27 | } 28 | 29 | //do not add margin from last target 30 | totalHeight += 31 | target.height + 32 | (index === targets.length - 1 ? 0 : target.marginBottom); 33 | 34 | if (index !== targets.length - 1) { 35 | const partners = getFromMap(target[settings.nextAfterAccessor], map); 36 | partners?.forEach((partner) => { 37 | totalHeight += partner.height + partner.marginBottom; 38 | }); 39 | } 40 | 41 | return totalHeight; 42 | }, 0) / 43 | 2 - 44 | source.height / 2 45 | ); 46 | }; 47 | -------------------------------------------------------------------------------- /tests/getInitialTargetsShiftLeft.test.ts: -------------------------------------------------------------------------------- 1 | import { TestNode } from "./TestNode"; 2 | import { defaultSettings } from "../src/defaultSettings"; 3 | import { getInitialTargetsShiftLeft } from "../src/getInitialTargetsShiftLeft"; 4 | import { TreeMap } from "../src/TreeMap"; 5 | 6 | const map: TreeMap = {}; 7 | 8 | test("1 target same size", () => { 9 | const source = { width: 10, marginRight: 10 } as TestNode; 10 | const targets = [{ width: 10, marginRight: 10 }] as TestNode[]; 11 | const shift = getInitialTargetsShiftLeft( 12 | source, 13 | targets, 14 | defaultSettings, 15 | map 16 | ); 17 | 18 | expect(shift).toBe(0); 19 | }); 20 | 21 | test("1 target smaller", () => { 22 | const source = { width: 10, marginRight: 10 } as TestNode; 23 | const targets = [{ width: 8, marginRight: 10 }] as TestNode[]; 24 | const shift = getInitialTargetsShiftLeft( 25 | source, 26 | targets, 27 | defaultSettings, 28 | map 29 | ); 30 | 31 | expect(shift).toBe(-1); 32 | }); 33 | 34 | test("1 target bigger", () => { 35 | const source = { width: 10, marginRight: 10 } as TestNode; 36 | const targets = [{ width: 12, marginRight: 10 }] as TestNode[]; 37 | const shift = getInitialTargetsShiftLeft( 38 | source, 39 | targets, 40 | defaultSettings, 41 | map 42 | ); 43 | 44 | expect(shift).toBe(1); 45 | }); 46 | 47 | test("2 targets", () => { 48 | const source = { width: 100 } as TestNode; 49 | const targets = [ 50 | { width: 100, marginRight: 10 }, 51 | { width: 100, marginRight: 10 }, 52 | ] as TestNode[]; 53 | const shift = getInitialTargetsShiftLeft( 54 | source, 55 | targets, 56 | defaultSettings, 57 | map 58 | ); 59 | 60 | expect(shift).toBe(55); 61 | }); 62 | -------------------------------------------------------------------------------- /src/addRootSiblingsPositions.ts: -------------------------------------------------------------------------------- 1 | import { getNodeRightX } from "./getNodeRightX"; 2 | import { getFromMap } from "./getFromMap"; 3 | import { Settings } from "./Settings"; 4 | import { TreeMap } from "./TreeMap"; 5 | import { TreeNode } from "./TreeNode"; 6 | 7 | export const addRootSiblingsPositions = ( 8 | root: TreeNode, 9 | settings: Settings, 10 | map: TreeMap 11 | ) => { 12 | if (root[settings.nextBeforeAccessor]) 13 | getFromMap(root[settings.nextBeforeAccessor], map) 14 | .reverse() 15 | .forEach((currentNode, nextBeforeIndex, nextBefores) => { 16 | const previousNode = nextBefores[nextBeforeIndex - 1] || root; 17 | 18 | if (settings.orientation === "vertical") { 19 | currentNode.x = 20 | previousNode.x - currentNode.width - currentNode.marginRight; 21 | //align vertically 22 | currentNode.y = root.y + root.height / 2 - currentNode.height / 2; 23 | 24 | const currentBottomY = 25 | currentNode.y + currentNode.height + currentNode.marginBottom; 26 | if (currentNode.y < root.groupTopY) root.groupTopY = currentNode.y; 27 | if (currentBottomY > root.groupBottomY) 28 | root.groupBottomY = currentBottomY; 29 | } else { 30 | currentNode.y = 31 | previousNode.y - currentNode.height - currentNode.marginBottom; 32 | //align horizontally 33 | currentNode.x = root.x + root.width / 2 - currentNode.width / 2; 34 | 35 | const currentRightEdgeX = getNodeRightX(currentNode); 36 | if (currentNode.x < root.groupLeftX) root.groupLeftX = currentNode.x; 37 | if (currentRightEdgeX > root.groupRightX) 38 | root.groupRightX = currentRightEdgeX; 39 | } 40 | }); 41 | }; 42 | -------------------------------------------------------------------------------- /playground/source.js: -------------------------------------------------------------------------------- 1 | const { layoutFromMap } = require("../src/index.ts"); 2 | const flatTree = require("./flatTree.ts").default; 3 | const { randomTree } = require("../fixtures/randomTree"); 4 | 5 | var draw = SVG().addTo("body"); 6 | 7 | const { nodes, rels } = layoutFromMap(1, randomTree(), { 8 | rootX: window.innerWidth / 2, 9 | rootY: window.innerHeight / 2, 10 | sourceTargetSpacing: 20, 11 | //orientation: "horizontal", 12 | }); 13 | 14 | //console.log(nodes); 15 | 16 | rels.forEach((rel) => { 17 | draw.path(getPathD(rel.source, rel.target)); 18 | }); 19 | nodes.forEach((node) => { 20 | drawNode(node); 21 | }); 22 | 23 | function drawNode(node) { 24 | draw 25 | .rect( 26 | node.width + (node.marginRight || 0), 27 | node.height + (node.marginBottom || 0) 28 | ) 29 | .move(node.x, node.y) 30 | .radius(3) 31 | .opacity(0.1) 32 | .fill(stringToColour(node.name || "")); 33 | 34 | draw 35 | .rect(node.width, node.height) 36 | .radius(3) 37 | .move(node.x, node.y) 38 | .fill(stringToColour(node.name)); 39 | 40 | //draw.text(node.name).move(node.x, node.y); 41 | } 42 | 43 | function stringToColour(str) { 44 | str += "fixed-padding"; 45 | var hash = 0; 46 | for (var i = 0; i < str.length; i++) { 47 | hash = str.charCodeAt(i) + ((hash << 5) - hash); 48 | } 49 | var colour = "#"; 50 | for (var i = 0; i < 3; i++) { 51 | var value = (hash >> (i * 8)) & 0xff; 52 | colour += ("00" + value.toString(16)).substr(-2); 53 | } 54 | return colour; 55 | } 56 | 57 | function getPathD(sourceNode, targetNode) { 58 | return `M${sourceNode.x + sourceNode.width / 2} ${ 59 | sourceNode.y + sourceNode.height / 2 60 | } L${targetNode.x + targetNode.width / 2} ${ 61 | targetNode.y + targetNode.height / 2 62 | }`; 63 | } 64 | -------------------------------------------------------------------------------- /src/addRootSpousesPositions.ts: -------------------------------------------------------------------------------- 1 | import { getNodeBottomY } from "./getNodeBottomY"; 2 | import { getNodeRightX } from "./getNodeRightX"; 3 | import { getFromMap } from "./getFromMap"; 4 | import { Settings } from "./Settings"; 5 | import { TreeMap } from "./TreeMap"; 6 | import { TreeNode } from "./TreeNode"; 7 | 8 | export const addRootSpousesPositions = ( 9 | root: TreeNode, 10 | settings: Settings, 11 | map: TreeMap 12 | ) => { 13 | if (root[settings.nextAfterAccessor]) 14 | getFromMap(root[settings.nextAfterAccessor], map).forEach( 15 | (currentNode, nextAfterIndex, nextAfters) => { 16 | const previousNode = nextAfters[nextAfterIndex - 1] || root; 17 | 18 | if (settings.orientation === "vertical") { 19 | currentNode.x = getNodeRightX(previousNode); 20 | // align vertically 21 | currentNode.y = root.y + root.height / 2 - currentNode.height / 2; 22 | 23 | const bottomEdgeY = 24 | currentNode.y + currentNode.height + currentNode.marginBottom; 25 | if (currentNode.y < root.groupTopY) root.groupTopY = currentNode.y; 26 | if (bottomEdgeY > root.groupBottomY) root.groupBottomY = bottomEdgeY; 27 | } else { 28 | currentNode.y = getNodeBottomY(previousNode); 29 | // align horizontally 30 | currentNode.x = root.x + root.width / 2 - currentNode.width / 2; 31 | 32 | //TODO: this function is the same in addRootSiblingsPositions (and above) 33 | const currentRightEdgeX = 34 | currentNode.x + currentNode.width + currentNode.marginRight; 35 | if (currentNode.x < root.groupLeftX) root.groupLeftX = currentNode.x; 36 | if (currentRightEdgeX > root.groupRightX) 37 | root.groupRightX = currentRightEdgeX; 38 | } 39 | } 40 | ); 41 | }; 42 | -------------------------------------------------------------------------------- /src/centerSourceToTargets.ts: -------------------------------------------------------------------------------- 1 | import { Settings } from "./Settings"; 2 | import { TreeMap } from "./TreeMap"; 3 | import { TreeNode } from "./TreeNode"; 4 | import { getFromMap } from "./getFromMap"; 5 | import { last } from "./last"; 6 | 7 | export const centerSourceToTargets = ( 8 | source: TreeNode, 9 | targets: TreeNode[], 10 | settings: Settings, 11 | map: TreeMap 12 | ) => { 13 | if (!source.isRoot) { 14 | //center only on actual children, not all generational nodes 15 | const firstTarget = targets[0]; 16 | const lastTarget = last(targets); 17 | 18 | if (firstTarget && lastTarget) { 19 | if (settings.orientation === "vertical") { 20 | const newSourceX = 21 | (firstTarget.x + lastTarget.x + lastTarget.width) / 2 - 22 | source.width / 2; 23 | 24 | const delta = newSourceX - source.x; 25 | if (newSourceX !== source.x) { 26 | source.x += delta; 27 | 28 | const siblings = getFromMap(source[settings.nextBeforeAccessor], map); 29 | siblings?.forEach((sibling) => (sibling.x += delta)); 30 | 31 | const partners = getFromMap(source[settings.nextAfterAccessor], map); 32 | partners?.forEach((partner) => (partner.x += delta)); 33 | } 34 | } else { 35 | const newSourceY = 36 | (firstTarget.y + lastTarget.y + lastTarget.height) / 2 - 37 | source.height / 2; 38 | 39 | const delta = newSourceY - source.y; 40 | if (newSourceY !== source.y) { 41 | source.y += delta; 42 | 43 | const siblings = getFromMap(source[settings.nextBeforeAccessor], map); 44 | siblings?.forEach((sibling) => (sibling.y += delta)); 45 | 46 | const partners = getFromMap(source[settings.nextAfterAccessor], map); 47 | partners?.forEach((partner) => (partner.y += delta)); 48 | } 49 | } 50 | } 51 | } 52 | }; 53 | -------------------------------------------------------------------------------- /src/layoutFromMap.ts: -------------------------------------------------------------------------------- 1 | import { Settings } from "./Settings"; 2 | import { TreeMap } from "./TreeMap"; 3 | import { addGroupBottomY } from "./addGroupBottomY"; 4 | import { addGroupLeftX } from "./addGroupLeftX"; 5 | import { addGroupRightX } from "./addGroupRightX"; 6 | import { addGroupTopY } from "./addGroupTopY"; 7 | import { addLevelNodesSizes } from "./addLevelNodesSizes"; 8 | import { addRootSiblingsPositions } from "./addRootSiblingsPositions"; 9 | import { addRootSpousesPositions } from "./addRootSpousesPositions"; 10 | import { defaultSettings } from "./defaultSettings"; 11 | import { drillChildren } from "./drillChildren"; 12 | import { drillParents } from "./drillParents"; 13 | import { getElements } from "./getElements"; 14 | import { makeRoot } from "./makeRoot"; 15 | import { normalizeTree } from "./normalizeTree"; 16 | 17 | export function layoutFromMap( 18 | rootId: string | number, 19 | originalMap: Record, 20 | customSettings: Partial = {} 21 | ) { 22 | const settings: Settings = { 23 | ...defaultSettings, 24 | ...customSettings, 25 | }; 26 | 27 | const map: TreeMap = settings.clone 28 | ? JSON.parse(JSON.stringify(originalMap)) 29 | : originalMap; 30 | 31 | const root = makeRoot(map[rootId], settings); 32 | 33 | addLevelNodesSizes([root], settings, map); 34 | 35 | addRootSiblingsPositions(root, settings, map); 36 | addRootSpousesPositions(root, settings, map); 37 | 38 | addGroupBottomY(root, settings, map); 39 | addGroupRightX(root, settings, map); 40 | addGroupLeftX(root, settings, map); 41 | addGroupTopY(root, settings, map); 42 | 43 | drillChildren(root, settings, map); 44 | normalizeTree(root, settings.targetsAccessor, settings, map); 45 | 46 | drillParents(root, settings, map); 47 | normalizeTree(root, settings.sourcesAccessor, settings, map); 48 | 49 | return getElements(root, settings, map); 50 | } 51 | -------------------------------------------------------------------------------- /fixtures/randomTree.ts: -------------------------------------------------------------------------------- 1 | import { RandomNode, randomNode } from "./randomNode"; 2 | 3 | import { randomInt } from "./randomInt"; 4 | 5 | type RandomTree = Record; 6 | 7 | export const randomTree = (): RandomTree => { 8 | const tree = {}; 9 | const root = randomNode(); 10 | 11 | const childDepth = randomInt(1, 3); 12 | const parentDepth = randomInt(1, 3); 13 | 14 | tree[root.id] = root; 15 | spawnChildren(root, 0); 16 | function spawnChildren(subtree, depth) { 17 | if (depth === childDepth) { 18 | return; 19 | } 20 | for (let index = 0; index < randomInt(1, 3); index++) { 21 | const child = randomNode(); 22 | 23 | for (let index = 0; index < randomInt(0, 2); index++) { 24 | const spouse = randomNode(); 25 | tree[spouse.id] = spouse; 26 | child.spouses.push(spouse.id); 27 | } 28 | 29 | for (let index = 0; index < randomInt(0, 2); index++) { 30 | const sibling = randomNode(); 31 | tree[sibling.id] = sibling; 32 | child.siblings.push(sibling.id); 33 | } 34 | 35 | tree[child.id] = child; 36 | subtree.children.push(child.id); 37 | spawnChildren(child, depth + 1); 38 | } 39 | } 40 | 41 | spawnParents(root, 0); 42 | function spawnParents(subtree, depth) { 43 | if (depth === parentDepth) { 44 | return; 45 | } 46 | for (let index = 0; index < randomInt(1, 3); index++) { 47 | const parent = randomNode(); 48 | 49 | for (let index = 0; index < randomInt(0, 2); index++) { 50 | const spouse = randomNode(); 51 | tree[spouse.id] = spouse; 52 | parent.spouses.push(spouse.id); 53 | } 54 | 55 | for (let index = 0; index < randomInt(0, 2); index++) { 56 | const sibling = randomNode(); 57 | tree[sibling.id] = sibling; 58 | parent.siblings.push(sibling.id); 59 | } 60 | 61 | tree[parent.id] = parent; 62 | subtree.parents.push(parent.id); 63 | spawnParents(parent, depth + 1); 64 | } 65 | } 66 | 67 | return tree; 68 | }; 69 | -------------------------------------------------------------------------------- /src/checkContourOverlap.ts: -------------------------------------------------------------------------------- 1 | import { getNodeBottomY } from "./getNodeBottomY"; 2 | import { Settings } from "./Settings"; 3 | import { TreeNode } from "./TreeNode"; 4 | import { getNodeRightX } from "./getNodeRightX"; 5 | 6 | export function checkContourOverlap( 7 | contourSet: TreeNode[], 8 | node: TreeNode, 9 | settings: Settings 10 | ) { 11 | contourSet.forEach((contourNode) => { 12 | if (settings.orientation === "vertical") { 13 | const nodeBottomY = getNodeBottomY(node); 14 | 15 | const contourRightX = getNodeRightX(contourNode); 16 | 17 | const contourTopY = contourNode.y; 18 | const contourBottomY = getNodeBottomY(contourNode); 19 | 20 | const coversCountour = 21 | node.y <= contourTopY && nodeBottomY >= contourBottomY; 22 | const traspassHorizontal = node.x < contourRightX; 23 | 24 | // if (coversCountour) { 25 | // //todo: if it covers the whole box, remove box from contour array 26 | // } 27 | 28 | if ( 29 | traspassHorizontal && 30 | ((node.y > contourTopY && node.y < contourBottomY) || 31 | (nodeBottomY > contourTopY && nodeBottomY < contourBottomY) || 32 | coversCountour) 33 | ) { 34 | node.x += contourRightX - node.x; 35 | } 36 | } else { 37 | const nodeRightX = getNodeRightX(node); 38 | 39 | const contourBottomY = getNodeBottomY(contourNode); 40 | const contourLeftX = contourNode.x; 41 | const contourRightX = getNodeRightX(contourNode); 42 | 43 | const coversCountour = 44 | node.x <= contourLeftX && nodeRightX >= contourRightX; 45 | const traspassVertical = node.y < contourBottomY; 46 | 47 | // if (coversCountour) { 48 | // //todo: if it covers the whole box, remove box from contour array 49 | // } 50 | 51 | if ( 52 | traspassVertical && 53 | ((node.x > contourLeftX && node.x < contourRightX) || 54 | (nodeRightX > contourLeftX && nodeRightX < contourRightX) || 55 | coversCountour) 56 | ) { 57 | node.y += contourBottomY - node.y; 58 | } 59 | } 60 | }); 61 | contourSet.push(node); 62 | } 63 | -------------------------------------------------------------------------------- /src/normalizeTree.ts: -------------------------------------------------------------------------------- 1 | import { Settings } from "./Settings"; 2 | import { TreeMap } from "./TreeMap"; 3 | import { TreeNode } from "./TreeNode"; 4 | import { getFromMap } from "./getFromMap"; 5 | import { last } from "./last"; 6 | 7 | export const normalizeTree = ( 8 | root: TreeNode, 9 | accessor: string, 10 | settings: Settings, 11 | map: TreeMap 12 | ) => { 13 | const targets = getFromMap(root[accessor], map); 14 | if (!targets || !targets.length) return; 15 | 16 | const firstTargetSiblings = getFromMap( 17 | targets[0][settings.nextBeforeAccessor], 18 | map 19 | ); 20 | 21 | const firstMostNode = firstTargetSiblings?.[0] || targets[0]; 22 | 23 | const lastTarget = last(targets); 24 | const lastTargetPartner = last( 25 | getFromMap(lastTarget[settings.nextAfterAccessor], map) 26 | ); 27 | 28 | const lastMostNode = lastTargetPartner || lastTarget; 29 | 30 | let shift; 31 | if (settings.orientation === "vertical") { 32 | const centerPointX = 33 | (firstMostNode.x + lastMostNode.x + lastMostNode.width) / 2; 34 | 35 | const rootCenterX = root.x + root.width / 2; 36 | shift = centerPointX - rootCenterX; 37 | 38 | targets.forEach((node) => { 39 | normalizeTargetsX(node); 40 | }); 41 | } else { 42 | const centerPointY = 43 | (firstMostNode.y + lastMostNode.y + lastMostNode.height) / 2; 44 | 45 | const rootCenterY = root.y + root.height / 2; 46 | shift = centerPointY - rootCenterY; 47 | 48 | targets.forEach((node) => { 49 | normalizeTargetsY(node); 50 | }); 51 | } 52 | 53 | function normalizeTargetsX(subtree) { 54 | subtree.x -= shift; 55 | 56 | getFromMap(subtree[settings.nextBeforeAccessor], map)?.forEach( 57 | (sibling) => { 58 | sibling.x -= shift; 59 | } 60 | ); 61 | 62 | getFromMap(subtree[settings.nextAfterAccessor], map)?.forEach((partner) => { 63 | partner.x -= shift; 64 | }); 65 | 66 | getFromMap(subtree[accessor], map)?.forEach((node) => { 67 | normalizeTargetsX(node); 68 | }); 69 | } 70 | 71 | function normalizeTargetsY(subtree) { 72 | subtree.y -= shift; 73 | 74 | getFromMap(subtree[settings.nextBeforeAccessor], map)?.forEach( 75 | (sibling) => { 76 | sibling.y -= shift; 77 | } 78 | ); 79 | 80 | getFromMap(subtree[settings.nextAfterAccessor], map)?.forEach((partner) => { 81 | partner.y -= shift; 82 | }); 83 | 84 | getFromMap(subtree[accessor], map)?.forEach((node) => { 85 | normalizeTargetsY(node); 86 | }); 87 | } 88 | }; 89 | -------------------------------------------------------------------------------- /src/getElements.ts: -------------------------------------------------------------------------------- 1 | import { Settings } from "./Settings"; 2 | import { TreeMap } from "./TreeMap"; 3 | import { TreeNode } from "./TreeNode"; 4 | import { TreeRel } from "./TreeRel"; 5 | import { getFromMap } from "./getFromMap"; 6 | 7 | export const getElements = ( 8 | root: TreeNode, 9 | settings: Settings, 10 | map: TreeMap 11 | ): { 12 | map: TreeMap; 13 | maxBottom: number; 14 | maxLeft: number; 15 | maxRight: number; 16 | maxTop: number; 17 | nodes: TreeNode[]; 18 | rels: TreeRel[]; 19 | } => { 20 | const nodes = [root]; 21 | const rels = []; 22 | 23 | let maxRight = root.x + root.width; 24 | let maxLeft = root.x; 25 | let maxBottom = root.y + root.height; 26 | let maxTop = root.y; 27 | 28 | function compare(node) { 29 | maxRight = Math.max(maxRight, node.x + node.width); 30 | maxLeft = Math.min(maxLeft, node.x); 31 | maxBottom = Math.max(maxBottom, node.y + node.height); 32 | maxTop = Math.min(maxTop, node.y); 33 | } 34 | 35 | function processNextBefores(subtree) { 36 | const nextBefores = getFromMap(subtree[settings.nextBeforeAccessor], map); 37 | 38 | nextBefores?.forEach((sibling) => { 39 | compare(sibling); 40 | nodes.push(sibling); 41 | rels.push({ source: subtree, target: sibling }); 42 | }); 43 | } 44 | 45 | function processNextAfters(subtree) { 46 | const nextAfters = getFromMap(subtree[settings.nextAfterAccessor], map); 47 | 48 | nextAfters?.forEach((spouse) => { 49 | compare(spouse); 50 | nodes.push(spouse); 51 | rels.push({ source: subtree, target: spouse }); 52 | }); 53 | } 54 | 55 | drill(root); 56 | function drill(subtree, direction?: "parents" | "children") { 57 | processNextBefores(subtree); 58 | processNextAfters(subtree); 59 | 60 | if (!direction || direction === "parents") { 61 | const parents = getFromMap(subtree[settings.sourcesAccessor], map); 62 | 63 | parents?.forEach((parent) => { 64 | compare(parent); 65 | nodes.push(parent); 66 | rels.push({ source: subtree, target: parent }); 67 | drill(parent, "parents"); 68 | }); 69 | } 70 | 71 | if (!direction || direction === "children") { 72 | const children = getFromMap(subtree[settings.targetsAccessor], map); 73 | children?.forEach((child) => { 74 | compare(child); 75 | nodes.push(child); 76 | rels.push({ source: subtree, target: child }); 77 | drill(child, "children"); 78 | }); 79 | } 80 | } 81 | 82 | return { 83 | map, 84 | nodes, 85 | rels, 86 | maxRight, 87 | maxLeft, 88 | maxBottom, 89 | maxTop, 90 | }; 91 | }; 92 | -------------------------------------------------------------------------------- /src/addLevelNodesSizes.ts: -------------------------------------------------------------------------------- 1 | import { getNodeBottomY } from "./getNodeBottomY"; 2 | import { Settings } from "./Settings"; 3 | import { TreeMap } from "./TreeMap"; 4 | import { TreeNode } from "./TreeNode"; 5 | import { getFromMap } from "./getFromMap"; 6 | import { getNodeRightX } from "./getNodeRightX"; 7 | 8 | export const addLevelNodesSizes = ( 9 | levelNodes: TreeNode[], 10 | settings: Settings, 11 | map: TreeMap 12 | ): void => { 13 | levelNodes.forEach((node, index) => { 14 | node.width = node.width || settings.nodeWidth; 15 | node.height = node.height || settings.nodeHeight; 16 | if (settings.orientation === "vertical") { 17 | node.marginBottom = settings.sourceTargetSpacing; 18 | } else { 19 | node.marginRight = settings.sourceTargetSpacing; 20 | } 21 | 22 | //initial values 23 | node.groupMaxHeight = node.height; 24 | node.groupMaxWidth = node.width; 25 | 26 | const siblings = getFromMap(node[settings.nextBeforeAccessor], map); 27 | siblings?.forEach((sibling) => { 28 | sibling.width = sibling.width || settings.nodeWidth; 29 | sibling.height = sibling.height || settings.nodeHeight; 30 | if (settings.orientation === "vertical") { 31 | sibling.marginRight = settings.nextBeforeSpacing; 32 | sibling.marginBottom = settings.sourceTargetSpacing; 33 | } else { 34 | sibling.marginBottom = settings.nextBeforeSpacing; 35 | sibling.marginRight = settings.sourceTargetSpacing; 36 | } 37 | 38 | //check maxes 39 | node.groupMaxHeight = Math.max(node.groupMaxHeight, sibling.height); 40 | node.groupMaxWidth = Math.max(node.groupMaxWidth, sibling.width); 41 | }); 42 | 43 | const spouses = getFromMap(node[settings.nextAfterAccessor], map); 44 | spouses?.forEach((spouse, spouseIndex) => { 45 | spouse.width = spouse.width || settings.nodeWidth; 46 | spouse.height = spouse.height || settings.nodeHeight; 47 | if (spouseIndex === spouses.length - 1) { 48 | // secondDegreeSpacing because you want more space between the last spouse and the next child 49 | // so they don't get confused as being both children 50 | if (settings.orientation === "vertical") { 51 | spouse.marginRight = settings.secondDegreeSpacing; 52 | } else { 53 | spouse.marginBottom = settings.secondDegreeSpacing; 54 | } 55 | } else { 56 | if (settings.orientation === "vertical") { 57 | spouse.marginRight = settings.nextAfterSpacing; 58 | } else { 59 | spouse.marginBottom = settings.nextAfterSpacing; 60 | } 61 | } 62 | if (settings.orientation === "vertical") { 63 | spouse.marginBottom = settings.sourceTargetSpacing; 64 | } else { 65 | spouse.marginRight = settings.sourceTargetSpacing; 66 | } 67 | 68 | node.groupMaxHeight = Math.max(node.groupMaxHeight, spouse.height); 69 | node.groupMaxWidth = Math.max(node.groupMaxWidth, spouse.width); 70 | }); 71 | 72 | if (spouses && spouses.length) { 73 | //for sure there is an after node 74 | if (settings.orientation === "vertical") { 75 | node.marginRight = settings.nextAfterSpacing; 76 | } else { 77 | node.marginBottom = settings.nextAfterSpacing; 78 | } 79 | } else { 80 | if (index === levelNodes.length - 1) { 81 | // there is a cousin next 82 | if (settings.orientation === "vertical") { 83 | node.marginRight = settings.secondDegreeSpacing; 84 | } else { 85 | node.marginBottom = settings.secondDegreeSpacing; 86 | } 87 | } else { 88 | //there is sibling next 89 | if (settings.orientation === "vertical") { 90 | node.marginRight = settings.firstDegreeSpacing; 91 | } else { 92 | node.marginBottom = settings.firstDegreeSpacing; 93 | } 94 | } 95 | } 96 | }); 97 | }; 98 | -------------------------------------------------------------------------------- /src/drillChildren.ts: -------------------------------------------------------------------------------- 1 | import { addGroupBottomY } from "./addGroupBottomY"; 2 | import { addGroupRightX } from "./addGroupRightX"; 3 | import { addLevelNodesSizes } from "./addLevelNodesSizes"; 4 | import { centerSourceToTargets } from "./centerSourceToTargets"; 5 | import { checkContourOverlap } from "./checkContourOverlap"; 6 | import { getFromMap } from "./getFromMap"; 7 | import { getInitialTargetsShiftLeft } from "./getInitialTargetsShiftLeft"; 8 | import { getInitialTargetsShiftTop } from "./getInitialTargetsShiftTop"; 9 | import { getNodeBottomY } from "./getNodeBottomY"; 10 | import { getNodeRightX } from "./getNodeRightX"; 11 | 12 | const descendantsContour = []; 13 | export function drillChildren(subtree, settings, map) { 14 | const children = getFromMap(subtree[settings.targetsAccessor], map); 15 | if (!children?.length) return; 16 | 17 | addLevelNodesSizes(children, settings, map); 18 | 19 | if (settings.orientation === "vertical") { 20 | const initialShiftLeft = getInitialTargetsShiftLeft( 21 | subtree, 22 | children, 23 | settings, 24 | map 25 | ); 26 | let currentX = subtree.x - initialShiftLeft; 27 | 28 | children.forEach((child) => { 29 | const midVerticalY = subtree.groupBottomY + child.groupMaxHeight / 2; 30 | 31 | /////////////////// BEFORES /////////////////// 32 | const siblings = getFromMap(child[settings.nextBeforeAccessor], map); 33 | siblings?.forEach((sibling) => { 34 | sibling.x = currentX; 35 | sibling.y = midVerticalY - sibling.height / 2; 36 | 37 | checkContourOverlap(descendantsContour, sibling, settings); 38 | 39 | currentX = getNodeRightX(sibling); 40 | }); 41 | 42 | /////////////////// GROUP MAIN NODE 43 | 44 | //Set positions 45 | child.x = currentX; 46 | child.y = midVerticalY - child.height / 2; 47 | 48 | checkContourOverlap(descendantsContour, child, settings); 49 | currentX = getNodeRightX(child); 50 | 51 | /////////////////// AFTERS /////////////////// 52 | getFromMap(child[settings.nextAfterAccessor], map)?.forEach((partner) => { 53 | partner.x = currentX; 54 | partner.y = midVerticalY - partner.height / 2; 55 | 56 | checkContourOverlap(descendantsContour, partner, settings); 57 | currentX = getNodeRightX(partner); 58 | }); 59 | 60 | addGroupBottomY(child, settings, map); 61 | 62 | drillChildren(child, settings, map); 63 | }); 64 | } else { 65 | const initialShiftTop = getInitialTargetsShiftTop( 66 | subtree, 67 | children, 68 | settings, 69 | map 70 | ); 71 | let currentY = subtree.y - initialShiftTop; 72 | 73 | children.forEach((child) => { 74 | const midPointX = subtree.groupRightX + child.groupMaxWidth / 2; 75 | 76 | /////////////////// SIBLING 77 | const siblings = getFromMap(child[settings.nextBeforeAccessor], map); 78 | siblings?.forEach((sibling) => { 79 | sibling.y = currentY; 80 | sibling.x = midPointX - sibling.width / 2; 81 | 82 | checkContourOverlap(descendantsContour, sibling, settings); 83 | 84 | currentY = getNodeBottomY(sibling); 85 | }); 86 | 87 | /////////////////// CHILD 88 | 89 | //Set positions 90 | child.y = currentY; 91 | child.x = midPointX - child.width / 2; 92 | 93 | checkContourOverlap(descendantsContour, child, settings); 94 | currentY = getNodeBottomY(child); 95 | 96 | /////////////////// partners 97 | const partners = getFromMap(child[settings.nextAfterAccessor], map); 98 | partners?.forEach((partner) => { 99 | partner.y = currentY; 100 | partner.x = midPointX - partner.width / 2; 101 | 102 | checkContourOverlap(descendantsContour, partner, settings); 103 | currentY = getNodeBottomY(partner); 104 | }); 105 | 106 | addGroupRightX(child, settings, map); 107 | 108 | drillChildren(child, settings, map); 109 | }); 110 | } 111 | 112 | centerSourceToTargets(subtree, children, settings, map); 113 | } 114 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # entitree-flex 2 | 3 | This is the core package that fuels the iconic https://www.entitree.com website! 4 | 5 | In a paper from 2013, A.J. van der Ploeg enhanced the Tidy Tree (Reingold-Tilford) algorithm to allow 6 | for variable-sized nodes, while keeping its linear runtime nature. He 7 | described the algorithm in his paper, [Drawing Non-layered Tidy Trees in 8 | Linear Time](https://core.ac.uk/download/pdf/301654972.pdf). The author also provided a working Java application 9 | on GitHub at https://github.com/cwi-swat/non-layered-tidy-trees 10 | 11 | This package take it to the next level by allowing also side nodes, very useful if you are drawing family trees and pedigrees. 12 | 13 | ## Examples 14 | 15 | Screenshot 2021-07-11 at 16 41 14 16 | Screenshot 2021-07-11 at 16 40 53 17 | Screenshot 2021-07-11 at 16 40 40 18 | Screenshot 2021-07-11 at 16 40 17 19 | 20 | ## Install 21 | 22 | ``` 23 | npm i entitree-flex 24 | ``` 25 | 26 | OR 27 | 28 | ``` 29 | yarn add entitree-flex 30 | ``` 31 | 32 | It does come with TS definitions 33 | 34 | ## Usage from flat object 35 | 36 | ``` 37 | const { layoutFromMap } = require("entitree-flex") 38 | //or 39 | import { layoutFromMap } from "entitree-flex" 40 | 41 | const flatTree = { 42 | 1: { 43 | name: "root", 44 | width: 14, 45 | children: [2, 3], 46 | parents: [7] 47 | }, 48 | 2: { name: "child2" }, 49 | 3: { name: "child3", children: [4, 5], spouses: [6] }, 50 | 4: { name: "grandChild4" }, 51 | 5: { name: "grandChild5" }, 52 | 6: { name: "spouse of child 3" }, 53 | 7: { name: "parent of root" }, 54 | }; 55 | 56 | const { map, maxBottom, maxLeft, maxRight, maxTop, nodes, rels } = layoutFromMap(1, flatTree [, settings]) 57 | ``` 58 | 59 | ## Playground 60 | 61 | You can play live in your browser with random trees or make your own tree for testing. 62 | 63 | Just run `yarn browser` and then open the file `playground/index.html` in your broser and see the results. 64 | 65 | Edit the `playground/source.js` file to see changes. 66 | 67 | ## Settings 68 | 69 | Structure and defaults of the settings 70 | 71 | ``` 72 | defaultSettings = { 73 | clone: false, // returns a copy of the input, if your application does not allow editing the original object 74 | enableFlex: true, // has slightly better perfomance if turned off (node.width, node.height will not be read) 75 | firstDegreeSpacing: 15, // spacing in px between nodes belonging to the same source, eg children with same parent 76 | nextAfterAccessor: "spouses", // the side node prop used to go sideways, AFTER the current node 77 | nextAfterSpacing: 10, // the spacing of the "side" nodes AFTER the current node 78 | nextBeforeAccessor: "siblings", // the side node prop used to go sideways, BEFORE the current node 79 | nextBeforeSpacing: 10, // the spacing of the "side" nodes BEFORE the current node 80 | nodeHeight: 40, // default node height in px 81 | nodeWidth: 40, // default node width in px 82 | orientation: "vertical", // "vertical" to see parents top and children bottom, "horizontal" to see parents left and 83 | rootX: 0, // set root position if other than 0 84 | rootY: 0, // set root position if other than 0 85 | secondDegreeSpacing: 20, // spacing in px between nodes not belonging to same parent eg "cousin" nodes 86 | sourcesAccessor: "parents", // the prop used as the array of ancestors ids 87 | sourceTargetSpacing: 10, // the "vertical" spacing between nodes in vertical orientation, horizontal otherwise 88 | targetsAccessor: "children", // the prop used as the array of children ids 89 | }; 90 | ``` 91 | 92 | ## Similar examples in javascript 93 | 94 | - https://github.com/d3/d3-hierarchy no bidirectional, no flexible, no side nodes 95 | - https://github.com/Klortho/d3-flextree no bidirectional, no side nodes 96 | 97 | ## License 98 | 99 | GNU General Public License v3.0 100 | 101 | Copyright (c) 2022, Codeledge 102 | -------------------------------------------------------------------------------- /src/drillParents.ts: -------------------------------------------------------------------------------- 1 | import { addGroupLeftX } from "./addGroupLeftX"; 2 | import { addGroupTopY } from "./addGroupTopY"; 3 | import { addLevelNodesSizes } from "./addLevelNodesSizes"; 4 | import { centerSourceToTargets } from "./centerSourceToTargets"; 5 | import { checkContourOverlap } from "./checkContourOverlap"; 6 | import { getFromMap } from "./getFromMap"; 7 | import { getInitialTargetsShiftLeft } from "./getInitialTargetsShiftLeft"; 8 | import { getInitialTargetsShiftTop } from "./getInitialTargetsShiftTop"; 9 | import { getNodeBottomY } from "./getNodeBottomY"; 10 | import { getNodeRightX } from "./getNodeRightX"; 11 | 12 | const parentsContour = []; 13 | export function drillParents(subtree, settings, map) { 14 | const parents = getFromMap(subtree[settings.sourcesAccessor], map); 15 | if (!parents?.length) return; 16 | 17 | addLevelNodesSizes(parents, settings, map); 18 | 19 | if (settings.orientation === "vertical") { 20 | const initialShiftLeft = getInitialTargetsShiftLeft( 21 | subtree, 22 | parents, 23 | settings, 24 | map 25 | ); 26 | let currentX = subtree.x - initialShiftLeft; 27 | 28 | parents.forEach((parent) => { 29 | const midVerticalY = 30 | subtree.groupTopY - 31 | settings.sourceTargetSpacing - 32 | parent.groupMaxHeight / 2; 33 | 34 | /////////////////// BEFORES /////////////////// 35 | getFromMap(parent[settings.nextBeforeAccessor], map)?.forEach( 36 | (sibling) => { 37 | sibling.x = currentX; 38 | sibling.y = midVerticalY - sibling.height / 2; 39 | 40 | checkContourOverlap(parentsContour, sibling, settings); 41 | 42 | currentX = getNodeRightX(sibling); 43 | } 44 | ); 45 | 46 | /////////////////// GROUP MAIN NODE /////////////// 47 | //set positions 48 | parent.x = currentX; 49 | parent.y = midVerticalY - parent.height / 2; 50 | 51 | //check if touches one of the contours 52 | checkContourOverlap(parentsContour, parent, settings); 53 | currentX = getNodeRightX(parent); 54 | 55 | /////////////////// AFTERS /////////////////// 56 | getFromMap(parent[settings.nextAfterAccessor], map)?.forEach( 57 | (partner) => { 58 | partner.x = currentX; 59 | partner.y = midVerticalY - partner.height / 2; 60 | 61 | checkContourOverlap(parentsContour, partner, settings); 62 | 63 | currentX = getNodeRightX(partner); 64 | } 65 | ); 66 | 67 | addGroupTopY(parent, settings, map); 68 | 69 | drillParents(parent, settings, map); 70 | }); 71 | } else { 72 | const initialShiftTop = getInitialTargetsShiftTop( 73 | subtree, 74 | parents, 75 | settings, 76 | map 77 | ); 78 | let currentY = subtree.y - initialShiftTop; 79 | 80 | parents.forEach((parent) => { 81 | const midPointX = 82 | subtree.groupLeftX - 83 | settings.sourceTargetSpacing - 84 | parent.groupMaxWidth / 2; 85 | 86 | /////////////////// SIBLING 87 | getFromMap(parent[settings.nextBeforeAccessor], map)?.forEach( 88 | (sibling) => { 89 | sibling.y = currentY; 90 | sibling.x = midPointX - sibling.width / 2; 91 | 92 | checkContourOverlap(parentsContour, sibling, settings); 93 | 94 | //update currentY position 95 | currentY = getNodeBottomY(sibling); 96 | } 97 | ); 98 | 99 | /////////////////// GROUP MAIN NODE /////////////// 100 | //Set positions 101 | parent.y = currentY; 102 | parent.x = midPointX - parent.width / 2; 103 | 104 | checkContourOverlap(parentsContour, parent, settings); 105 | //update currentY position 106 | currentY = getNodeBottomY(parent); 107 | 108 | /////////////////// SPOUSES 109 | getFromMap(parent[settings.nextAfterAccessor], map)?.forEach( 110 | (partner) => { 111 | partner.y = currentY; 112 | partner.x = midPointX - partner.width / 2; 113 | 114 | checkContourOverlap(parentsContour, partner, settings); 115 | //update currentY position 116 | currentY = getNodeBottomY(partner); 117 | } 118 | ); 119 | 120 | addGroupLeftX(parent, settings, map); 121 | 122 | drillParents(parent, settings, map); 123 | }); 124 | } 125 | 126 | centerSourceToTargets(subtree, parents, settings, map); 127 | } 128 | -------------------------------------------------------------------------------- /tree.js: -------------------------------------------------------------------------------- 1 | var draw = SVG().addTo("#drawing").size(400, 400); 2 | 3 | const nodeWidth = 40; 4 | const nodeHeight = 40; 5 | const nodePadding = 5; 6 | 7 | const tree = { 8 | name: "root", 9 | children: [ 10 | { 11 | name: "pino", 12 | width: 112, 13 | height: 40, 14 | children: [ 15 | { 16 | name: "stronz", 17 | children: [ 18 | { 19 | name: "sAt2", 20 | width: 22, 21 | }, 22 | { 23 | name: "stron3", 24 | width: 22, 25 | children: [ 26 | { 27 | name: "sok", 28 | width: 62, 29 | }, 30 | ], 31 | }, 32 | ], 33 | }, 34 | { 35 | name: "ronz", 36 | height: 180, 37 | children: [ 38 | { 39 | name: "akn1", 40 | children: [ 41 | { 42 | name: "ston2", 43 | }, 44 | { 45 | name: "ston3", 46 | width: 72, 47 | }, 48 | ], 49 | }, 50 | { 51 | name: "akn2", 52 | }, 53 | { 54 | name: "akn3", 55 | children: [ 56 | { 57 | name: "stron2", 58 | }, 59 | { 60 | name: "stron3", 61 | }, 62 | ], 63 | }, 64 | ], 65 | }, 66 | ], 67 | }, 68 | { 69 | name: "gino", 70 | width: 52, 71 | children: [ 72 | { 73 | name: "shubb", 74 | width: 82, 75 | children: [ 76 | { 77 | name: "cane", 78 | }, 79 | ], 80 | }, 81 | { 82 | name: "babba", 83 | }, 84 | { 85 | name: "gaba", 86 | }, 87 | ], 88 | }, 89 | ], 90 | parents: [ 91 | { 92 | name: "caio", 93 | }, 94 | ], 95 | }; 96 | 97 | layout(tree); 98 | 99 | //console.log(tree); 100 | 101 | drawPaths(tree); 102 | drawLayout(tree); 103 | 104 | function stringToColour(str) { 105 | var hash = 0; 106 | for (var i = 0; i < str.length; i++) { 107 | hash = str.charCodeAt(i) + ((hash << 5) - hash); 108 | } 109 | var colour = "#"; 110 | for (var i = 0; i < 3; i++) { 111 | var value = (hash >> (i * 8)) & 0xff; 112 | colour += ("00" + value.toString(16)).substr(-2); 113 | } 114 | return colour; 115 | } 116 | 117 | function drawPaths(subtree) { 118 | drill(subtree); 119 | function drill(subtree) { 120 | if (subtree.children) { 121 | subtree.children.forEach((child, index) => { 122 | draw.path( 123 | `M${subtree.x + subtree.width / 2 + nodePadding} ${ 124 | subtree.y + subtree.height + nodePadding 125 | } L${child.x + child.width / 2 + nodePadding} ${ 126 | child.y + nodePadding 127 | }` 128 | ); 129 | drill(child); 130 | }); 131 | } 132 | } 133 | } 134 | 135 | function drawLayout(subtree) { 136 | drill(subtree); 137 | function drill(subtree) { 138 | draw 139 | .rect(subtree.width, subtree.height) 140 | // .move( 141 | // subtree.x - subtree.width / 2 + nodePadding, 142 | // subtree.y - subtree.height / 2 + nodePadding 143 | // ) 144 | .move(subtree.x + nodePadding, subtree.y + nodePadding) 145 | .fill(stringToColour(subtree.name)); 146 | 147 | draw 148 | .text(subtree.name) 149 | .move(subtree.x + nodePadding, subtree.y + nodePadding); 150 | 151 | if (subtree.children) { 152 | subtree.children.forEach((child, index) => { 153 | drill(child); 154 | }); 155 | } 156 | } 157 | } 158 | 159 | function layout(root) { 160 | const contourLeft = [root]; 161 | 162 | root.x = 120; 163 | root.y = 0; 164 | 165 | drill(root); 166 | function drill(subtree) { 167 | subtree.width = subtree.width || nodeWidth; 168 | subtree.height = subtree.height || nodeHeight; 169 | if (!subtree.children) return; 170 | 171 | let currentChildY = subtree.y + subtree.height + 2 * nodePadding; 172 | subtree.children.forEach((child, index) => { 173 | child.width = child.width || nodeWidth; 174 | child.height = child.height || nodeHeight; 175 | 176 | child.parent = subtree; 177 | if (index === 0) { 178 | const initialShiftLeft = 179 | subtree.children.reduce((totalWidth, child) => { 180 | totalWidth += (child.width || nodeWidth) + 2 * nodePadding; 181 | return totalWidth; 182 | }, 0) / 183 | 2 - 184 | (subtree.width + 2 * nodePadding) / 2; 185 | child.x = subtree.x - initialShiftLeft; 186 | } else { 187 | const previousSibling = subtree.children[index - 1]; 188 | child.x = previousSibling.x + previousSibling.width + 2 * nodePadding; 189 | } 190 | child.y = currentChildY; 191 | 192 | const childLeftBorder = { 193 | x: child.x, 194 | topY: currentChildY, 195 | bottomY: currentChildY + child.height + 2 * nodePadding, 196 | }; 197 | //check if touches one of the contours 198 | contourLeft.forEach((contourNode) => { 199 | const contourRightBorder = { 200 | x: contourNode.x + contourNode.width + 2 * nodePadding, 201 | topY: contourNode.y, 202 | bottomY: contourNode.y + contourNode.height + 2 * nodePadding, 203 | }; 204 | 205 | const childLeftBorderCovers = 206 | childLeftBorder.topY <= contourRightBorder.topY && 207 | childLeftBorder.bottomY >= contourRightBorder.bottomY; 208 | 209 | if (child.name === "ronz" && contourNode.name === "sok") { 210 | console.log(childLeftBorder, child, contourRightBorder); 211 | } 212 | 213 | if ( 214 | contourRightBorder.x >= childLeftBorder.x && 215 | ((childLeftBorder.topY > contourRightBorder.topY && 216 | childLeftBorder.topY < contourRightBorder.bottomY) || 217 | (childLeftBorder.bottomY > contourRightBorder.topY && 218 | childLeftBorder.bottomY < contourRightBorder.bottomY) || 219 | childLeftBorderCovers) 220 | ) { 221 | if (childLeftBorderCovers) { 222 | //todo: if it covers the whole box, remove box from contour 223 | } 224 | 225 | const delta = contourRightBorder.x - childLeftBorder.x; 226 | child.x += delta; 227 | childLeftBorder.x += delta; 228 | } 229 | }); 230 | 231 | contourLeft.push(child); 232 | 233 | drill(child); 234 | }); 235 | 236 | //put the parent in the middle of the children, when done 237 | const lasChild = subtree.children[subtree.children.length - 1]; 238 | subtree.x = 239 | (subtree.children[0].x + lasChild.x + lasChild.width + 2 * nodePadding) / 240 | 2 - 241 | (subtree.width + 2 * nodePadding) / 2; 242 | } 243 | } 244 | --------------------------------------------------------------------------------