diff --git a/examples/assets/icons.ts b/examples/assets/icons.ts new file mode 100644 index 00000000..6b7ec8bc --- /dev/null +++ b/examples/assets/icons.ts @@ -0,0 +1,2 @@ +export const person = `data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPHN2ZyB3aWR0aD0iMTZweCIgaGVpZ2h0PSIxNnB4IiB2aWV3Qm94PSIwIDAgMTYgMTYiIHZlcnNpb249IjEuMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayI+CiAgICA8IS0tIEdlbmVyYXRvcjogc2tldGNodG9vbCA2My4xICgxMDEwMTApIC0gaHR0cHM6Ly9za2V0Y2guY29tIC0tPgogICAgPHRpdGxlPkNDNEQ2NkYzLTg3QTktNEIxQy1CRjNBLUMwQUY0RjQ5OTM1MTwvdGl0bGU+CiAgICA8ZGVzYz5DcmVhdGVkIHdpdGggc2tldGNodG9vbC48L2Rlc2M+CiAgICA8ZGVmcz4KICAgICAgICA8cGF0aCBkPSJNOCwtNS4wMDAwMDAwNmUtMDggQzEwLjI5NzUwNDYsLTUuMDAwMDAwMDZlLTA4IDEyLjE2LDEuODYyNDk1NCAxMi4xNiw0LjE1OTk5OTk4IEMxMi4xNiw2LjQ1NzUwNDU1IDEwLjI5NzUwNDYsOC4zMiA4LDguMzIgQzUuNzAyNDk1NDMsOC4zMiAzLjgzOTk5OTk3LDYuNDU3NTA0NTUgMy44Mzk5OTk5Nyw0LjE1OTk5OTk4IEMzLjgzOTk5OTk3LDEuODYyNDk1NCA1LjcwMjQ5NTQzLC01LjAwMDAwMDA2ZS0wOCA4LC01LjAwMDAwMDA2ZS0wOCBaIE04LDAuOTk5OTk5OTU2IEM2LjI1NDc4MDE4LDAuOTk5OTk5OTU2IDQuODM5OTk5OTgsMi40MTQ3ODAxNiA0LjgzOTk5OTk4LDQuMTU5OTk5OTggQzQuODM5OTk5OTgsNS45MDUyMTk4IDYuMjU0NzgwMTgsNy4zMiA4LDcuMzIgQzkuNzQ1MjE5ODEsNy4zMiAxMS4xNiw1LjkwNTIxOTggMTEuMTYsNC4xNTk5OTk5OCBDMTEuMTYsMi40MTQ3ODAxNiA5Ljc0NTIxOTgxLDAuOTk5OTk5OTU2IDgsMC45OTk5OTk5NTYgWiBNOCw5LjYwMDAwMDAxIEMxMS41OTI1NTI4LDkuNjAwMDAwMDEgMTQuMjIyNzIzOCwxMS4yNDk2MjM1IDE1Ljg5MDUxMywxNC41NDg4NzA2IEMxNS45NjEyMTg2LDE0LjY4ODc0MTUgMTUuOTk4MDU3MywxNC44NDMyNzM2IDE1Ljk5ODA1NzMsMTUgQzE1Ljk5ODA1NzMsMTUuNTEyODIzMyAxNS42MTIwMjY2LDE1LjkzNTQ4NDQgMTUuMTE0NywxNS45OTMyNjc2IEwxNC45OTgwODE3LDE2LjAwMDAwMDEgTDEuMDAxNDIxNjQsMTYuMDAwMDAwMSBDMC44NDQ2NTczODUsMTYuMDAwMDAwMSAwLjY5MDA4ODgzNCwxNS45NjMxNDQ4IDAuNTUwMTkwODEzLDE1Ljg5MjQwODggQzAuMDU3MzE5OTIwNCwxNS42NDMyMDA5IC0wLjE0MDIwNzk1NiwxNS4wNDE2MjcyIDAuMTA5MDExNDIxLDE0LjU0ODc2MjEgQzEuNzc3MTU3MDYsMTEuMjQ5NTg3NCA0LjQwNzQ4NjU5LDkuNjAwMDAwMDEgOCw5LjYwMDAwMDAxIFogTTgsMTAuNiBDNC44OTE3MDY2OSwxMC42IDIuNjQ5NDIyNzgsMTEuOTQzMzQ2MiAxLjE0ODk4NjUzLDE0LjcxNzc5IEwxLjAwMTk5OTk2LDE0Ljk5OSBMMTQuOTk4MDM3NiwxNSBMMTQuODUwNTE4MSwxNC43MTc3OTI5IEMxMy4zNTA0MTI1LDExLjk0MzM0NzUgMTEuMTA4MzAyMywxMC42IDgsMTAuNiBaIiBpZD0icGF0aC0xIj48L3BhdGg+CiAgICA8L2RlZnM+CiAgICA8ZyBpZD0iU3ltYm9scyIgc3Ryb2tlPSJub25lIiBzdHJva2Utd2lkdGg9IjEiIGZpbGw9Im5vbmUiIGZpbGwtcnVsZT0iZXZlbm9kZCI+CiAgICAgICAgPGcgaWQ9IkVudGl0eS9wZXJzb25fcmVndWxhciI+CiAgICAgICAgICAgIDxtYXNrIGlkPSJtYXNrLTIiIGZpbGw9IndoaXRlIj4KICAgICAgICAgICAgICAgIDx1c2UgeGxpbms6aHJlZj0iI3BhdGgtMSI+PC91c2U+CiAgICAgICAgICAgIDwvbWFzaz4KICAgICAgICAgICAgPHVzZSBpZD0icGVyc29uIiBmaWxsPSIjZmZmIiBmaWxsLXJ1bGU9Im5vbnplcm8iIHhsaW5rOmhyZWY9IiNwYXRoLTEiPjwvdXNlPgogICAgICAgICAgICA8ZyBpZD0iY29sb3JzL2FhX2ljb25fZGFyayIgbWFzaz0idXJsKCNtYXNrLTIpIiBmaWxsPSIjZmZmIj4KICAgICAgICAgICAgICAgIDxyZWN0IGlkPSJpY29uX2RhcmsiIHg9IjAiIHk9IjAiIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PC9yZWN0PgogICAgICAgICAgICA8L2c+CiAgICAgICAgPC9nPgogICAgPC9nPgo8L3N2Zz4=` +export const company = `data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPHN2ZyB3aWR0aD0iMTZweCIgaGVpZ2h0PSIxNnB4IiB2aWV3Qm94PSIwIDAgMTYgMTYiIHZlcnNpb249IjEuMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayI+CiAgICA8IS0tIEdlbmVyYXRvcjogc2tldGNodG9vbCA2My4xICgxMDEwMTApIC0gaHR0cHM6Ly9za2V0Y2guY29tIC0tPgogICAgPHRpdGxlPkI0RTRGQ0FGLTdCMzctNERCNy1BRUFGLTdGRDY0RUE0QzQwQTwvdGl0bGU+CiAgICA8ZGVzYz5DcmVhdGVkIHdpdGggc2tldGNodG9vbC48L2Rlc2M+CiAgICA8ZGVmcz4KICAgICAgICA8cGF0aCBkPSJNMC45OTk5OTk5NSwxNi4wMDAwMDAxIEMwLjQ0NzcxNTIwMywxNi4wMDAwMDAxIC01LjAwMDAwMDA2ZS0wOCwxNS41NTIyODQ3IC01LjAwMDAwMDA2ZS0wOCwxNSBMLTUuMDAwMDAwMDZlLTA4LDQuODM5OTk5OTcgQy01LjAwMDAwMDA2ZS0wOCw0LjI4NzcxNTIzIDAuNDQ3NzE1MjAzLDMuODM5OTk5OTcgMC45OTk5OTk5NSwzLjgzOTk5OTk3IEwzLjk5OTk5OTk1LDMuODM5OTk5OTcgTDMuOTk5OTk5OTcsMC45OTk5OTk5NSBDMy45OTk5OTk5NywwLjQ0NzcxNTIwMyA0LjQ0NzcxNTIzLC00Ljk5OTk3NjYxZS0wOCA0Ljk5OTk5OTk3LC00Ljk5OTk3NjYxZS0wOCBMMTEsLTQuOTk5OTc2NjFlLTA4IEMxMS41NTIyODQ3LC00Ljk5OTk3NjYxZS0wOCAxMiwwLjQ0NzcxNTIwMyAxMiwwLjk5OTk5OTk1IEwxMiw0Ljk5OTk5OTk4IEwxMiw0Ljk5OTk5OTk4IEwxNSw0Ljk5OTk5OTk4IEMxNS41NTIyODQ3LDQuOTk5OTk5OTggMTYsNS40NDc3MTUyMyAxNiw1Ljk5OTk5OTk4IEwxNiwxNSBDMTYsMTUuNTUyMjg0NyAxNS41NTIyODQ3LDE2IDE1LDE2IEwwLjk5OTk5OTk1LDE2LjAwMDAwMDEgWiBNMTEsMC45OTk5OTk5NTYgTDQuOTk5OTk5OTgsMC45OTk5OTk5NTYgTDQuOTk5OTk5OTgsMTUgTDUuOTk5OTk5OTksMTUgTDUuOTk5OTk5OTksMTUgTDUuOTk5OTk5OTksMTEuMjQgQzUuOTk5OTk5OTksMTAuNjg3NzE1MyA2LjQ0NzcxNTI0LDEwLjI0IDYuOTk5OTk5OTksMTAuMjQgTDguOTk5OTk5OTksMTAuMjQgQzkuNTUyMjg0NzYsMTAuMjQgOS45OTk5OTk5OSwxMC42ODc3MTUzIDkuOTk5OTk5OTksMTEuMjQgTDkuOTk5OTk5OTksMTUgTDExLDE1IEwxMSwwLjk5OTk5OTk1NiBaIE04Ljk5OTk5OTk5LDExLjI0IEw2Ljk5OTk5OTk5LDExLjI0IEw2Ljk5OTk5OTk5LDE1IEw4Ljk5OTk5OTk5LDE1IEw4Ljk5OTk5OTk5LDExLjI0IFogTTMuOTk5OTk5OTYsNC44Mzk5OTk5OCBMMC45OTk5OTk5NTYsNC44Mzk5OTk5OCBMMC45OTk5OTk5NTYsMTUgTDMuOTk5OTk5OTYsMTQuOTk5IEwzLjk5OTk5OTk2LDQuODM5OTk5OTggWiBNMTUsNS45OTk5OTk5OSBMMTIsNS45OTk5OTk5OSBMMTIsMTUgTDE1LDE1IEwxNSw1Ljk5OTk5OTk5IFoiIGlkPSJwYXRoLTEiPjwvcGF0aD4KICAgIDwvZGVmcz4KICAgIDxnIGlkPSJTeW1ib2xzIiBzdHJva2U9Im5vbmUiIHN0cm9rZS13aWR0aD0iMSIgZmlsbD0ibm9uZSIgZmlsbC1ydWxlPSJldmVub2RkIj4KICAgICAgICA8ZyBpZD0iRW50aXR5L2NvbXBhbnlfcmVndWxhciI+CiAgICAgICAgICAgIDxtYXNrIGlkPSJtYXNrLTIiIGZpbGw9Im5vbmUiPgogICAgICAgICAgICAgICAgPHVzZSB4bGluazpocmVmPSIjcGF0aC0xIj48L3VzZT4KICAgICAgICAgICAgPC9tYXNrPgogICAgICAgICAgICA8dXNlIGlkPSJDb21iaW5lZC1TaGFwZSIgZmlsbD0iI2ZmZiIgZmlsbC1ydWxlPSJub256ZXJvIiB4bGluazpocmVmPSIjcGF0aC0xIj48L3VzZT4KICAgICAgICAgICAgPGcgaWQ9ImNvbG9ycy9hYV9pY29uX2RhcmsiIG1hc2s9InVybCgjbWFzay0yKSIgZmlsbD0iI2ZmZiI+CiAgICAgICAgICAgICAgICA8cmVjdCBpZD0iaWNvbl9kYXJrIiB4PSIwIiB5PSIwIiB3aWR0aD0iMTYiIGhlaWdodD0iMTYiPjwvcmVjdD4KICAgICAgICAgICAgPC9nPgogICAgICAgIDwvZz4KICAgIDwvZz4KPC9zdmc+` diff --git a/examples/assets/person.png b/examples/assets/person.png new file mode 100644 index 00000000..60461d14 Binary files /dev/null and b/examples/assets/person.png differ diff --git a/examples/native/src/index.css b/examples/native/src/index.css index 66988d80..70128f88 100644 --- a/examples/native/src/index.css +++ b/examples/native/src/index.css @@ -1,4 +1,13 @@ @import '../../reset'; +@import url("https://fonts.googleapis.com/css?family=Roboto:400,500,700&display=swap"); + +@font-face { + font-family: Roboto; + font-weight: 400; + font-style: normal; + font-stretch: normal; + font-display: swap; +} #examples { display: flex; diff --git a/examples/native/src/labels/index.ts b/examples/native/src/labels/index.ts index feca6d38..5147f0ce 100644 --- a/examples/native/src/labels/index.ts +++ b/examples/native/src/labels/index.ts @@ -1,18 +1,26 @@ import * as Renderer from '@trellis/renderers/webgl' import * as Graph from '@trellis/index' import * as Collide from '@trellis/layout/collide' +import { person } from '../../../assets/icons' const GREEN = '#91AD49' const GREEN_LIGHT = '#C6D336' const DARK_GREEN = '#607330' +const IMAGE_ICON: Graph.ImageIcon = { + type: 'imageIcon', + url: person, + scale: 0.66 +} + const TEXT_ICON: Graph.TextIcon = { type: 'textIcon', - family: 'sans-serif', - color: '#fff', - weight: '400', - text: '!', - size: 14 + content: '!', + style: { + fontSize: 14, + fontWeight: '400', + color: '#fff' + } } const NODE_STYLE: Graph.NodeStyle = { @@ -20,23 +28,23 @@ const NODE_STYLE: Graph.NodeStyle = { icon: TEXT_ICON, stroke: [{ width: 2, color: GREEN_LIGHT }], label: { - position: 'right', + position: 'bottom', fontName: 'NodeLabel', - fontFamily: 'Arial, sans-serif', - background: { color: GREEN_LIGHT }, + fontFamily: 'Roboto', + highlight: { color: GREEN_LIGHT }, margin: 4 } } const NODE_HOVER_STYLE: Graph.NodeStyle = { color: DARK_GREEN, - icon: TEXT_ICON, + icon: IMAGE_ICON, stroke: [{ width: 2, color: GREEN_LIGHT }], label: { - position: 'right', + position: 'bottom', fontName: 'NodeLabelHover', - fontFamily: 'Arial, sans-serif', - background: { color: DARK_GREEN }, + fontFamily: 'Roboto', + highlight: { color: DARK_GREEN }, color: '#FFF', margin: 4 } @@ -59,11 +67,29 @@ const data = [ const collide = Collide.Layout() -const edges: Graph.Edge[] = [] +const edges: Graph.Edge[] = [ + { + id: '0::1', + source: '0', + target: '1', + label: '0 <- EDGE LABEL -> 1', + style: { + arrow: 'both', + label: { + fontName: 'EdgeLabel', + fontFamily: 'Roboto', + fontSize: 10, + color: DARK_GREEN, + margin: 4 + } + } + } +] + let nodes = data.map((label, index) => ({ radius: 10, - label: index % 2 === 0 ? label + ' 北京' : label, - id: `${index}-${label}`, + label: `${label}${index % 2 === 0 ? ' 北京' : ''}`, + id: `${index}`, style: NODE_STYLE })) diff --git a/examples/native/src/perf/index.ts b/examples/native/src/perf/index.ts index 4d85199c..1555127f 100644 --- a/examples/native/src/perf/index.ts +++ b/examples/native/src/perf/index.ts @@ -22,20 +22,25 @@ const sampleCoordinatePlane = function* (count: number, step: number, sample: nu const PURPLE = '#7A5DC5' const LIGHT_PURPLE = '#CAD' const ARIAL_PINK = 'ArialPink' +const TEXT_ICON: Graph.TextIcon = { + type: 'textIcon', + content: 'T', + style: { + fontSize: 14, + color: '#fff', + fontWeight: '400' + } +} const NODE_STYLE: Graph.NodeStyle = { color: PURPLE, stroke: [{ width: 2, color: LIGHT_PURPLE }], - icon: { type: 'textIcon', text: 'T', family: 'sans-serif', size: 14, color: '#fff', weight: '400' }, + icon: TEXT_ICON, label: { - position: 'top', + position: 'bottom', fontName: ARIAL_PINK, - fontFamily: ['Arial', 'sans-serif'], - margin: 2, - background: { - color: '#f66', - opacity: 0.5 - } + fontFamily: 'Roboto', + margin: 2 } } @@ -43,7 +48,7 @@ const NODE_HOVER_STYLE: Graph.NodeStyle = { color: '#f66', stroke: [{ width: 2, color: '#fcc' }], label: { position: 'bottom', color: '#fcc' }, - icon: { type: 'textIcon', text: 'L', family: 'sans-serif', size: 14, color: '#fff', weight: '400' } + icon: TEXT_ICON } const EDGE_STYLE: Graph.EdgeStyle = { diff --git a/package-lock.json b/package-lock.json index 44196f8b..145a37cd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "d3-force": "^3.0.0", "d3-hierarchy": "^3.1.2", "d3-interpolate": "^3.0.1", + "fontfaceobserver": "^2.3.0", "pixi.js": "^7.3.2", "stats.js": "^0.17.0" }, @@ -31,6 +32,7 @@ "@types/d3-interpolate": "^3.0.3", "@types/d3-scale": "^4.0.6", "@types/d3-scale-chromatic": "^3.0.1", + "@types/fontfaceobserver": "^2.1.3", "@types/react": "^18.2.33", "@types/react-dom": "^18.2.14", "@types/stats.js": "^0.17.2", @@ -3693,6 +3695,12 @@ "resolved": "https://registry.npmjs.org/@types/earcut/-/earcut-2.1.2.tgz", "integrity": "sha512-EU6fwVNP1TGVTkCILfURtzzwJq/ie5LgipELnzCINgm4VdDIkkbB8wnLSe81J77Bbqf4MiO3sJGhWzc6MCp5dQ==" }, + "node_modules/@types/fontfaceobserver": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@types/fontfaceobserver/-/fontfaceobserver-2.1.3.tgz", + "integrity": "sha512-AewfFg9iUfoUZ4EfKxhBaEuzY2TUS+Hm0vXWMPcJRY7C4wC9XtW20lPVYHTcWVZYq1uthCEa5APl7RAX7jr2Xg==", + "dev": true + }, "node_modules/@types/json-schema": { "version": "7.0.14", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.14.tgz", @@ -5389,6 +5397,11 @@ "integrity": "sha512-36yxDn5H7OFZQla0/jFJmbIKTdZAQHngCedGxiMmpNfEZM0sdEeT+WczLQrjK6D7o2aiyLYDnkw0R3JK0Qv1RQ==", "dev": true }, + "node_modules/fontfaceobserver": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/fontfaceobserver/-/fontfaceobserver-2.3.0.tgz", + "integrity": "sha512-6FPvD/IVyT4ZlNe7Wcn5Fb/4ChigpucKYSvD6a+0iMoLn2inpo711eyIcKjmDtE5XNcgAkSH9uN/nfAeZzHEfg==" + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", diff --git a/package.json b/package.json index 35682c9a..c714d059 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "d3-force": "^3.0.0", "d3-hierarchy": "^3.1.2", "d3-interpolate": "^3.0.1", + "fontfaceobserver": "^2.3.0", "pixi.js": "^7.3.2", "stats.js": "^0.17.0" }, @@ -54,6 +55,7 @@ "@types/d3-interpolate": "^3.0.3", "@types/d3-scale": "^4.0.6", "@types/d3-scale-chromatic": "^3.0.1", + "@types/fontfaceobserver": "^2.1.3", "@types/react": "^18.2.33", "@types/react-dom": "^18.2.14", "@types/stats.js": "^0.17.2", @@ -75,7 +77,14 @@ "vite": "^4.5.0", "vitest": "^0.34.6" }, - "keywords": ["graph", "network", "infovis", "visualization", "react", "webgl"], + "keywords": [ + "graph", + "network", + "infovis", + "visualization", + "react", + "webgl" + ], "peerDependencies": { "react": ">=16.0" }, @@ -83,5 +92,8 @@ "type": "git", "url": "https://github.com/sayari-analytics/trellis.git" }, - "workspaces": ["examples/native", "examples/react"] + "workspaces": [ + "examples/native", + "examples/react" + ] } diff --git a/src/bindings/react/renderer.ts b/src/bindings/react/renderer.ts index c927e5f8..4236c9d7 100644 --- a/src/bindings/react/renderer.ts +++ b/src/bindings/react/renderer.ts @@ -1,6 +1,6 @@ import { createElement, useRef, useEffect } from 'react' import { Renderer as WebGLRenderer, Options } from '../../renderers/webgl' -import { Node, Edge, Annotation } from '../..' +import { Node, Edge, Annotation } from '../../types/api' export type Props = Options & { nodes: N[] diff --git a/src/bindings/react/selection.ts b/src/bindings/react/selection.ts index 705d28eb..e89ee696 100644 --- a/src/bindings/react/selection.ts +++ b/src/bindings/react/selection.ts @@ -1,6 +1,6 @@ import { ReactNode, useCallback, useEffect, useRef, useState } from 'react' import { ViewportDragDecelerateEvent, ViewportDragEvent } from '../../renderers/webgl' -import { Annotation, Node } from '../..' +import { Annotation, Node } from '../../types/api' export type SelectionChangeEvent = { type: 'selectionChange' @@ -209,11 +209,13 @@ export const Selection = (props: Props) => { y: state.annotation.y, radius: state.annotation.radius, style: { - backgroundColor: props.color ?? '#eee', - stroke: { - color: props.strokeColor ?? '#ccc', - width: props.strokeWidth ?? 2 - } + color: props.color ?? '#eee', + stroke: [ + { + color: props.strokeColor ?? '#ccc', + width: props.strokeWidth ?? 2 + } + ] } } : state.annotation?.type === 'rectangle' @@ -225,11 +227,13 @@ export const Selection = (props: Props) => { width: state.annotation.width, height: state.annotation.height, style: { - backgroundColor: props.color ?? '#eee', - stroke: { - color: props.strokeColor ?? '#ccc', - width: props.strokeWidth ?? 2 - } + color: props.color ?? '#eee', + stroke: [ + { + color: props.strokeColor ?? '#ccc', + width: props.strokeWidth ?? 2 + } + ] } } : undefined diff --git a/src/index.ts b/src/index.ts index 8d07f884..21e6b2bc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,384 +1,2 @@ -import { TWO_PI } from './renderers/webgl/utils' -import type { LabelStyle } from './renderers/webgl/objects/label' -import type { Stroke } from './types' - -export type Node = { - id: string - radius: number - x?: number - y?: number - fx?: number - fy?: number - label?: string - style?: NodeStyle - subgraph?: { - nodes: Node[] - edges: Edge[] - options?: {} - } -} - -export type Edge = { - id: string - source: string - target: string - label?: string - style?: EdgeStyle -} - -export type TextIcon = { - type: 'textIcon' - family: string - text: string - color: string - size: number - weight?: '100' | '200' | '300' | '400' | '500' | '600' | '700' | '800' | '900' - offsetX?: number - offsetY?: number -} - -export type ImageIcon = { - type: 'imageIcon' - url: string - scale?: number - offsetX?: number - offsetY?: number -} - -export type NodeStyle = { - color?: string - icon?: TextIcon | ImageIcon - stroke?: Stroke[] - badge?: { - position: number - radius: number - color: string - stroke?: string - strokeWidth?: number - icon?: TextIcon | ImageIcon - }[] - label?: LabelStyle -} - -export type EdgeStyle = { - width?: number - stroke?: string - strokeOpacity?: number - arrow?: 'forward' | 'reverse' | 'both' | 'none' - label?: LabelStyle -} - -export type CircleAnnotation = { - type: 'circle' - id: string - x: number - y: number - radius: number - style: { - backgroundColor: string - stroke: { - color: string - width: number - } - } -} - -export type RectangleAnnotation = { - type: 'rectangle' - id: string - x: number - y: number - width: number - height: number - resize?: boolean - style: { - backgroundColor: string - stroke: { - color: string - width: number - } - } -} - -export type TextAnnotation = { - type: 'text' - id: string - x: number - y: number - width: number - height: number - content: string - resize?: boolean - style: Partial<{ - backgroundColor: string - padding: number - stroke: { - color: string - width: number - } - text: Partial<{ - fontName: string - fontSize: number - fontWeight: 'normal' | 'bold' | 'bolder' | 'lighter' | '100' | '200' | '300' | '400' | '500' | '600' | '700' | '800' | '900' - fontStyle: 'normal' | 'italic' | 'oblique' - weight: string - color: string - align: 'left' | 'center' | 'right' | 'justify' - letterSpacing: number - lineSpacing: number - maxWidth: number - }> - }> -} - -export type Annotation = CircleAnnotation | RectangleAnnotation | TextAnnotation - -export type Bounds = { - left: number - top: number - right: number - bottom: number -} - -export type Dimensions = { width: number; height: number } - -export type Viewport = { x: number; y: number; zoom: number } - -export const getSelectionBounds = (elements: (Node | Annotation)[], padding: number = 0): Bounds => { - let left = 0 - let top = 0 - let right = 0 - let bottom = 0 - - for (const el of elements) { - if ('radius' in el) { - const elementLeft = (el.x ?? 0) - el.radius - const elementTop = (el.y ?? 0) - el.radius - const elementRight = (el.x ?? 0) + el.radius - const elementBottom = (el.y ?? 0) + el.radius - if (elementLeft < left) left = elementLeft - if (elementTop < top) top = elementTop - if (elementRight > right) right = elementRight - if (elementBottom > bottom) bottom = elementBottom - } else if ('width' in el && 'height' in el) { - const elementLeft = el.x ?? 0 - const elementTop = el.y ?? 0 - const elementRight = (el.x ?? 0) + el.width - const elementBottom = (el.x ?? 0) + el.height - if (elementLeft < left) left = elementLeft - if (elementTop < top) top = elementTop - if (elementRight > right) right = elementRight - if (elementBottom > bottom) bottom = elementBottom - } - } - - return { - left: left - padding, - top: top - padding, - right: right + padding, - bottom: bottom + padding - } -} - -export const mergeBounds = (a: Bounds, b: Bounds, padding: number = 0): Bounds => { - return { - left: Math.min(a.left, b.left) - padding, - top: Math.min(a.top, b.top) - padding, - right: Math.max(a.right, b.right) + padding, - bottom: Math.max(a.bottom, b.bottom) + padding - } -} - -export const viewportToBounds = ({ x, y, zoom }: Viewport, { width, height }: Dimensions): Bounds => { - const xOffset = width / 2 / zoom - const yOffset = height / 2 / zoom - return { - left: -(x + xOffset), - top: -(y + yOffset), - right: -(x - xOffset), - bottom: -(y - yOffset) - } -} - -export const boundsToViewport = ({ left, top, right, bottom }: Bounds, { width, height }: Dimensions): Viewport => { - const targetWidth = right - left - const targetHeight = bottom - top - const x = targetWidth / 2 - right - const y = targetHeight / 2 - bottom - - if (targetWidth / targetHeight > width / height) { - // fit to width - return { x, y, zoom: width / targetWidth } - } else { - // fit to height - return { x, y, zoom: height / targetHeight } - } -} - -export const boundsToDimensions = ({ left, top, right, bottom }: Bounds, zoom: number): Dimensions => { - return { - width: (right - left) / zoom, - height: (bottom - top) / zoom - } -} - -export const clamp = (min: number, max: number, value: number) => Math.max(min, Math.min(max, value)) - -export const equals = (a: T, b: T) => { - if (a === b) { - return true - } else if (Array.isArray(a) && Array.isArray(b)) { - if (a.length !== b.length) { - return false - } - - for (let i = 0; i < a.length; i++) { - if (!equals(a[i], b[i])) { - return false - } - } - - return true - } else if (typeof a === 'object' && typeof b === 'object') { - if (Object.keys(a ?? {}).length !== Object.keys(b ?? {}).length) { - return false - } - - for (const key in a) { - if (!equals(a[key], b?.[key])) { - return false - } - } - - return true - } - - return false -} - -export const connectedComponents = (graph: { nodes: N[]; edges: E[] }): { nodes: N[]; edges: E[] }[] => { - const adjacencyList: Record> = Object.create(null) - const nodes: Record = {} - const visited = new Set() - const components: { nodes: Record; edges: Record }[] = [] - - for (const edge of graph.edges) { - if (adjacencyList[edge.source] === undefined) { - adjacencyList[edge.source] = {} - } - if (adjacencyList[edge.source][edge.target] === undefined) { - adjacencyList[edge.source][edge.target] = [] - } - if (adjacencyList[edge.target] === undefined) { - adjacencyList[edge.target] = {} - } - if (adjacencyList[edge.target][edge.source] === undefined) { - adjacencyList[edge.target][edge.source] = [] - } - - adjacencyList[edge.source][edge.target].push(edge) - adjacencyList[edge.target][edge.source].push(edge) - } - - for (const node of graph.nodes) { - nodes[node.id] = node - } - - for (const { id } of graph.nodes) { - if (visited.has(id)) { - continue - } - - visited.add(id) - const toVisit = [id] - const component: { nodes: Record; edges: Record } = { - nodes: { [id]: nodes[id] }, - edges: {} - } - - while (toVisit.length > 0) { - const next = adjacencyList[toVisit.pop()!] - if (next === undefined) { - continue - } - - for (const [adjacentNode, edges] of Object.entries(next)) { - for (const edge of edges) { - component.edges[edge.id] = edge - } - component.nodes[adjacentNode] = nodes[adjacentNode] - - if (!visited.has(adjacentNode)) { - toVisit.push(adjacentNode) - visited.add(adjacentNode) - } - } - } - - components.push(component) - } - - return components.map(({ nodes, edges }) => ({ - nodes: Object.values(nodes), - edges: Object.values(edges) - })) -} - -export function* bfs( - predicate: (node: N) => boolean, - graph: { nodes: N[]; edges: E[] } -): Generator { - const adjacencyList: Record = Object.create(null) - const nodes: Record = {} - const visited = new Set() - const queue = [graph.nodes[0].id] - - for (const edge of graph.edges) { - if (adjacencyList[edge.source] === undefined) { - adjacencyList[edge.source] = [] - } - if (adjacencyList[edge.target] === undefined) { - adjacencyList[edge.target] = [] - } - - adjacencyList[edge.source].push(edge.target) - adjacencyList[edge.target].push(edge.source) - } - - for (const node of graph.nodes) { - nodes[node.id] = node - } - - while (queue.length > 0) { - const node = queue.shift()! - - if (visited.has(node)) { - continue - } - - visited.add(node) - - if (predicate(nodes[node])) { - yield nodes[node] - } - - if (adjacencyList[node]) { - for (const adjacentNode of adjacencyList[node]) { - if (!visited.has(adjacentNode)) { - queue.push(adjacentNode) - } - } - } - } -} - -export const distance = (x0: number, y0: number, x1: number, y1: number) => Math.hypot(x1 - x0, y1 - y0) - -export const angle = (x0: number, y0: number, x1: number, y1: number) => { - const angle = Math.atan2(y0 - y1, x0 - x1) - return angle < 0 ? angle + TWO_PI : angle -} - -// exports -export type { Stroke } from './types' -export type { LabelStyle, LabelBackgroundStyle, LabelPosition, FontWeight, TextAlign } from './renderers/webgl/objects/label' +export * from './types/api' +export * from './utils/api' diff --git a/src/layout/cluster/index.ts b/src/layout/cluster/index.ts index 665f9408..253971df 100644 --- a/src/layout/cluster/index.ts +++ b/src/layout/cluster/index.ts @@ -1,5 +1,5 @@ import { pack, hierarchy } from 'd3-hierarchy' -import { Node } from '../..' +import { Node } from '../../types' export const Layout = () => { return (nodes: N[]) => { diff --git a/src/layout/collide/index.ts b/src/layout/collide/index.ts index d9c1da66..ef834cdc 100644 --- a/src/layout/collide/index.ts +++ b/src/layout/collide/index.ts @@ -1,5 +1,5 @@ import { forceCollide, forceSimulation, SimulationNodeDatum } from 'd3-force' -import { Node, Edge } from '../..' +import { Node, Edge } from '../../types' export type Options = Partial<{ nodePadding: number diff --git a/src/layout/components/index.ts b/src/layout/components/index.ts index 869f4d65..0ab6a91f 100644 --- a/src/layout/components/index.ts +++ b/src/layout/components/index.ts @@ -1,5 +1,6 @@ import { packEnclose, packSiblings } from 'd3-hierarchy' -import { Node, Edge, connectedComponents } from '../..' +import { connectedComponents } from '../../utils/api' +import { Node, Edge } from '../../types' export type Options = Partial<{ padding: number diff --git a/src/layout/fisheye/index.ts b/src/layout/fisheye/index.ts index d320748b..b210c29f 100644 --- a/src/layout/fisheye/index.ts +++ b/src/layout/fisheye/index.ts @@ -1,4 +1,4 @@ -import { Node } from '../..' +import { Node } from '../../types' export const Layout = () => { return (previousNodes: N[], nextNodes: N[]) => { diff --git a/src/layout/force/index.ts b/src/layout/force/index.ts index be6722b7..c6aac8a6 100644 --- a/src/layout/force/index.ts +++ b/src/layout/force/index.ts @@ -11,8 +11,7 @@ import { SimulationLinkDatum, SimulationNodeDatum } from 'd3-force' -import { Extend } from '../../types' -import { Node, Edge } from '../..' +import { Node, Edge, Extend } from '../../types' export type Options = Partial<{ nodeStrength: number diff --git a/src/layout/hierarchy/index.ts b/src/layout/hierarchy/index.ts index 7e4a6b79..18cbe5a6 100644 --- a/src/layout/hierarchy/index.ts +++ b/src/layout/hierarchy/index.ts @@ -1,4 +1,4 @@ -import type { Node, Edge } from '../..' +import type { Node, Edge } from '../../types' import { hierarchyToGraph, createGraphIndex, graphToHierarchy, HierarchyData } from './utils' import { HierarchyNode } from 'd3-hierarchy' import tree from './tree' diff --git a/src/layout/hierarchy/utils.ts b/src/layout/hierarchy/utils.ts index fe4486e8..d48b0dd2 100644 --- a/src/layout/hierarchy/utils.ts +++ b/src/layout/hierarchy/utils.ts @@ -1,5 +1,5 @@ import { hierarchy, HierarchyPointNode } from 'd3-hierarchy' -import type { Node, Edge } from '../..' +import type { Node, Edge } from '../../types' // types export type TreePath = { edge: E; node: N } diff --git a/src/layout/radial/index.ts b/src/layout/radial/index.ts index 7d564afc..21db6bb9 100644 --- a/src/layout/radial/index.ts +++ b/src/layout/radial/index.ts @@ -1,5 +1,5 @@ import * as Hierarchy from '../hierarchy' -import { Node, Edge } from '../..' +import { Node, Edge } from '../../types' export type Options = Partial<{ x: number diff --git a/src/renderers/image/index.ts b/src/renderers/image/index.ts index 9e9d4d96..a3f783cc 100644 --- a/src/renderers/image/index.ts +++ b/src/renderers/image/index.ts @@ -1,5 +1,5 @@ import * as WebGL from '../webgl' -import { Node, Edge, Annotation } from '../../' +import { Node, Edge, Annotation } from '../../types' export type Options = { width: number diff --git a/src/renderers/webgl/LifecycleManager.ts b/src/renderers/webgl/LifecycleManager.ts new file mode 100644 index 00000000..fbdc4735 --- /dev/null +++ b/src/renderers/webgl/LifecycleManager.ts @@ -0,0 +1,26 @@ +import { NodeStrokes } from './objects/nodeStrokes' +import { LineSegment } from './objects/lineSegment' +import { NodeFill } from './objects/nodeFill' +import { Arrow } from './objects/arrow' +import ObjectManager from './objects/ObjectManager' +import Icon from './objects/Icon' +import Text from './objects/text/Text' + +export default class LifecycleManager { + nodes = new ObjectManager(2000) + icons = new ObjectManager(1000) + edges = new ObjectManager(2000) + arrows = new ObjectManager(1000) + labels = new ObjectManager(2000) + interactions = new ObjectManager(2000) + // interactions = new ObjectManager(2000) // TODO + + render() { + this.nodes.render() + this.icons.render() + this.edges.render() + this.arrows.render() + this.labels.render() + this.interactions.render() + } +} diff --git a/src/renderers/webgl/edge.ts b/src/renderers/webgl/edge.ts index 956156bb..3a46e679 100644 --- a/src/renderers/webgl/edge.ts +++ b/src/renderers/webgl/edge.ts @@ -1,28 +1,33 @@ -import { MIN_EDGES_ZOOM, MIN_INTERACTION_ZOOM, Renderer } from '.' +import { DEFAULT_LABEL_STYLE, MIN_EDGES_ZOOM, MIN_INTERACTION_ZOOM, MIN_LABEL_ZOOM } from '../../utils/constants' +import { type Renderer } from '.' +import { midPoint } from './utils' import { movePoint } from './utils' import { NodeRenderer } from './node' -import * as Graph from '../..' +import type { ArrowStyle, Edge } from '../../types' import { Arrow } from './objects/arrow' import { LineSegment } from './objects/lineSegment' import { FederatedPointerEvent } from 'pixi.js' import { EdgeHitArea } from './interaction/edgeHitArea' +import { angle } from '../../utils/api' +import Text from './objects/text/Text' const DEFAULT_EDGE_WIDTH = 1 const DEFAULT_EDGE_COLOR = 0xaaaaaa const DEFAULT_ARROW = 'none' export class EdgeRenderer { - edge?: Graph.Edge - - renderer: Renderer - lineSegment: LineSegment + edge!: Edge source!: NodeRenderer target!: NodeRenderer - x0?: number - y0?: number - x1?: number - y1?: number - theta?: number + label?: Text + renderer: Renderer + lineSegment: LineSegment + x0 = 0 + y0 = 0 + x1 = 0 + y1 = 0 + theta = 0 + center: [x: number, y: number] = [0, 0] width?: number stroke?: string | number strokeOpacity?: number @@ -30,48 +35,53 @@ export class EdgeRenderer { targetRadius?: number private hitArea: EdgeHitArea - private arrow?: { forward: Arrow; reverse?: undefined } | { forward?: undefined; reverse: Arrow } | { forward: Arrow; reverse: Arrow } - private lineMounted = false - private forwardArrowMounted = false - private reverseArrowMounted = false + private forwardArrow?: Arrow + private reverseArrow?: Arrow private doubleClickTimeout: NodeJS.Timeout | undefined private doubleClick = false - constructor(renderer: Renderer, edge: Graph.Edge, source: NodeRenderer, target: NodeRenderer) { + constructor(renderer: Renderer, edge: Edge, source: NodeRenderer, target: NodeRenderer) { this.renderer = renderer this.lineSegment = new LineSegment(this.renderer.edgesContainer) this.hitArea = new EdgeHitArea(this.renderer.interactionContainer, this) this.update(edge, source, target) } - update(edge: Graph.Edge, source: NodeRenderer, target: NodeRenderer) { + update(edge: Edge, source: NodeRenderer, target: NodeRenderer) { + this.edge = edge this.source = source this.target = target const arrow = edge.style?.arrow ?? DEFAULT_ARROW - if (arrow !== (this.edge?.style?.arrow ?? DEFAULT_ARROW)) { - this.arrow?.forward?.delete() - this.arrow?.reverse?.delete() - this.forwardArrowMounted = false - this.reverseArrowMounted = false - this.arrow = undefined + if (arrow !== this.arrow) { switch (arrow) { case 'forward': - this.arrow = { forward: new Arrow(this.renderer.edgesContainer, this.renderer.arrow) } + this.applyArrow({ forward: true, reverse: false }) break + case 'reverse': - this.arrow = { reverse: new Arrow(this.renderer.edgesContainer, this.renderer.arrow) } + this.applyArrow({ forward: false, reverse: true }) break + case 'both': - this.arrow = { - forward: new Arrow(this.renderer.edgesContainer, this.renderer.arrow), - reverse: new Arrow(this.renderer.edgesContainer, this.renderer.arrow) - } + this.applyArrow({ forward: true, reverse: true }) + break + + case 'none': + this.applyArrow({ forward: false, reverse: false }) + break } } - this.edge = edge + if (this.label) { + if (edge.label === undefined || edge.label.trim() === '') { + this.managers.labels.delete(this.label) + this.label = undefined + } else { + this.label.update(edge.label, edge.style?.label) + } + } return this } @@ -83,51 +93,8 @@ export class EdgeRenderer { const y1 = this.target.y const sourceRadius = this.source.strokes.radius const targetRadius = this.target.strokes.radius - const isVisible = this.visible(Math.min(x0, x1), Math.min(y0, y1), Math.max(x0, x1), Math.max(y0, y1)) - - // TODO - disable events if edge has no event handlers - // TODO - disable events when dragging/scrolling - if (isVisible && this.renderer.zoom > MIN_INTERACTION_ZOOM) { - this.renderer.interactionObjectManager.mount(this.hitArea) - } else { - this.renderer.interactionObjectManager.unmount(this.hitArea) - } - if (isVisible) { - if (!this.lineMounted) { - this.renderer.edgeObjectManager.mount(this.lineSegment) - this.lineMounted = true - } - } else { - if (this.lineMounted) { - this.renderer.edgeObjectManager.unmount(this.lineSegment) - this.lineMounted = false - } - } - - if (isVisible) { - if (!this.forwardArrowMounted && this.arrow?.forward) { - this.renderer.edgeArrowObjectManager.mount(this.arrow?.forward) - this.forwardArrowMounted = true - } - } else { - if (this.forwardArrowMounted && this.arrow?.forward) { - this.renderer.edgeArrowObjectManager.unmount(this.arrow?.forward) - this.forwardArrowMounted = false - } - } - - if (isVisible) { - if (!this.reverseArrowMounted && this.arrow?.reverse) { - this.renderer.edgeArrowObjectManager.mount(this.arrow?.reverse) - this.reverseArrowMounted = true - } - } else { - if (this.reverseArrowMounted && this.arrow?.reverse) { - this.renderer.edgeArrowObjectManager.unmount(this.arrow?.reverse) - this.reverseArrowMounted = false - } - } + const isVisible = this.visible(x0, y0, x1, y1) if (isVisible) { const width = this.edge?.style?.width ?? DEFAULT_EDGE_WIDTH @@ -154,52 +121,111 @@ export class EdgeRenderer { this.y0 = y0 this.x1 = x1 this.y1 = y1 - this.theta = Graph.angle(this.x0, this.y0, this.x1, this.y1) + this.theta = angle(this.x0, this.y0, this.x1, this.y1) let edgeX0 = this.x0 let edgeY0 = this.y0 let edgeX1 = this.x1 let edgeY1 = this.y1 - if (this.arrow?.forward) { - const edgePoint = movePoint(x1, y1, this.theta, this.targetRadius + this.arrow.forward.height) + if (this.forwardArrow) { + const edgePoint = movePoint(x1, y1, this.theta, this.targetRadius + this.forwardArrow.height) edgeX1 = edgePoint[0] edgeY1 = edgePoint[1] const [arrowX1, arrowY1] = movePoint(x1, y1, this.theta, this.targetRadius) - this.arrow.forward.update(arrowX1, arrowY1, this.theta, this.stroke, this.strokeOpacity) + this.forwardArrow.update(arrowX1, arrowY1, this.theta, this.stroke, this.strokeOpacity) } else { const edgePoint = movePoint(x1, y1, this.theta, this.targetRadius) edgeX1 = edgePoint[0] edgeY1 = edgePoint[1] } - if (this.arrow?.reverse) { - const edgePoint = movePoint(x0, y0, this.theta, -this.sourceRadius - this.arrow.reverse.height) + if (this.reverseArrow) { + const edgePoint = movePoint(x0, y0, this.theta, -this.sourceRadius - this.reverseArrow.height) edgeX0 = edgePoint[0] edgeY0 = edgePoint[1] const [arrowX0, arrowY0] = movePoint(x0, y0, this.theta, -this.sourceRadius) - this.arrow.reverse.update(arrowX0, arrowY0, this.theta + Math.PI, this.stroke, this.strokeOpacity) + this.reverseArrow.update(arrowX0, arrowY0, this.theta + Math.PI, this.stroke, this.strokeOpacity) } else { const edgePoint = movePoint(x0, y0, this.theta, -this.sourceRadius) edgeX0 = edgePoint[0] edgeY0 = edgePoint[1] } - this.lineSegment.update(edgeX0, edgeY0, edgeX1, edgeY1, this.width, this.stroke, this.strokeOpacity) + this.center = midPoint(edgeX0, edgeY0, edgeX1, edgeY1) + + if (this.label) { + this.label.rotation = this.theta + this.label.moveTo(...this.center) + } + // TODO - draw hitArea over arrow + this.lineSegment.update(edgeX0, edgeY0, edgeX1, edgeY1, this.width, this.stroke, this.strokeOpacity) this.hitArea.update(edgeX0, edgeY0, edgeX1, edgeY1, this.width, this.theta) } } + + // TODO - disable events if edge has no event handlers + // TODO - disable events when dragging/scrolling + const shouldHitAreaMount = isVisible && this.renderer.zoom > MIN_INTERACTION_ZOOM + const hitAreaMounted = this.managers.interactions.isMounted(this.hitArea) + if (shouldHitAreaMount && !hitAreaMounted) { + this.managers.interactions.mount(this.hitArea) + } else if (!shouldHitAreaMount && hitAreaMounted) { + this.managers.interactions.unmount(this.hitArea) + } + + const lineMounted = this.managers.edges.isMounted(this.lineSegment) + if (isVisible && !lineMounted) { + this.managers.edges.mount(this.lineSegment) + } else if (!isVisible && lineMounted) { + this.managers.edges.unmount(this.lineSegment) + } + + if (this.forwardArrow) { + const forwardArrowMounted = this.managers.arrows.isMounted(this.forwardArrow) + if (isVisible && !forwardArrowMounted) { + this.managers.arrows.mount(this.forwardArrow) + } else if (!isVisible && forwardArrowMounted) { + this.managers.arrows.unmount(this.forwardArrow) + } + } + + if (this.reverseArrow) { + const reverseArrowMounted = this.managers.arrows.isMounted(this.reverseArrow) + if (isVisible && !reverseArrowMounted) { + this.managers.arrows.mount(this.reverseArrow) + } else if (!isVisible && reverseArrowMounted) { + this.managers.arrows.unmount(this.reverseArrow) + } + } + + const shouldLabelMount = isVisible && this.renderer.zoom > MIN_LABEL_ZOOM + + if (shouldLabelMount) { + this.applyLabel() + } + + if (this.label) { + const labelMounted = this.managers.labels.isMounted(this.label) + if (shouldLabelMount && !labelMounted) { + this.managers.labels.mount(this.label) + } else if (!shouldLabelMount && labelMounted) { + this.managers.labels.unmount(this.label) + } + } } delete() { - this.renderer.edgeObjectManager.delete(this.lineSegment) - if (this.arrow?.forward) { - this.renderer.edgeArrowObjectManager.delete(this.arrow.forward) + clearTimeout(this.doubleClickTimeout) + + this.managers.edges.delete(this.lineSegment) + this.managers.interactions.delete(this.hitArea) + if (this.forwardArrow) { + this.managers.arrows.delete(this.forwardArrow) } - if (this.arrow?.reverse) { - this.renderer.edgeArrowObjectManager.delete(this.arrow.reverse) + if (this.reverseArrow) { + this.managers.arrows.delete(this.reverseArrow) } - this.renderer.interactionObjectManager.delete(this.hitArea) } pointerEnter = (event: FederatedPointerEvent) => { @@ -351,7 +377,8 @@ export class EdgeRenderer { this.doubleClick = false } - private visible(minX: number, minY: number, maxX: number, maxY: number) { + private visible(x0: number, y0: number, x1: number, y1: number) { + const [minX, minY, maxX, maxY] = [Math.min(x0, x1), Math.min(y0, y1), Math.max(x0, x1), Math.max(y0, y1)] // TODO - also calculate whether edge intersects with any of the 4 bbox edges return ( this.renderer.zoom > MIN_EDGES_ZOOM && @@ -361,4 +388,50 @@ export class EdgeRenderer { minY <= this.renderer.maxY ) } + + private get managers() { + return this.renderer.managers + } + + private get arrow(): ArrowStyle { + if (this.forwardArrow === undefined && this.reverseArrow === undefined) { + return 'none' + } else if (this.reverseArrow === undefined) { + return 'forward' + } else if (this.forwardArrow === undefined) { + return 'reverse' + } else { + return 'both' + } + } + + private applyArrow({ forward, reverse }: { forward: boolean; reverse: boolean }) { + if (forward && this.forwardArrow === undefined) { + this.forwardArrow = new Arrow(this.renderer.edgesContainer, this.renderer.arrow) + } else if (!forward && this.forwardArrow !== undefined) { + this.managers.arrows.delete(this.forwardArrow) + this.forwardArrow = undefined + } + + if (reverse && this.reverseArrow === undefined) { + this.reverseArrow = new Arrow(this.renderer.edgesContainer, this.renderer.arrow) + } else if (!reverse && this.reverseArrow !== undefined) { + this.managers.arrows.delete(this.reverseArrow) + this.reverseArrow = undefined + } + + return this + } + + private applyLabel() { + const label = this.edge.label + const style = this.edge.style?.label + if (label !== undefined && label.trim() !== '' && this.label === undefined) { + this.label = new Text(this.renderer.assets, this.renderer.labelsContainer, label, style, DEFAULT_LABEL_STYLE) + this.label.rotation = this.theta + this.label.moveTo(...this.center) + } + + return this + } } diff --git a/src/renderers/webgl/index.ts b/src/renderers/webgl/index.ts index 8fdd3591..a42730d3 100644 --- a/src/renderers/webgl/index.ts +++ b/src/renderers/webgl/index.ts @@ -1,30 +1,31 @@ import { Application, Container, EventSystem, FederatedPointerEvent, Rectangle } from 'pixi.js' +import type { Node, Edge, Annotation, Viewport } from '../../types' import Stats from 'stats.js' -import * as Graph from '../..' +// import * as Graph from '../..' import { Zoom } from './interaction/zoom' import { Drag } from './interaction/drag' import { Decelerate } from './interaction/decelerate' import { Grid } from './grid' import { NodeRenderer } from './node' import { EdgeRenderer } from './edge' -import { ArrowTexture } from './textures/arrow' -import { CircleTexture } from './textures/circle' -import { Font } from './textures/font' -import { interpolate } from '../../utils' +import { interpolate } from '../../utils/helpers' import { logUnknownEdgeError } from './utils' -import { ObjectManager } from './objectManager' -import { TextIconTexture } from './textures/textIcon' +import ArrowTexture from './textures/ArrowTexture' +import CircleTexture from './textures/CircleTexture' +import TextIconTexture from './textures/TextIconTexture' +import AssetManager from './loaders/AssetManager' +import LifecycleManager from './LifecycleManager' export type Keys = { altKey?: boolean; ctrlKey?: boolean; metaKey?: boolean; shiftKey?: boolean } export type MousePosition = { x: number; y: number; clientX: number; clientY: number } export type Position = 'nw' | 'ne' | 'se' | 'sw' -export type NodePointerEvent = { type: 'nodePointer'; target: Graph.Node; targetIdx: number } & MousePosition & Keys -export type NodeDragEvent = { type: 'nodeDrag'; dx: number; dy: number; target: Graph.Node; targetIdx: number } & MousePosition & Keys -export type EdgePointerEvent = { type: 'edgePointer'; target: Graph.Edge; targetIdx: number } & MousePosition & Keys +export type NodePointerEvent = { type: 'nodePointer'; target: Node; targetIdx: number } & MousePosition & Keys +export type NodeDragEvent = { type: 'nodeDrag'; dx: number; dy: number; target: Node; targetIdx: number } & MousePosition & Keys +export type EdgePointerEvent = { type: 'edgePointer'; target: Edge; targetIdx: number } & MousePosition & Keys export type AnnotationPointerEvent = { type: 'annotationPointer' position?: Position - target: Graph.Annotation + target: Annotation targetIdx: number } & MousePosition & Keys @@ -32,18 +33,18 @@ export type AnnotationDragEvent = { type: 'annotationDrag' dx: number dy: number - target: Graph.Annotation + target: Annotation targetIdx: number } & MousePosition & Keys export type AnnotationResizeEvent = { type: 'annotationResize' position: Position - target: Graph.Annotation + target: Annotation targetIdx: number } & MousePosition & Keys -export type ViewportPointerEvent = { type: 'viewportPointer'; target: Graph.Viewport } & MousePosition & Keys +export type ViewportPointerEvent = { type: 'viewportPointer'; target: Viewport } & MousePosition & Keys export type ViewportDragEvent = { type: 'viewportDrag'; dx: number; dy: number } & MousePosition & Keys export type ViewportDragDecelerateEvent = { type: 'viewportDragDecelarate'; dx: number; dy: number } & Keys export type ViewportWheelEvent = { type: 'viewportWheel'; dx: number; dy: number; dz: number } & MousePosition & Keys @@ -99,14 +100,6 @@ export const defaultOptions = { dragInertia: 0.88 } -// TODO - make configurable -export const MIN_LABEL_ZOOM = 0.25 -export const MIN_NODE_STROKE_ZOOM = 0.3 -export const MIN_NODE_ICON_ZOOM = 0.3 -export const MIN_INTERACTION_ZOOM = 0.15 -export const MIN_EDGES_ZOOM = 0.1 -export const MIN_ZOOM = 3 - export class Renderer { width: number height: number @@ -134,17 +127,11 @@ export class Renderer { zoomInteraction = new Zoom(this) dragInteraction = new Drag(this) decelerateInteraction = new Decelerate(this) - nodeStrokeObjectManager = new ObjectManager(1000) - nodeIconObjectManager = new ObjectManager(1000) - edgeObjectManager = new ObjectManager(2000) - edgeArrowObjectManager = new ObjectManager(1000) - labelObjectManager = new ObjectManager(2000) - interactionObjectManager = new ObjectManager(2000) - font = new Font() + managers = new LifecycleManager() eventSystem: EventSystem - nodes: Graph.Node[] = [] + nodes: Node[] = [] nodeRenderersById: Record = {} - edges: Graph.Edge[] = [] + edges: Edge[] = [] edgeRenderersById: Record = {} renderedNodes = false dragInertia = defaultOptions.dragInertia @@ -156,6 +143,7 @@ export class Renderer { textIcon: TextIconTexture draggedNode?: NodeRenderer hoveredNode?: NodeRenderer + assets = new AssetManager() private doubleClick = false private doubleClickTimeout: NodeJS.Timeout | undefined @@ -268,7 +256,7 @@ export class Renderer { } } - update({ nodes, edges, options }: { nodes: Graph.Node[]; edges: Graph.Edge[]; annotations?: Graph.Annotation[]; options: Options }) { + update({ nodes, edges, options }: { nodes: Node[]; edges: Edge[]; annotations?: Annotation[]; options: Options }) { this.animateViewport = options.animateViewport === true || options.animateViewport === undefined ? defaultOptions.animateViewport : options.animateViewport this.animateNodePosition = @@ -457,7 +445,13 @@ export class Renderer { } image() { - return new Promise((resolve) => resolve(new Blob())) // TODO + return new Promise((resolve) => { + if (this.assets.loading) { + return + } + + return resolve(new Blob()) + }) // TODO } private render(dt: number) { @@ -506,13 +500,7 @@ export class Renderer { this.edgeRenderersById[edge.id].render() } - this.nodeStrokeObjectManager.render() - this.nodeIconObjectManager.render() - this.edgeObjectManager.render() - this.edgeArrowObjectManager.render() - this.labelObjectManager.render() - this.interactionObjectManager.render() - + this.managers.render() this.app.render() } diff --git a/src/renderers/webgl/loaders/AssetLoader.ts b/src/renderers/webgl/loaders/AssetLoader.ts new file mode 100644 index 00000000..326cc061 --- /dev/null +++ b/src/renderers/webgl/loaders/AssetLoader.ts @@ -0,0 +1,60 @@ +import { Publisher, Subscriber } from './PubSub' +import { Assets, Texture } from 'pixi.js' +import { noop } from '../../../utils/helpers' + +type LoadAssetProps = Partial> & { url: string } + +export default class AssetLoader { + private cache: { [url: string]: Publisher } = {} + + static get(url: string): Texture | null { + if (Assets.cache.has(url)) { + return Assets.cache.get(url) + } else { + return null + } + } + + load({ url, resolve = noop, reject = noop }: LoadAssetProps) { + if (this.cache[url] === undefined) { + this.cache[url] = this.createPublisher(url) + } + + return this.cache[url].subscribe({ resolve, reject }) + } + + cancel() { + for (const url in this.cache) { + this.cache[url].cancel() + } + + this.cache = {} + return undefined + } + + get loading() { + for (const url in this.cache) { + if (this.cache[url].loading) { + return true + } + } + + return false + } + + private createPublisher(url: string) { + return new Publisher( + async function loadAsset(): Promise { + try { + return await Assets.load(url) + } catch (error) { + console.warn(error) + throw new Error(`Error loading asset: ${url}`) + } + }, + function checkCache() { + return AssetLoader.get(url) + } + ) + } +} diff --git a/src/renderers/webgl/loaders/AssetManager.ts b/src/renderers/webgl/loaders/AssetManager.ts new file mode 100644 index 00000000..cd03e8ec --- /dev/null +++ b/src/renderers/webgl/loaders/AssetManager.ts @@ -0,0 +1,34 @@ +import FontLoader from './FontLoader' +import AssetLoader from './AssetLoader' +import { Subscription } from './PubSub' +import { Texture } from 'pixi.js' + +export type FontSubscription = Subscription +export type AssetSubscription = Subscription + +export default class AssetManager { + private _font = new FontLoader() + private _asset = new AssetLoader() + + loadFont = this._font.load.bind(this._font) + loadUrl = this._asset.load.bind(this._asset) + + shouldLoadFont( + style: { fontFamily?: string; fontWeight?: string | number } | undefined + ): style is { fontFamily: string; fontWeight?: string | number } { + return style?.fontFamily !== undefined && !FontLoader.available(style.fontFamily, style.fontWeight) + } + + checkAssetCache(url: string) { + return AssetLoader.get(url) + } + + cancel() { + this._font.cancel() + this._asset.cancel() + } + + get loading() { + return this._font.loading || this._asset.loading + } +} diff --git a/src/renderers/webgl/loaders/FontLoader.ts b/src/renderers/webgl/loaders/FontLoader.ts new file mode 100644 index 00000000..b40dded9 --- /dev/null +++ b/src/renderers/webgl/loaders/FontLoader.ts @@ -0,0 +1,78 @@ +import { DEFAULT_TEXT_STYLE, GENERIC_FONT_FAMILIES } from '../../../utils/constants' +import { Subscriber, Publisher } from './PubSub' +import { noop } from '../../../utils/helpers' +import FontFaceObserver from 'fontfaceobserver' + +type LoadFontProps = Partial> & { + fontFamily: string + fontWeight?: string | number + timeout?: number +} + +export default class FontLoader { + private cache: { [key: string]: Publisher } = {} + + static sanitize(fontFamily: string) { + return fontFamily.split(', ')[0] + } + + static isGenericFont(fontFamily: string) { + return GENERIC_FONT_FAMILIES.has(FontLoader.sanitize(fontFamily)) + } + + static toFontString(fontFamily: string, weight: string | number) { + return `${weight} 1em ${FontLoader.sanitize(fontFamily)}` + } + + static available(fontFamily: string, fontWeight: string | number = DEFAULT_TEXT_STYLE.fontWeight) { + return FontLoader.isGenericFont(fontFamily) || document.fonts.check(FontLoader.toFontString(fontFamily, fontWeight)) + } + + load({ fontFamily, fontWeight = DEFAULT_TEXT_STYLE.fontWeight, timeout = 10000, resolve = noop, reject = noop }: LoadFontProps) { + const font = FontLoader.toFontString(fontFamily, fontWeight) + + if (this.cache[font] === undefined) { + this.cache[font] = this.createPublisher(fontFamily, fontWeight, timeout) + } + + return this.cache[font].subscribe({ resolve, reject }) + } + + cancel() { + for (const font in this.cache) { + this.cache[font].cancel() + } + + this.cache = {} + return undefined + } + + get loading() { + for (const font in this.cache) { + if (this.cache[font].loading) { + return true + } + } + + return false + } + + private createPublisher(fontFamily: string, fontWeight: string | number, timeout: number) { + return new Publisher( + async function loadBrowserFont(): Promise { + try { + const weight = typeof fontWeight === 'string' && !isNaN(+fontWeight) ? +fontWeight : fontWeight + const font = new FontFaceObserver(fontFamily, { weight }) + await font.load(null, timeout) + return true + } catch (e) { + console.warn(e) + throw new Error(`Error loading font: ${FontLoader.toFontString(fontFamily, fontWeight)}`) + } + }, + function checkBrowserFonts(): true | null { + return FontLoader.available(fontFamily, fontWeight) ? true : null + } + ) + } +} diff --git a/src/renderers/webgl/loaders/PubSub.ts b/src/renderers/webgl/loaders/PubSub.ts new file mode 100644 index 00000000..5941958f --- /dev/null +++ b/src/renderers/webgl/loaders/PubSub.ts @@ -0,0 +1,115 @@ +export type Subscriber = { + resolve: (result: T) => void + reject: (error: E) => void +} + +export class Subscription { + private _unsubscribed = false + + constructor( + private _publisher: Publisher, + private _executor: Subscriber + ) { + this._publisher = _publisher + this._executor = _executor + } + + unsubscribe() { + const done = this._publisher.completed || this._publisher.cancelled + + if (!done && !this._unsubscribed) { + this._publisher.unsubscribe(this._executor) + } + + this._unsubscribed = true + return undefined + } +} + +export class Publisher { + subscribers = new Set>() + + private _loader: () => Promise + private _cancelled = false + private _asset: T | null = null + private _error: E | null = null + + constructor(asyncLoader: () => Promise, syncLoader?: () => T | null) { + this._loader = asyncLoader + + const asset = syncLoader?.() ?? null + + if (asset !== null) { + this._asset = asset + } else { + this._load() + } + } + + subscribe(subscriber: Subscriber) { + if (this._asset !== null) { + subscriber.resolve(this._asset) + } else if (this._error !== null) { + subscriber.reject(this._error) + } else if (!this._cancelled) { + this.subscribers.add(subscriber) + } + + return new Subscription(this, subscriber) + } + + unsubscribe(subscriber: Subscriber) { + this.subscribers.delete(subscriber) + return undefined + } + + cancel() { + this._cancelled = true + this.subscribers = new Set() + return undefined + } + + get completed() { + return this._asset !== null || this._error !== null + } + + get cancelled() { + return this._cancelled + } + + get loading() { + return !this.completed && !this.cancelled && this.subscribers.size > 0 + } + + private async _load() { + try { + const asset = await this._loader() + this._resolve(asset) + } catch (error) { + console.warn(error) + this._reject(error as unknown as E) + } + } + + private _resolve(result: T) { + if (!this.cancelled) { + for (const { resolve } of this.subscribers) { + resolve(result) + } + } + + this._asset = result + this.subscribers = new Set() + } + + private _reject(error: E) { + if (!this.cancelled) { + for (const { reject } of this.subscribers) { + reject(error) + } + } + + this._error = error + this.subscribers = new Set() + } +} diff --git a/src/renderers/webgl/node.ts b/src/renderers/webgl/node.ts index cbffdc25..f03e7b5d 100644 --- a/src/renderers/webgl/node.ts +++ b/src/renderers/webgl/node.ts @@ -1,19 +1,20 @@ +import { DEFAULT_LABEL_STYLE, MIN_LABEL_ZOOM, MIN_INTERACTION_ZOOM, MIN_NODE_STROKE_ZOOM, MIN_NODE_ICON_ZOOM } from '../../utils/constants' import { FederatedPointerEvent } from 'pixi.js' -import { MIN_LABEL_ZOOM, MIN_INTERACTION_ZOOM, MIN_NODE_STROKE_ZOOM, Renderer, MIN_NODE_ICON_ZOOM } from '.' -import * as Graph from '../..' -import { Label } from './objects/label' -import { NodeFill } from './objects/nodeFill' import { NodeStrokes } from './objects/nodeStrokes' -import { Icon } from './objects/icon' import { NodeHitArea } from './interaction/nodeHitArea' -import { interpolate } from '../../utils' +import { interpolate } from '../../utils/helpers' +import { NodeFill } from './objects/nodeFill' +import { type Renderer } from '.' +import type { Node } from '../../types' +import Text from './objects/text/Text' +import Icon from './objects/Icon' export class NodeRenderer { - node!: Graph.Node + node!: Node x!: number y!: number fill: NodeFill - label?: Label + label?: Text icon?: Icon strokes: NodeStrokes @@ -28,12 +29,8 @@ export class NodeRenderer { private interpolateX?: (dt: number) => { value: number; done: boolean } private interpolateY?: (dt: number) => { value: number; done: boolean } private interpolateRadius?: (dt: number) => { value: number; done: boolean } - private fillMounted = false - private strokeMounted = false - private labelMounted = false - private iconMounted = false - constructor(renderer: Renderer, node: Graph.Node) { + constructor(renderer: Renderer, node: Node) { this.renderer = renderer this.fill = new NodeFill(this.renderer.nodesContainer, this.renderer.circle) this.strokes = new NodeStrokes(this.renderer.nodesContainer, this.renderer.circle, this.fill) @@ -41,28 +38,22 @@ export class NodeRenderer { this.update(node) } - update(node: Graph.Node) { - if (this.label === undefined) { - if (node.label !== undefined) { - this.label = new Label(this.renderer.labelsContainer, node.label, node.style?.label) + update(node: Node) { + if (this.label) { + if (node.label === undefined || node.label.trim() === '') { + this.managers.labels.delete(this.label) + this.label = undefined + } else { + this.label.update(node.label, node.style?.label) } - } else if (node.label === undefined || node.label.trim() === '') { - this.renderer.labelObjectManager.delete(this.label) - this.labelMounted = false - this.label = undefined - } else if (!this.label.equals(node.label, node.style?.label)) { - this.label.update(node.label, node.style?.label) } - if (this.icon === undefined) { - if (node.style?.icon) { - this.icon = new Icon(this.renderer.nodesContainer, this.renderer.textIcon, this.fill, node.style.icon) - } - } else { + if (this.icon) { if (node.style?.icon === undefined) { this.icon.delete() - this.iconMounted = false this.icon = undefined + } else { + this.icon.update(node.style.icon) } } @@ -73,6 +64,7 @@ export class NodeRenderer { * - the animateViewport option is not disabled * - it's not the first render */ + const x = node.x ?? 0 const y = node.y ?? 0 const xChanged = x !== this.x @@ -147,75 +139,75 @@ export class NodeRenderer { // TODO - disable events if node has no event handlers // TODO - disable events if node pixel width < ~5px // TODO - disable events when dragging/scrolling - if (isVisible && this.renderer.zoom > MIN_INTERACTION_ZOOM) { - this.renderer.interactionObjectManager.mount(this.hitArea) - } else { - this.renderer.interactionObjectManager.unmount(this.hitArea) + const shouldHitAreaMount = isVisible && this.renderer.zoom > MIN_INTERACTION_ZOOM + const hitAreaMounted = this.managers.interactions.isMounted(this.hitArea) + + if (shouldHitAreaMount && !hitAreaMounted) { + this.managers.interactions.mount(this.hitArea) + } else if (!shouldHitAreaMount && hitAreaMounted) { + this.managers.interactions.unmount(this.hitArea) } - if (isVisible) { - if (!this.fillMounted) { - this.fill.mount() - this.fillMounted = true - } - } else { - if (this.fillMounted) { - this.fill.unmount() - this.fillMounted = false - } + const fillMounted = this.managers.nodes.isMounted(this.fill) + + if (isVisible && !fillMounted) { + this.managers.nodes.mount(this.fill) + } else if (!isVisible && fillMounted) { + this.managers.nodes.unmount(this.fill) } - if (isVisible && this.renderer.zoom > MIN_NODE_STROKE_ZOOM) { - if (!this.strokeMounted) { - this.renderer.nodeStrokeObjectManager.mount(this.strokes) - this.strokeMounted = true - } - } else { - if (this.strokeMounted) { - this.renderer.nodeStrokeObjectManager.unmount(this.strokes) - this.strokeMounted = false - } + const shouldStrokesMount = isVisible && this.renderer.zoom > MIN_NODE_STROKE_ZOOM + const strokesMounted = this.managers.nodes.isMounted(this.strokes) + + if (shouldStrokesMount && !strokesMounted) { + this.managers.nodes.mount(this.strokes) + } else if (!shouldStrokesMount && strokesMounted) { + this.managers.nodes.unmount(this.strokes) + } + + const shouldLabelMount = isVisible && this.renderer.zoom > MIN_LABEL_ZOOM + + if (shouldLabelMount) { + this.applyLabel() } if (this.label) { - if (isVisible && this.renderer.zoom > MIN_LABEL_ZOOM) { - if (!this.labelMounted) { - this.renderer.labelObjectManager.mount(this.label) - this.labelMounted = true - } - } else { - if (this.labelMounted) { - this.renderer.labelObjectManager.unmount(this.label) - this.labelMounted = false - } + const labelMounted = this.managers.labels.isMounted(this.label) + if (shouldLabelMount && !labelMounted) { + this.managers.labels.mount(this.label) + } else if (!shouldLabelMount && labelMounted) { + this.managers.labels.unmount(this.label) } } + const shouldIconMount = isVisible && this.renderer.zoom > MIN_NODE_ICON_ZOOM + + if (shouldIconMount) { + this.applyIcon() + } + if (this.icon) { - if (isVisible && this.renderer.zoom > MIN_NODE_ICON_ZOOM) { - if (!this.iconMounted) { - this.renderer.nodeIconObjectManager.mount(this.icon) - this.iconMounted = true - } - } else { - if (this.iconMounted) { - this.renderer.nodeIconObjectManager.unmount(this.icon) - this.iconMounted = false - } + const iconMounted = this.managers.icons.isMounted(this.icon) + if (shouldIconMount && !iconMounted) { + this.managers.icons.mount(this.icon) + } else if (!shouldIconMount && iconMounted) { + this.managers.icons.unmount(this.icon) } } } delete() { clearTimeout(this.doubleClickTimeout) - this.fill.delete() - this.renderer.nodeStrokeObjectManager.delete(this.strokes) - this.renderer.interactionObjectManager.delete(this.hitArea) + + this.managers.nodes.delete(this.fill) + this.managers.nodes.delete(this.strokes) + this.managers.interactions.delete(this.hitArea) + if (this.label) { - this.renderer.labelObjectManager.delete(this.label) + this.managers.labels.delete(this.label) } if (this.icon) { - this.renderer.nodeIconObjectManager.delete(this.icon) + this.managers.icons.delete(this.icon) } } @@ -495,28 +487,65 @@ export class NodeRenderer { this.doubleClick = false } - private setPosition(node: Graph.Node, x: number, y: number, radius: number) { + private setPosition(node: Node, x: number, y: number, radius: number) { this.x = x this.y = y this.fill.update(this.x, this.y, radius, node.style) this.strokes.update(this.x, this.y, radius, node.style) - if (this.label !== undefined) { - this.label.moveTo(this.x, this.y, this.strokes.radius) - } - if (this.icon && node.style?.icon) { - this.icon.update(this.x, this.y, node.style.icon) + + if (this.label) { + this.label.offset = this.strokes.radius + this.label.moveTo(this.x, this.y) } - this.hitArea.update(x, y, radius) + + this.icon?.moveTo(this.x, this.y) + this.hitArea.update(this.x, this.y, radius) } private visible() { - // TODO - consider label to calculate min/max // this.label?.getBounds(true).width - return ( - this.x + this.strokes.radius >= this.renderer.minX && - this.x - this.strokes.radius <= this.renderer.maxX && - this.y + this.strokes.radius >= this.renderer.minY && - this.y - this.strokes.radius <= this.renderer.maxY - ) + let left: number, right: number, top: number, bottom: number + + if (this.label) { + left = this.x - Math.max(this.strokes.radius, this.label.rect.left) + right = this.x + Math.max(this.strokes.radius, this.label.rect.right) + top = this.y - Math.max(this.strokes.radius, this.label.rect.top) + bottom = this.y + Math.max(this.strokes.radius, this.label.rect.bottom) + } else { + left = this.x - this.strokes.radius + right = this.x + this.strokes.radius + top = this.y - this.strokes.radius + bottom = this.y + this.strokes.radius + } + + const { minX, maxX, minY, maxY } = this.renderer + + return right >= minX && left <= maxX && bottom >= minY && top <= maxY + } + + private get managers() { + return this.renderer.managers + } + + private applyLabel() { + const label = this.node.label + const style = this.node.style?.label + if (label !== undefined && label.trim() !== '' && this.label === undefined) { + this.label = new Text(this.renderer.assets, this.renderer.labelsContainer, label, style, DEFAULT_LABEL_STYLE) + this.label.offset = this.strokes.radius + this.label.moveTo(this.x, this.y) + } + + return this + } + + private applyIcon() { + const icon = this.node.style?.icon + if (icon !== undefined && this.icon === undefined) { + this.icon = new Icon(this.renderer.assets, this.renderer.textIcon, this.renderer.nodesContainer, this.fill, icon) + this.icon.moveTo(this.x, this.y) + } + + return this } } diff --git a/src/renderers/webgl/objects/Icon.ts b/src/renderers/webgl/objects/Icon.ts new file mode 100644 index 00000000..fb8667d6 --- /dev/null +++ b/src/renderers/webgl/objects/Icon.ts @@ -0,0 +1,156 @@ +import { Container, Sprite, Texture } from 'pixi.js' +import { IconStyle } from '../../../types' +import { equals } from '../../../utils/api' +import AssetManager, { AssetSubscription, FontSubscription } from '../loaders/AssetManager' +import TextIconTexture from '../textures/TextIconTexture' + +export default class Icon { + mounted = false + + private x = 0 + private y = 0 + private object: Sprite + private subscription?: FontSubscription | AssetSubscription + + constructor( + private assets: AssetManager, + private textIconTexture: TextIconTexture, + private container: Container, + private fill: { getContainerIndex: () => number }, + private icon: IconStyle + ) { + this.assets = assets + this.textIconTexture = textIconTexture + this.container = container + this.fill = fill + this.icon = icon + + if (this.icon.type === 'imageIcon' || this.assets.shouldLoadFont(this.icon.style)) { + this.object = this.create(Texture.EMPTY) + this.loadTexture() + } else { + this.object = this.create(this.textIconTexture.get(this.icon)) + } + } + + update(icon: IconStyle) { + if (!equals(icon, this.icon)) { + this.cancel() + this.icon = icon + + if (this.icon.type === 'imageIcon' || this.assets.shouldLoadFont(this.icon.style)) { + this.loadTexture() + } else { + this.texture = this.textIconTexture.get(this.icon) + } + } + + return this + } + + moveTo(_x: number, _y: number) { + const x = _x + this.offsetX + const y = _y + this.offsetY + + if (x !== this.x) { + this.x = x + this.object.x = x + } + + if (y !== this.y) { + this.y = y + this.object.y = y + } + + return this + } + + mount() { + if (!this.mounted) { + this.container.addChildAt(this.object, this.fill.getContainerIndex() + 1) + this.mounted = true + } + + return this + } + + unmount() { + if (this.mounted) { + this.container.removeChild(this.object) + this.mounted = false + } + + return this + } + + delete() { + this.unmount().cancel() + this.object.destroy() + + return undefined + } + + cancel() { + this.subscription?.unsubscribe() + this.subscription = undefined + + return this + } + + private get scale() { + if (this.icon.type === 'textIcon') { + return 1 / this.textIconTexture.scaleFactor + } else { + return this.icon.scale ?? 1 + } + } + + private get offsetX() { + return this.icon.offset?.x ?? 0 + } + + private get offsetY() { + return this.icon.offset?.y ?? 0 + } + + private set texture(texture: Texture) { + if (texture !== this.object.texture) { + this.object.texture = texture + this.object.scale.set(this.scale) + } + } + + private create(texture: Texture) { + const icon = new Sprite(texture) + icon.scale.set(this.scale) + icon.anchor.set(0.5) + icon.x = this.x + icon.y = this.y + return icon + } + + private loadTexture() { + if (this.icon.type === 'imageIcon') { + this.subscription = this.assets.loadUrl({ + url: this.icon.url, + resolve: (texture) => { + this.subscription = undefined + this.texture = texture + } + }) + } else if (this.assets.shouldLoadFont(this.icon.style)) { + this.subscription = this.assets.loadFont({ + fontFamily: this.icon.style.fontFamily, + fontWeight: this.icon.style.fontWeight, + resolve: () => { + this.subscription = undefined + if (this.icon.type === 'textIcon') { + this.texture = this.textIconTexture.get(this.icon) + } + } + }) + } + + return this + } +} diff --git a/src/renderers/webgl/objectManager.ts b/src/renderers/webgl/objects/ObjectManager.ts similarity index 50% rename from src/renderers/webgl/objectManager.ts rename to src/renderers/webgl/objects/ObjectManager.ts index 502b67f0..6d754f96 100644 --- a/src/renderers/webgl/objectManager.ts +++ b/src/renderers/webgl/objects/ObjectManager.ts @@ -1,31 +1,21 @@ -export interface RenderObject { - mounted: boolean +import { RenderObjectLifecycle } from '../../../types' - mount(): this +export default class ObjectManager { + private batch = new Map() - unmount(): this - - delete(): void -} - -export class ObjectManager { - private batchSize: number - - private batch = new Map() - - constructor(batchSize: number) { - this.batchSize = batchSize + constructor(private limit: number) { + this.limit = limit } - mount(object: RenderObject) { + mount(object: T) { this.batch.set(object, 0) } - unmount(object: RenderObject) { + unmount(object: T) { this.batch.set(object, 1) } - delete(object: RenderObject) { + delete(object: T) { this.batch.set(object, 2) } @@ -33,7 +23,7 @@ export class ObjectManager { let count = 0 for (const [object, operation] of this.batch) { - if (count === this.batchSize) { + if (count === this.limit) { break } @@ -53,4 +43,9 @@ export class ObjectManager { count++ } } + + isMounted(object: T) { + const code = this.batch.get(object) + return code === 0 || (code === undefined && object.mounted) + } } diff --git a/src/renderers/webgl/objects/arrow.ts b/src/renderers/webgl/objects/arrow.ts index d4437b0e..3895ba32 100644 --- a/src/renderers/webgl/objects/arrow.ts +++ b/src/renderers/webgl/objects/arrow.ts @@ -1,5 +1,5 @@ import { Container, Sprite } from 'pixi.js' -import { ArrowTexture } from '../textures/arrow' +import ArrowTexture from '../textures/ArrowTexture' export class Arrow { mounted = false @@ -13,7 +13,7 @@ export class Arrow { constructor(container: Container, arrowTexture: ArrowTexture) { this.container = container this.arrowTexture = arrowTexture - this.arrow = new Sprite(this.arrowTexture.texture) + this.arrow = new Sprite(this.arrowTexture.get()) this.height = this.arrowTexture.height this.width = this.arrowTexture.width this.arrow.anchor.set(0, 0.5) diff --git a/src/renderers/webgl/objects/icon.ts b/src/renderers/webgl/objects/icon.ts deleted file mode 100644 index 5534629d..00000000 --- a/src/renderers/webgl/objects/icon.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { Container, Sprite } from 'pixi.js' -import { NodeFill } from './nodeFill' -import { TextIconTexture } from '../textures/textIcon' -import * as Graph from '../../..' - -// TODO - support image icons -export class Icon { - mounted = false - - private container: Container - private textIconTexture: TextIconTexture - private nodeFill: NodeFill - private style: Graph.TextIcon | Graph.ImageIcon - private icon: Sprite - - constructor(container: Container, textIconTexture: TextIconTexture, nodeFill: NodeFill, style: Graph.TextIcon | Graph.ImageIcon) { - this.container = container - this.textIconTexture = textIconTexture - this.nodeFill = nodeFill - - this.icon = this.createIcon(style) - this.style = style - } - - update(x: number, y: number, style: Graph.TextIcon | Graph.ImageIcon) { - if (!Graph.equals(this.style, style)) { - const isMounted = this.mounted - this.delete() - this.icon = this.createIcon(style) - if (isMounted) { - this.mount() - } - this.style = style - } - - this.icon.x = this.style.offsetX ?? 0 + x - this.icon.y = this.style.offsetY ?? 0 + y - - return this - } - - mount() { - if (!this.mounted) { - this.container.addChildAt(this.icon, this.nodeFill.getContainerIndex() + 1) - this.mounted = true - } - - return this - } - - unmount() { - if (this.mounted) { - this.container.removeChild(this.icon) - this.mounted = false - } - - return this - } - - delete() { - this.unmount() - this.icon.destroy() - - return undefined - } - - private createIcon(style: Graph.TextIcon | Graph.ImageIcon): Sprite { - let icon: Sprite - - if (style.type === 'textIcon') { - icon = new Sprite(this.textIconTexture.create(style.text, style.family, style.size, style.weight ?? 'normal', style.color)) - icon.anchor.set(0.5) - icon.scale.set(1 / this.textIconTexture.scaleFactor) - } else if (style.type === 'imageIcon') { - // TODO - icon = new Sprite(this.textIconTexture.create('?', 'sans-serif', 12, 'normal', 0x000000)) - icon.anchor.set(0.5) - } else { - icon = new Sprite(this.textIconTexture.create('?', 'sans-serif', 12, 'normal', 0x000000)) - icon.anchor.set(0.5) - } - - return icon - } -} diff --git a/src/renderers/webgl/objects/label/background.ts b/src/renderers/webgl/objects/label/background.ts deleted file mode 100644 index 6bae1b0d..00000000 --- a/src/renderers/webgl/objects/label/background.ts +++ /dev/null @@ -1,140 +0,0 @@ -import utils, { STYLE_DEFAULTS } from './utils' -import type { LabelBackgroundStyle } from './utils' -import { BitmapText, ColorSource, Container, Point, Rectangle, Sprite, Text, Texture } from 'pixi.js' -import { equals } from '../../../..' - -export class LabelBackground { - mounted = false - - private x?: number - private y?: number - private dirty = false - private sprite: Sprite - private label: Text | BitmapText - private container: Container - private rect: Rectangle - private _style: LabelBackgroundStyle - - constructor(container: Container, label: Text | BitmapText, style: LabelBackgroundStyle) { - this.label = label - this.container = container - this._style = style - this.rect = this.label.getLocalBounds() - - const { width, height } = this.size - - this.sprite = Sprite.from(Texture.WHITE) - this.sprite.height = height - this.sprite.width = width - this.sprite.anchor.set(this.label.anchor.x, this.label.anchor.y) - this.sprite.alpha = this.style.opacity - this.sprite.tint = this.style.color - } - - update(label: Text | BitmapText, style: LabelBackgroundStyle) { - this.dirty = !equals(style.padding, this._style.padding) - this.bounds = label.getLocalBounds() - this.anchor = label.anchor.clone() - - if (this.label !== label) { - this.label = label - } - - if (this._style !== style) { - this._style = style - this.color = style.color - this.opacity = style.opacity ?? STYLE_DEFAULTS.OPACITY - } - - if (this.dirty) { - this.dirty = false - this.resize() - } - - return this - } - - moveTo(x: number, y: number) { - if (this.x !== x) { - this.x = x - this.sprite.x = x - } - - if (this.y !== y) { - this.y = y - this.sprite.y = y - } - } - - mount() { - if (!this.mounted) { - this.container.addChild(this.sprite) - this.mounted = true - } - - return this - } - - unmount() { - if (this.mounted) { - this.container.removeChild(this.sprite) - this.mounted = false - } - - return this - } - - delete() { - this.unmount() - this.sprite.destroy() - - return undefined - } - - private resize() { - const { height, width } = this.size - if (height !== this.sprite.height) { - this.sprite.height = height - } - if (width !== this.sprite.width) { - this.sprite.width = width - } - return this - } - - private get style() { - return utils.mergeBackgroundDefaults(this._style) - } - - private get size() { - const [top, right, bottom, left] = utils.getBackgroundPadding(this._style.padding) - const height = this.rect.height + top + bottom - const width = this.rect.width + right + left - return { width, height } - } - - private set anchor(anchor: Point) { - if (!this.sprite.anchor.equals(anchor)) { - this.sprite.anchor.copyFrom(anchor) - } - } - - private set color(color: ColorSource) { - if (this.sprite.tint !== color) { - this.sprite.tint = color - } - } - - private set opacity(opacity: number) { - if (this.sprite.alpha !== opacity) { - this.sprite.alpha = opacity - } - } - - private set bounds(bounds: Rectangle) { - if (this.rect.width !== bounds.width || this.rect.height !== bounds.height) { - this.rect = bounds - this.dirty = true - } - } -} diff --git a/src/renderers/webgl/objects/label/index.ts b/src/renderers/webgl/objects/label/index.ts deleted file mode 100644 index 7b229fe6..00000000 --- a/src/renderers/webgl/objects/label/index.ts +++ /dev/null @@ -1,289 +0,0 @@ -import utils, { STYLE_DEFAULTS } from './utils' -import type { LabelPosition, LabelStyle, LabelBackgroundStyle, TextAlign, FontWeight } from './utils' -import type { Stroke } from '../../../../types' -import { BitmapText, Container, Text } from 'pixi.js' -import { LabelBackground } from './background' - -/** - * TODO - * - add support for background color, custom font loading - * - add support for loading custom fonts as asset bundles - * - moving/scaling labels is slow. render ASCII text characters as sprites to partical container? - */ -export class Label { - mounted = false - - private x?: number - private y?: number - private dirty = false - private transformed = false - private label: string - private container: Container - private text: BitmapText | Text - private labelBackground: LabelBackground | null = null - private _style: LabelStyle | undefined - - constructor(container: Container, label: string, style: LabelStyle | undefined) { - this.container = container - this.label = label - this._style = style - - if (utils.isASCII(this.label)) { - utils.loadFont(this.style) - this.text = new BitmapText(this.label, utils.getBitmapStyle(this.style)) - this.text.resolution = 2 - } else { - this.text = new Text(this.label, utils.getTextStyle(this.style)) - } - - this.text.anchor.set(...utils.getAnchorPoint(this.style.position)) - if (this.style.background !== undefined) { - this.labelBackground = new LabelBackground(container, this.text, this.style.background) - } - } - - update(label: string, style: LabelStyle | undefined) { - const labelHasChanged = this.label !== label - const styleHasChanged = this._style !== style - - this._style = style - - if (labelHasChanged) { - this.label = label - this.text.text = label - - const isBitmapText = this.isBitmapText() - const isASCII = utils.isASCII(label) - // if the text type has changed, regenerate a new text object - if ((isBitmapText && !isASCII) || (!isBitmapText && isASCII)) { - this.transformText() - } - } - - if (styleHasChanged) { - this.stroke = style?.stroke - this.wordWrap = style?.wordWrap - this.color = style?.color ?? STYLE_DEFAULTS.COLOR - this.fontWeight = style?.fontWeight ?? STYLE_DEFAULTS.FONT_WEIGHT - this.letterSpacing = style?.letterSpacing ?? STYLE_DEFAULTS.LETTER_SPACING - this.position = style?.position ?? STYLE_DEFAULTS.POSITION - this.fontSize = style?.fontSize ?? STYLE_DEFAULTS.FONT_SIZE - this.fontFamily = style?.fontFamily ?? STYLE_DEFAULTS.FONT_FAMILY - this.fontName = style?.fontName ?? STYLE_DEFAULTS.FONT_NAME - } - - if (this.dirty) { - this.dirty = false - this.updateText() - } - - if (labelHasChanged || styleHasChanged) { - this.transformed = false - this.background = style?.background - } - - return this - } - - moveTo(x: number, y: number, offset = 0) { - const { label, bg } = utils.getLabelCoordinates(x, y, offset, this.isBitmapText(), this.style) - - this.labelBackground?.moveTo(bg.x, bg.y) - - if (label.x !== this.x) { - this.text.x = label.x - this.x = label.x - } - if (label.y !== this.y) { - this.text.y = label.y - this.y = label.y - } - } - - mount() { - if (!this.mounted) { - this.labelBackground?.mount() - this.container.addChild(this.text) - this.mounted = true - } - - return this - } - - unmount() { - if (this.mounted) { - this.labelBackground?.unmount() - this.container.removeChild(this.text) - this.mounted = false - } - - return this - } - - delete() { - this.unmount() - this.text.destroy() - if (!this.transformed) { - this.labelBackground?.delete() - } - - return undefined - } - - equals(label: string, style: LabelStyle | undefined) { - return this.label === label && this._style === style - } - - private isBitmapText(text: Text | BitmapText = this.text): text is BitmapText { - return text instanceof BitmapText - } - - private updateText() { - if (this.isBitmapText(this.text)) { - this.text.updateText() - } else { - this.text.updateText(true) - } - } - - private transformText() { - this.transformed = true - const isMounted = this.mounted - - this.delete() - - if (utils.isASCII(this.label)) { - utils.loadFont(this.style) - this.text = new BitmapText(this.label, utils.getBitmapStyle(this.style)) - this.text.resolution = 2 - } else { - this.text = new Text(this.label, utils.getTextStyle(this.style)) - } - - this.anchor = utils.getAnchorPoint(this.style.position) - this.text.x = this.x ?? 0 - this.text.y = this.y ?? 0 - - if (isMounted) { - this.mount() - } - } - - private get style() { - return utils.mergeDefaults(this._style) - } - - private set position(position: LabelPosition) { - this.align = utils.getTextAlign(position) - this.anchor = utils.getAnchorPoint(position) - } - - private set align(align: TextAlign) { - if (this.isBitmapText(this.text)) { - if (this.text.align !== align) { - this.dirty = true - this.text.align = align - } - } else { - if (this.text.style.align !== align) { - this.dirty = true - this.text.style.align = align - } - } - } - - private set anchor([x, y]: [x: number, y: number]) { - if (!this.text.anchor.equals({ x, y })) { - this.text.anchor.set(x, y) - } - } - - private set fontSize(fontSize: number) { - if (this.isBitmapText(this.text)) { - if (this.text.fontSize !== fontSize) { - this.dirty = true - this.text.fontSize = fontSize - } - } else { - if (this.text.style.fontSize !== fontSize) { - this.dirty = true - this.text.style.fontSize = fontSize - } - } - } - - private set wordWrap(wordWrap: number | undefined) { - const wordWrapWidth = wordWrap ?? 0 - if (!this.isBitmapText(this.text) && this.text.style.wordWrapWidth !== wordWrapWidth) { - this.dirty = true - this.text.style.wordWrap = wordWrap !== undefined - this.text.style.wordWrapWidth = wordWrapWidth - } - } - - private set color(color: string) { - if (!this.isBitmapText(this.text) && this.text.style.fill !== color) { - this.dirty = true - this.text.style.fill = color - } - } - - private set stroke(value: Stroke | undefined) { - if (!this.isBitmapText(this.text)) { - const stroke = value?.color ?? STYLE_DEFAULTS.STROKE - const strokeThickness = value?.width ?? STYLE_DEFAULTS.STROKE_THICKNESS - if (this.text.style.stroke !== stroke || this.text.style.strokeThickness !== strokeThickness) { - this.dirty = true - this.text.style.stroke = stroke - this.text.style.strokeThickness = strokeThickness - } - } - } - - private set fontFamily(fontFamily: string) { - if (!this.isBitmapText(this.text) && fontFamily !== this.text.style.fontFamily) { - this.dirty = true - this.text.style.fontFamily = fontFamily - } - } - - private set fontName(fontName: string) { - if (this.isBitmapText(this.text) && this.text.fontName !== fontName) { - this.dirty = true - this.text.fontName = fontName - } - } - - private set fontWeight(fontWeight: FontWeight) { - if (!this.isBitmapText(this.text) && this.text.style.fontWeight !== fontWeight) { - this.dirty = true - this.text.style.fontWeight = fontWeight - } - } - - private set background(background: LabelBackgroundStyle | undefined) { - if (this.labelBackground === null && background !== undefined) { - this.labelBackground = new LabelBackground(this.container, this.text, background) - } else if (this.labelBackground && background !== undefined) { - this.labelBackground.update(this.text, background) - } else if (this.labelBackground && background === undefined) { - this.labelBackground.delete() - this.labelBackground = null - } - } - - private set letterSpacing(letterSpacing: number) { - if (!this.isBitmapText(this.text)) { - if (this.text.style.letterSpacing !== letterSpacing) { - this.dirty = true - this.text.style.letterSpacing = letterSpacing - } - } else if (this.text.letterSpacing !== letterSpacing) { - this.dirty = true - this.text.letterSpacing = letterSpacing - } - } -} - -export { LabelBackground } from './background' -export type { LabelStyle, LabelBackgroundStyle, LabelPosition, FontWeight, TextAlign } from './utils' diff --git a/src/renderers/webgl/objects/label/utils.ts b/src/renderers/webgl/objects/label/utils.ts deleted file mode 100644 index ee8ce8a4..00000000 --- a/src/renderers/webgl/objects/label/utils.ts +++ /dev/null @@ -1,240 +0,0 @@ -import { MIN_ZOOM } from '../..' -import type { Stroke } from '../../../../types' -import { Text, TextStyle, ITextStyle, IBitmapTextStyle, BitmapFont, LINE_JOIN } from 'pixi.js' - -export type TextAlign = 'left' | 'center' | 'right' | 'justify' -export type FontWeight = 'normal' | 'bold' | 'bolder' | 'lighter' | '100' | '200' | '300' | '400' | '500' | '600' | '700' | '800' | '900' -export type LabelPosition = 'bottom' | 'left' | 'top' | 'right' - -export type LabelBackgroundStyle = { - color: string - opacity?: number - padding?: number | number[] -} - -export type LabelStyle = Partial<{ - fontName: string - fontSize: number - margin: number - wordWrap: number - letterSpacing: number - fontFamily: string - fontWeight: FontWeight - stroke: Stroke - color: string - position: LabelPosition - background: LabelBackgroundStyle -}> - -type _StyleDefaults = 'fontSize' | 'position' | 'fontFamily' | 'fontName' | 'margin' -export type StyleWithDefaults = Omit & { - fontSize: number - position: LabelPosition - fontFamily: string | string[] - fontName: string - margin: number -} - -export const RESOLUTION = 2 -export const STYLE_DEFAULTS = { - FONT_SIZE: 10, - STROKE_THICKNESS: 0, - LETTER_SPACING: 0.5, - WORD_WRAP: false, - MARGIN: 2, - OPACITY: 1, - PADDING: [4, 8] as [number, number], - STROKE: '#FFF', - FONT_NAME: 'Label', - COLOR: '#000000', - ALIGN: 'center' as const, - POSITION: 'bottom' as const, - FONT_WEIGHT: 'normal' as const, - FONT_FAMILY: 'Arial, sans-serif' -} - -// install text defaults -Text.defaultResolution = RESOLUTION -Text.defaultAutoResolution = false -TextStyle.defaultStyle = { - ...TextStyle.defaultStyle, - align: STYLE_DEFAULTS.ALIGN, - fill: STYLE_DEFAULTS.COLOR, - stroke: STYLE_DEFAULTS.STROKE, - lineJoin: LINE_JOIN.ROUND, - wordWrap: STYLE_DEFAULTS.WORD_WRAP, - fontSize: STYLE_DEFAULTS.FONT_SIZE, - fontFamily: STYLE_DEFAULTS.FONT_FAMILY, - strokeThickness: STYLE_DEFAULTS.STROKE_THICKNESS, - letterSpacing: STYLE_DEFAULTS.LETTER_SPACING -} - -// utils -const mergeDefaults = ({ - position = STYLE_DEFAULTS.POSITION, - fontSize = STYLE_DEFAULTS.FONT_SIZE, - fontFamily = STYLE_DEFAULTS.FONT_FAMILY, - fontName = STYLE_DEFAULTS.FONT_NAME, - margin = STYLE_DEFAULTS.MARGIN, - ...style -}: LabelStyle = {}): StyleWithDefaults => ({ - position, - fontSize, - fontFamily, - fontName, - margin, - ...style -}) - -const mergeBackgroundDefaults = ({ - color, - opacity = STYLE_DEFAULTS.OPACITY, - padding = STYLE_DEFAULTS.PADDING -}: LabelBackgroundStyle): Required => ({ - color, - opacity, - padding -}) - -const isASCII = (str: string) => { - for (const char of str) { - if (char.codePointAt(0)! > 126) { - return false - } - } - - return true -} - -const getTextAlign = (position: LabelPosition): TextAlign => { - return position === 'left' || position === 'right' ? position : 'center' -} - -const getAnchorPoint = (position: LabelPosition): [x: number, y: number] => { - switch (position) { - case 'bottom': - return [0.5, 0] - case 'left': - return [1, 0.5] - case 'top': - return [0.5, 1] - case 'right': - return [0, 0.5] - } -} - -const getTextStyle = ({ color, fontFamily, fontSize, fontWeight, wordWrap, stroke, position, letterSpacing }: StyleWithDefaults) => { - const style: Partial = {} - if (color !== undefined) { - style.fill = color - } - if (fontFamily !== undefined) { - style.fontFamily = fontFamily - } - if (fontSize !== undefined) { - style.fontSize = fontSize - } - if (fontWeight !== undefined) { - style.fontWeight = fontWeight - } - if (wordWrap !== undefined) { - style.wordWrap = true - style.wordWrapWidth = wordWrap - } - if (stroke !== undefined) { - style.stroke = stroke.color - style.strokeThickness = stroke.width - } - if (position !== STYLE_DEFAULTS.POSITION) { - style.align = getTextAlign(position) - } - if (letterSpacing !== undefined) { - style.letterSpacing = letterSpacing - } - return new TextStyle(style) -} - -const getBitmapStyle = (style: StyleWithDefaults): Partial => ({ - fontName: style.fontName, - fontSize: style.fontSize, - align: getTextAlign(style.position), - letterSpacing: style.letterSpacing ?? STYLE_DEFAULTS.LETTER_SPACING -}) - -const loadFont = (style: StyleWithDefaults) => { - if (BitmapFont.available[style.fontName] === undefined) { - BitmapFont.from(style.fontName, getTextStyle({ ...style, fontSize: style.fontSize * RESOLUTION * MIN_ZOOM }), { - chars: BitmapFont.ASCII - }) - } -} - -const getBackgroundPadding = ( - padding: number | number[] = STYLE_DEFAULTS.PADDING -): [top: number, right: number, bottom: number, left: number] => { - const [top, right = top, bottom = top, left = right]: number[] = typeof padding === 'number' ? [padding] : padding - return [top, right, bottom, left] -} - -const getLabelCoordinates = ( - x: number, - y: number, - offset: number, - isBitmapText: boolean, - { position, background, margin }: StyleWithDefaults -) => { - const shift = margin + offset - const label = { x, y } - const bg = { x, y } - - let top = 0 - let right = 0 - let bottom = 0 - let left = 0 - if (background !== undefined) { - const [t, r, b, l] = getBackgroundPadding(background.padding) - top += t - right += r - bottom += b - left += l - } - - if (isBitmapText && (position === 'left' || position === 'right')) { - label.y -= 1 - bg.y -= 1 - } - - switch (position) { - case 'bottom': - label.y += shift + top - bg.y += shift - break - case 'left': - label.x -= shift + right - bg.x -= shift - break - case 'top': - label.y -= shift + bottom - bg.y -= shift - break - case 'right': - label.x += shift + left - bg.x += shift - break - } - - return { label, bg } -} - -export default { - isASCII, - mergeDefaults, - mergeBackgroundDefaults, - getLabelCoordinates, - getTextAlign, - getAnchorPoint, - getTextStyle, - getBitmapStyle, - loadFont, - getBackgroundPadding -} diff --git a/src/renderers/webgl/objects/lineSegment.ts b/src/renderers/webgl/objects/lineSegment.ts index c7465062..a8f5596d 100644 --- a/src/renderers/webgl/objects/lineSegment.ts +++ b/src/renderers/webgl/objects/lineSegment.ts @@ -1,6 +1,6 @@ import { Container, Sprite, Texture } from 'pixi.js' import { HALF_PI } from '../utils' -import { angle, distance } from '../../..' +import { angle, distance } from '../../../utils/api' export class LineSegment { mounted = false diff --git a/src/renderers/webgl/objects/nodeFill.ts b/src/renderers/webgl/objects/nodeFill.ts index 282c86ca..e5a940a2 100644 --- a/src/renderers/webgl/objects/nodeFill.ts +++ b/src/renderers/webgl/objects/nodeFill.ts @@ -1,6 +1,6 @@ import { Container, Sprite } from 'pixi.js' -import * as Graph from '../../..' -import { CircleTexture } from '../textures/circle' +import type { NodeStyle } from '../../../types' +import CircleTexture from '../textures/CircleTexture' const DEFAULT_NODE_FILL = 0xaaaaaa @@ -11,19 +11,19 @@ export class NodeFill { private container: Container private circleTexture: CircleTexture private radius?: number - private style?: Graph.NodeStyle + private style?: NodeStyle constructor(container: Container, circleTexture: CircleTexture) { this.container = container this.circleTexture = circleTexture - this.fill = new Sprite(this.circleTexture.texture) + this.fill = new Sprite(this.circleTexture.get()) this.fill.anchor.set(0.5) this.fill.visible = false this.container.addChild(this.fill) } - update(x: number, y: number, radius: number, style?: Graph.NodeStyle) { + update(x: number, y: number, radius: number, style?: NodeStyle) { if ((style?.color ?? DEFAULT_NODE_FILL) !== (this.style?.color ?? DEFAULT_NODE_FILL)) { this.fill.tint = style?.color ?? DEFAULT_NODE_FILL } diff --git a/src/renderers/webgl/objects/nodeStrokes.ts b/src/renderers/webgl/objects/nodeStrokes.ts index 434a410b..00243b64 100644 --- a/src/renderers/webgl/objects/nodeStrokes.ts +++ b/src/renderers/webgl/objects/nodeStrokes.ts @@ -1,7 +1,7 @@ import { Container, Sprite } from 'pixi.js' -import { CircleTexture } from '../textures/circle' +import CircleTexture from '../textures/CircleTexture' import { NodeFill } from './nodeFill' -import * as Graph from '../../..' +import type { NodeStyle } from '../../../types' export class NodeStrokes { mounted = false @@ -11,7 +11,7 @@ export class NodeStrokes { private container: Container private circleTexture: CircleTexture private fill: NodeFill - private style?: Graph.NodeStyle + private style?: NodeStyle constructor(container: Container, circleTexture: CircleTexture, fill: NodeFill) { this.container = container @@ -19,7 +19,7 @@ export class NodeStrokes { this.fill = fill } - update(x: number, y: number, radius: number, style?: Graph.NodeStyle) { + update(x: number, y: number, radius: number, style?: NodeStyle) { if (style?.stroke !== this.style?.stroke) { // exit const isMounted = this.mounted @@ -34,7 +34,7 @@ export class NodeStrokes { for (let i = 0; i < style.stroke.length; i++) { this.radius += style.stroke[i].width - const circle = new Sprite(this.circleTexture.texture) + const circle = new Sprite(this.circleTexture.get()) circle.anchor.set(0.5) circle.scale.set(this.radius / this.circleTexture.scaleFactor) circle.tint = style.stroke[i].color diff --git a/src/renderers/webgl/objects/text/Text.ts b/src/renderers/webgl/objects/text/Text.ts new file mode 100644 index 00000000..06b61dd8 --- /dev/null +++ b/src/renderers/webgl/objects/text/Text.ts @@ -0,0 +1,317 @@ +import type { Bounds, TextStyle, TextObject, FontWeight } from '../../../../types' +import { BitmapText, Container, Text as PixiText } from 'pixi.js' +import { equals } from '../../../../utils/api' +import TextTexture, { TextTextureOptions } from '../../textures/TextTexture' +import AssetManager, { FontSubscription } from '../../loaders/AssetManager' +import TextHighlight from './TextHighlight' + +export default class Text { + mounted = false + + offset = 0 + style: TextTexture + + private x = 0 + private y = 0 + private transformed = false + + private object: TextObject + private highlight?: TextHighlight + private font?: FontSubscription + private _rect: Bounds + + constructor( + private assets: AssetManager, + private container: Container, + private content: string, + style: TextStyle | undefined, + options?: TextTextureOptions + ) { + this.assets = assets + this.container = container + this.content = content + this.style = new TextTexture(style, options) + + const { fontFamily, fontWeight } = this.style + if (this.assets.shouldLoadFont({ fontFamily, fontWeight })) { + this.style.fontLoading = true + } + + this.object = this.create() + this._rect = this.getRect() + this.applyHighlight() + + if (this.style.fontLoading) { + this.font = this.loadFont(fontFamily, fontWeight) + } + } + + update(content: string, style: TextStyle | undefined) { + const contentHasChanged = this.content !== content + const styleHasChanged = !this.style.compare(style) + const prevSpacing = [this.style.margin, this.style.highlight?.padding ?? 0] + + this.cancel() + this.style.update(style) + + const { fontFamily, fontWeight } = this.style + if (this.assets.shouldLoadFont({ fontFamily, fontWeight })) { + this.style.fontLoading = true + this.font = this.loadFont(fontFamily, fontWeight) + } + + if (contentHasChanged) { + this.content = content + this.object.text = content + + const isBitmapText = this.isBitmapText() + const isASCII = TextTexture.isASCII(content) + + // if the text type has changed, regenerate a new text object + if ((isBitmapText && !isASCII) || (!isBitmapText && isASCII)) { + this.transformText() + } + } + + if (styleHasChanged) { + this.applyHighlight() + if (!this.transformed) { + this.applyStyle() + } + } + + const nextSpacing = [this.style.margin, this.style.highlight?.padding ?? 0] + const sizeHasChanged = contentHasChanged || !equals(prevSpacing, nextSpacing) + + if (contentHasChanged || sizeHasChanged) { + this._rect = this.getRect() + } + + if (this.highlight && (this.transformed || sizeHasChanged)) { + this.highlight.resize(...this.size) + } + + this.transformed = false + + return this + } + + moveTo(_x: number, _y: number) { + const { position, margin } = this.style + + let [x, y] = this.offsetPosition(_x, _y, this.offset + margin) + + if (this.highlight) { + this.highlight.moveTo(x, y) + + const [px, py] = this.style.getHighlightPadding() + + if (position === 'center') { + x -= px + y -= py + } else { + const [nextX, nextY] = this.offsetPosition(x, y, position === 'bottom' || position === 'top' ? py : px) + x = nextX + y = nextY + } + } + + if (this.isBitmapText()) { + y -= position === 'bottom' ? 1 : 2 + } + + if (this.x !== x) { + this.x = x + this.object.x = x + } + + if (this.y !== y) { + this.y = y + this.object.y = y + } + + return this + } + + mount() { + if (!this.mounted) { + this.highlight?.mount() + this.container.addChild(this.object) + this.mounted = true + } + + return this + } + + unmount() { + if (this.mounted) { + this.highlight?.unmount() + this.container.removeChild(this.object) + this.mounted = false + } + + return this + } + + delete() { + this.unmount() + this.object.destroy() + if (!this.transformed) { + this.highlight?.delete() + this.cancel() + } + + return undefined + } + + cancel() { + this.font?.unsubscribe() + this.font = undefined + } + + get rotation() { + return this.object.rotation + } + + set rotation(rotation: number) { + this.object.rotation = rotation + if (this.highlight) { + this.highlight.rotation = rotation + } + } + + get rect() { + return this._rect + } + + private get size(): [width: number, height: number] { + const [px, py] = this.style.getHighlightPadding() + return [this.object.width + px * 2, this.object.height + py * 2] + } + + private isBitmapText(object: TextObject = this.object): object is BitmapText { + return object instanceof BitmapText + } + + private create() { + let object: TextObject + + if (TextTexture.isASCII(this.content)) { + this.style.createFont() + object = new BitmapText(this.content, this.style.getBitmapStyle()) + } else { + object = new PixiText(this.content, this.style.getTextStyle()) + } + + object.anchor.set(...this.style.anchor) + object.resolution = this.style.resolution + return object + } + + private transformText() { + const rotation = this.object.rotation + + this.transformed = true + const isMounted = this.mounted + + this.delete() + this.object = this.create() + this.object.x = this.x + this.object.y = this.y + this.object.rotation = rotation + + if (this.highlight) { + this.highlight.text = this.object + } + + if (isMounted) { + this.mount() + } + } + + private applyStyle() { + this.object.anchor.set(...this.style.anchor) + this.highlight?.anchor.set(...this.style.anchor) + + if (!this.isBitmapText(this.object)) { + this.object.style.stroke = this.style.stroke.color + this.object.style.strokeThickness = this.style.stroke.width + this.object.style.wordWrap = this.style.wordWrap + this.object.style.wordWrapWidth = this.style.wordWrapWidth + this.object.style.fontWeight = this.style.fontWeight + this.object.style.fill = this.style.color + this.object.style.letterSpacing = this.style.letterSpacing + this.object.style.align = this.style.align + this.object.style.fontSize = this.style.fontSize + this.object.style.fontFamily = this.style.fontFamily + } else { + this.transformText() + } + + return this + } + + private applyHighlight() { + if (!this.highlight && this.style.highlight) { + this.highlight = new TextHighlight(this.container, this.object, this.style.highlight).resize(...this.size) + } else if (this.highlight && this.style.highlight) { + this.highlight.update(this.style.highlight) + } else if (this.highlight && !this.style.highlight) { + this.highlight.delete() + this.highlight = undefined + } + + return this + } + + private offsetPosition(x: number, y: number, offset: number): [x: number, y: number] { + switch (this.style.position) { + case 'bottom': + return [x, y + offset] + case 'left': + return [x - offset, y] + case 'top': + return [x, y - offset] + case 'right': + return [x + offset, y] + default: + return [x, y] + } + } + + private getRect(): Bounds { + /** + * This rect defines the min/max distance away from its reference; it does not represent the label's exact position. + * This should only be recalculated when the size could have changed. i.e. content, margin, or background padding updates + */ + const empty = { top: 0, right: 0, bottom: 0, left: 0 } + const offset = this.offset + this.style.margin + const position = this.style.position + + const [width, height] = this.size + const [hx, hy] = [width / 2, height / 2] + + switch (position) { + case 'top': + case 'bottom': + return { ...empty, left: hx, right: hx, [position]: offset + height } + case 'left': + case 'right': + return { ...empty, top: hy, bottom: hy, [position]: offset + width } + case 'center': + return { top: hy, right: hx, bottom: hy, left: hx } + } + } + + private loadFont(fontFamily: string, fontWeight: FontWeight) { + return this.assets.loadFont({ + fontFamily, + fontWeight, + resolve: () => { + this.font = undefined + this.style.fontLoading = false + this.applyStyle() + } + }) + } +} diff --git a/src/renderers/webgl/objects/text/TextHighlight.ts b/src/renderers/webgl/objects/text/TextHighlight.ts new file mode 100644 index 00000000..c96ce704 --- /dev/null +++ b/src/renderers/webgl/objects/text/TextHighlight.ts @@ -0,0 +1,116 @@ +import { Container, Sprite, Texture } from 'pixi.js' +import { FillStyle, TextObject } from '../../../../types' + +export default class TextHighlight { + mounted = false + + private x = 0 + private y = 0 + private object: Sprite + private width: number + private height: number + + constructor( + private container: Container, + private textObject: TextObject, + private style: Required + ) { + this.container = container + this.textObject = textObject + this.style = style + this.width = this.text.width + this.height = this.text.height + this.object = this.create() + } + + update(style: Required) { + this.style = style + this.object.tint = style.color + this.object.alpha = style.opacity + + return this + } + + resize(width: number, height: number) { + if (this.width !== width) { + this.width = width + this.object.width = width + } + + if (this.height !== height) { + this.height = height + this.object.height = height + } + + return this + } + + moveTo(x: number, y: number) { + if (this.x !== x) { + this.x = x + this.object.x = x + } + + if (this.y !== y) { + this.y = y + this.object.y = y + } + + return this + } + + mount() { + if (!this.mounted) { + this.container.addChild(this.object) + this.mounted = true + } + + return this + } + + unmount() { + if (this.mounted) { + this.container.removeChild(this.object) + this.mounted = false + } + + return this + } + + delete() { + this.unmount() + this.object.destroy() + + return undefined + } + + get text() { + return this.textObject + } + + set text(text: TextObject) { + this.textObject = text + } + + get anchor() { + return this.object.anchor + } + + get rotation() { + return this.object.rotation + } + + set rotation(rotation: number) { + this.object.rotation = rotation + } + + private create() { + const object = Sprite.from(Texture.WHITE) + object.width = this.width + object.height = this.height + object.anchor.set(this.text.anchor.x, this.text.anchor.y) + object.alpha = this.style.opacity + object.tint = this.style.color + return object + } +} diff --git a/src/renderers/webgl/textures/arrow.ts b/src/renderers/webgl/textures/ArrowTexture.ts similarity index 67% rename from src/renderers/webgl/textures/arrow.ts rename to src/renderers/webgl/textures/ArrowTexture.ts index 44021223..7d11d113 100644 --- a/src/renderers/webgl/textures/arrow.ts +++ b/src/renderers/webgl/textures/ArrowTexture.ts @@ -1,11 +1,10 @@ import { RenderTexture, Graphics, Matrix, MSAA_QUALITY, Renderer as PixiRenderer } from 'pixi.js' -import { MIN_ZOOM, Renderer } from '..' +import { MIN_TEXTURE_ZOOM } from '../../../utils/constants' +import { Texture } from '../../../types' +import { Renderer } from '..' -export class ArrowTexture { - texture: RenderTexture - height = 12 // TODO - make configurable - width = 6 // TODO - make configurable - scaleFactor = MIN_ZOOM // minZoom -- TODO make configurable +export default class ArrowTexture implements Texture { + private texture: RenderTexture constructor(renderer: Renderer) { const graphic = new Graphics() @@ -32,7 +31,21 @@ export class ArrowTexture { graphic.destroy(true) } + get() { + return this.texture + } delete() { this.texture.destroy() } + + // TODO -> make configurable + get scaleFactor() { + return MIN_TEXTURE_ZOOM + } + get height() { + return 12 + } + get width() { + return 6 + } } diff --git a/src/renderers/webgl/textures/circle.ts b/src/renderers/webgl/textures/CircleTexture.ts similarity index 65% rename from src/renderers/webgl/textures/circle.ts rename to src/renderers/webgl/textures/CircleTexture.ts index f540ee0d..e1c6b5b1 100644 --- a/src/renderers/webgl/textures/circle.ts +++ b/src/renderers/webgl/textures/CircleTexture.ts @@ -1,9 +1,10 @@ import { RenderTexture, Graphics, Matrix, MSAA_QUALITY, Renderer as PixiRenderer } from 'pixi.js' -import { MIN_ZOOM, Renderer } from '..' +import { MIN_TEXTURE_ZOOM } from '../../../utils/constants' +import { Texture } from '../../../types' +import { Renderer } from '..' -export class CircleTexture { - texture: RenderTexture - scaleFactor = 10 * MIN_ZOOM // maxRadius * minZoom -- TODO make configurable +export default class CircleTexture implements Texture { + private texture: RenderTexture constructor(renderer: Renderer) { const graphic = new Graphics().beginFill(0xffffff).drawCircle(0, 0, this.scaleFactor) @@ -27,7 +28,18 @@ export class CircleTexture { graphic.destroy(true) } + get() { + return this.texture + } + delete() { this.texture.destroy() + return undefined + } + + // TODO -> make configurable + get scaleFactor() { + // maxRadius * minZoom + return 10 * MIN_TEXTURE_ZOOM } } diff --git a/src/renderers/webgl/textures/TextIconTexture.ts b/src/renderers/webgl/textures/TextIconTexture.ts new file mode 100644 index 00000000..07d7be2f --- /dev/null +++ b/src/renderers/webgl/textures/TextIconTexture.ts @@ -0,0 +1,71 @@ +import { RenderTexture, Text as PixiText, MSAA_QUALITY, Matrix, Renderer as PixiRenderer } from 'pixi.js' +import { DEFAULT_RESOLUTION, DEFAULT_TEXT_STYLE, MIN_TEXTURE_ZOOM } from '../../../utils/constants' +import { TextIcon, Texture } from '../../../types' +import { Renderer } from '..' +import TextTexture from './TextTexture' + +const getCacheKey = ({ content, style = {} }: TextIcon) => { + const { color, stroke, fontFamily, fontSize, fontWeight } = { ...style, ...DEFAULT_TEXT_STYLE } + return [content, color, stroke.color, stroke.width, fontFamily, fontSize, fontWeight].join('-') +} + +export default class TextIconTexture implements Texture { + protected cache: { [key: string]: RenderTexture } = {} + + constructor(private renderer: Renderer) { + this.renderer = renderer + } + + get(icon: TextIcon) { + const key = getCacheKey(icon) + + if (this.cache[key] === undefined) { + this.cache[key] = this.createTexture(icon) + } + + return this.cache[key] + } + + delete() { + for (const key in this.cache) { + this.cache[key].destroy() + } + + this.cache = {} + return undefined + } + + // TODO -> intergrate with renderer options + get scaleFactor() { + return MIN_TEXTURE_ZOOM + } + get resolution() { + return DEFAULT_RESOLUTION + } + + private createTexture(icon: TextIcon) { + const style = new TextTexture(icon.style, { defaultTextStyle: { align: 'center' } }) + style.fontSize = style.fontSize * this.scaleFactor + + const object = new PixiText(icon.content, style.getTextStyle()) + + object.updateText(true) + + const renderTexture = RenderTexture.create({ + width: object.width, + height: object.height, + multisample: MSAA_QUALITY.HIGH, + resolution: this.resolution + }) + + this.renderer.app.renderer.render(object, { renderTexture, transform: new Matrix() }) + + if (this.renderer.app.renderer instanceof PixiRenderer) { + this.renderer.app.renderer.framebuffer.blit() + } + + object.destroy(true) + + return renderTexture + } +} diff --git a/src/renderers/webgl/textures/TextTexture.ts b/src/renderers/webgl/textures/TextTexture.ts new file mode 100644 index 00000000..9d09e9b0 --- /dev/null +++ b/src/renderers/webgl/textures/TextTexture.ts @@ -0,0 +1,203 @@ +import type { TextStyle, TextHighlightStyle, TextAlign, Stroke, FontWeight, AnchorPosition } from '../../../types/api' +import { TextStyle as PixiTextStyle, ITextStyle as IPixiTextStyle, BitmapFont, IBitmapTextStyle, LINE_JOIN } from 'pixi.js' +import { DEFAULT_HIGHLIGHT_STYLE, DEFAULT_TEXT_STYLE, MIN_TEXTURE_ZOOM, DEFAULT_RESOLUTION } from '../../../utils/constants' +import { isNumber } from '../../../utils/helpers' +import { equals } from '../../../utils/api' + +export type TextTextureOptions = { + defaultTextStyle?: Omit + defaultHighlightStyle?: Partial +} + +export type DefaultTextStyle = Required> + +PixiTextStyle.defaultStyle.lineJoin = LINE_JOIN.ROUND +PixiTextStyle.defaultStyle.textBaseline = 'alphabetic' + +export default class TextTexture { + private defaultTextStyle: DefaultTextStyle = DEFAULT_TEXT_STYLE + private defaultHighlightStyle: Required = DEFAULT_HIGHLIGHT_STYLE + + color: string + stroke: Stroke + fontName: string + fontSize: number + fontFamily: string + fontWeight: FontWeight + letterSpacing: number + margin: number + align: TextAlign + position: AnchorPosition + highlight?: Required + + private _wordWrap: number | false = DEFAULT_TEXT_STYLE.wordWrap + + // TODO -> make configurable + maxFontSize = 16 + resolution = DEFAULT_RESOLUTION + scaleFactor = MIN_TEXTURE_ZOOM + chars = BitmapFont.ASCII + + private _style: TextStyle | undefined + private _fontLoading = false + + static isASCII(str: string) { + for (const char of str) { + if (char.codePointAt(0)! > 126) { + return false + } + } + + return true + } + + constructor(style: TextStyle | undefined, options?: TextTextureOptions) { + this.defaultTextStyle = Object.assign(this.defaultTextStyle, options?.defaultTextStyle) + this.defaultHighlightStyle = Object.assign(this.defaultHighlightStyle, options?.defaultHighlightStyle) + + this.color = this.defaultTextStyle.color + this.stroke = this.defaultTextStyle.stroke + this.fontName = this.defaultTextStyle.fontName + this.fontSize = this.defaultTextStyle.fontSize + this.fontFamily = this.defaultTextStyle.fontFamily + this.fontWeight = this.defaultTextStyle.fontWeight + this.letterSpacing = this.defaultTextStyle.letterSpacing + this.margin = this.defaultTextStyle.margin + this.align = this.defaultTextStyle.align + this.position = this.defaultTextStyle.position + + this.update(style) + } + + compare(style: TextStyle | undefined) { + return equals(this._style, style) + } + + update(style: TextStyle | undefined) { + this._style = style + this._fontLoading = false + + Object.assign(this, this.defaultTextStyle, style) + + if (style?.align === undefined && this.position !== 'center') { + this.align = this.position === 'left' || this.position === 'right' ? this.position : 'center' + } + + if (style?.highlight !== undefined) { + this.highlight = Object.assign(this.defaultHighlightStyle, style.highlight) + } else { + this.highlight = undefined + } + + return this + } + + get original(): TextStyle | undefined { + return this._style + } + + get lineHeight(): number { + return this.fontSize * 1.3 + } + + set wordWrap(wordWrap: number | false) { + this._wordWrap = wordWrap + } + + get wordWrap(): boolean { + return isNumber(this._wordWrap) + } + + get wordWrapWidth(): number { + if (isNumber(this._wordWrap)) { + return this._wordWrap + } else { + return PixiTextStyle.defaultStyle.wordWrapWidth + } + } + + get anchor(): [x: number, y: number] { + switch (this.position) { + case 'bottom': + return [0.5, 0] + case 'left': + return [1, 0.5] + case 'top': + return [0.5, 1] + case 'right': + return [0, 0.5] + default: + return [0.5, 0.5] + } + } + + get fontLoading() { + return this._fontLoading + } + + set fontLoading(loading: boolean) { + this._fontLoading = loading + + if (loading) { + this.fontName = `LoadingFont:${this.fontName}` + this.fontFamily = this.defaultTextStyle.fontFamily + } else { + this.fontName = this._style?.fontName ?? this.defaultTextStyle.fontName + this.fontFamily = this._style?.fontFamily ?? this.defaultTextStyle.fontFamily + } + } + + getTextStyle(): Partial { + return { + fill: this.color, + align: this.align, + fontSize: this.fontSize, + fontWeight: this.fontWeight, + fontFamily: this.fontFamily, + letterSpacing: this.letterSpacing, + stroke: this.stroke.color, + strokeThickness: this.stroke.width, + wordWrap: isNumber(this.wordWrap), + wordWrapWidth: this.wordWrapWidth, + lineHeight: this.lineHeight + } + } + + getBitmapStyle(): Partial { + return { + align: this.align, + fontName: this.fontName, + fontSize: this.fontSize, + letterSpacing: this.letterSpacing + } + } + + getHighlightPadding(): [py: number, px: number] { + const padding = this.highlight?.padding ?? 0 + return isNumber(padding) ? [padding, padding] : padding + } + + findFont(fontName = this.fontName): BitmapFont | undefined { + return BitmapFont.available[fontName] + } + + createFont() { + const font = this.findFont() + + if (font === undefined) { + const fontSize = this.maxFontSize * this.resolution * this.scaleFactor + + return BitmapFont.from( + this.fontName, + { ...this.getTextStyle(), fontSize, lineHeight: fontSize * 1.3 }, + { chars: this.chars, resolution: this.resolution } + ) + } + + return font + } + + destroyFont(fontName: string) { + this.findFont(fontName)?.destroy() + } +} diff --git a/src/renderers/webgl/textures/font.ts b/src/renderers/webgl/textures/font.ts deleted file mode 100644 index a26eb43f..00000000 --- a/src/renderers/webgl/textures/font.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { BitmapFont } from 'pixi.js' -import { MIN_ZOOM } from '..' - -export class Font { - private font: BitmapFont - - constructor(fontFamily = 'sans-serif', maxFontSize = 10, minZoom = MIN_ZOOM, strokeThickness = 1.5) { - this.font = BitmapFont.from( - 'Label', - { - fontFamily, - fontSize: maxFontSize * 2 * minZoom, // max font size * retina * minZoom - fill: 0x000000, - stroke: 0xffffff, - strokeThickness: strokeThickness * 2 * minZoom // strokeThickness * retina * minZoom - }, - { chars: BitmapFont.ASCII } - ) - } - - delete() { - this.font.destroy() - } -} diff --git a/src/renderers/webgl/textures/textIcon.ts b/src/renderers/webgl/textures/textIcon.ts deleted file mode 100644 index 10b683f0..00000000 --- a/src/renderers/webgl/textures/textIcon.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { RenderTexture, Text, Matrix, MSAA_QUALITY, Renderer as PixiRenderer, TextStyleFontWeight } from 'pixi.js' -import { MIN_ZOOM, Renderer } from '..' - -// TODO - wait for text icon font family to load -export class TextIconTexture { - renderer: Renderer - scaleFactor = MIN_ZOOM // minZoom -- TODO make configurable - - private cache: { [icon: string]: RenderTexture } = {} - - constructor(renderer: Renderer) { - this.renderer = renderer - } - - create(text: string, fontFamily: string, fontSize: number, fontWeight: TextStyleFontWeight, fill: string | number) { - const icon = `${text}-${fontFamily}-${fontSize}-${fontWeight}-${fill}` - - if (this.cache[icon] === undefined) { - const textObject = new Text(text, { - fontFamily, - fontSize: fontSize * this.scaleFactor, - fontWeight, - fill - }) - textObject.updateText(true) - - const texture = RenderTexture.create({ - width: textObject.width, - height: textObject.height, - multisample: MSAA_QUALITY.HIGH, - resolution: 2 - }) - - this.renderer.app.renderer.render(textObject, { - renderTexture: texture, - transform: new Matrix(1, 0, 0, 1, 0, 0) - }) - - if (this.renderer.app.renderer instanceof PixiRenderer) { - this.renderer.app.renderer.framebuffer.blit() - } - - textObject.destroy(true) - - this.cache[icon] = texture - } - - return this.cache[icon] - } - - delete() { - for (const key in this.cache) { - this.cache[key].destroy() - } - this.cache = {} - } -} diff --git a/src/renderers/webgl/utils.ts b/src/renderers/webgl/utils.ts index 4e609763..f96be923 100644 --- a/src/renderers/webgl/utils.ts +++ b/src/renderers/webgl/utils.ts @@ -1,5 +1,5 @@ /* eslint-disable no-console */ -import * as Graph from '../..' +import type { Node } from '../../types' export const HALF_PI = Math.PI / 2 @@ -9,7 +9,7 @@ export const THREE_HALF_PI = HALF_PI * 3 export const RADIANS_PER_DEGREE = Math.PI / 180 -export const logUnknownEdgeError = (source: Graph.Node | undefined, target: Graph.Node | undefined) => { +export const logUnknownEdgeError = (source: Node | undefined, target: Node | undefined) => { if (source === undefined && target === undefined) { console.error(`Error: Cannot render edge between unknown nodes ${source} and ${target}`) } else if (source === undefined) { @@ -19,9 +19,11 @@ export const logUnknownEdgeError = (source: Graph.Node | undefined, target: Grap } } -export const movePoint = (x: number, y: number, angle: number, distance: number) => - [x + Math.cos(angle) * distance, y + Math.sin(angle) * distance] as const +export const movePoint = (x: number, y: number, angle: number, distance: number): [x: number, y: number] => [ + x + Math.cos(angle) * distance, + y + Math.sin(angle) * distance +] -export const midPoint = (x0: number, y0: number, x1: number, y1: number) => [(x0 + x1) / 2, (y0 + y1) / 2] as const +export const midPoint = (x0: number, y0: number, x1: number, y1: number): [x: number, y: number] => [(x0 + x1) / 2, (y0 + y1) / 2] export const length = (x0: number, y0: number, x1: number, y1: number) => Math.hypot(x1 - x0, y1 - y0) diff --git a/src/types.ts b/src/types.ts deleted file mode 100644 index cbf92303..00000000 --- a/src/types.ts +++ /dev/null @@ -1,8 +0,0 @@ -export type Extend = { - [K in Exclude]: T[K] -} & R - -export type Stroke = { - color: string - width: number -} diff --git a/src/types/api.ts b/src/types/api.ts new file mode 100644 index 00000000..78aaae54 --- /dev/null +++ b/src/types/api.ts @@ -0,0 +1,151 @@ +// graph +export type Bounds = { left: number; top: number; right: number; bottom: number } + +export type Dimensions = { width: number; height: number } + +export type Viewport = { x: number; y: number; zoom: number } + +// style +export type FillStyle = { color: string; opacity?: number } + +export type Stroke = { color: string; width: number } + +export type FontWeight = 'normal' | 'bold' | 'bolder' | 'lighter' | '100' | '200' | '300' | '400' | '500' | '600' | '700' | '800' | '900' + +export type TextAlign = 'left' | 'center' | 'right' | 'justify' + +export type AnchorPosition = 'bottom' | 'left' | 'top' | 'right' | 'center' + +export type TextHighlightStyle = FillStyle & { + padding?: number | [px: number, py: number] +} + +export type TextStyle = Partial<{ + color: string + margin: number + stroke: Stroke + fontName: string + fontSize: number + fontFamily: string + letterSpacing: number + wordWrap: number | false + fontWeight: FontWeight + highlight: TextHighlightStyle + position: AnchorPosition + align: TextAlign +}> + +export type LabelStyle = Omit & { + position?: Exclude +} + +// icons +type IconBase = { + type: T + offset?: { x?: number; y?: number } +} + +export type ImageIcon = IconBase<'imageIcon'> & { + url: string + scale?: number +} + +export type TextIcon = IconBase<'textIcon'> & { + content: string + style?: Pick +} + +export type IconStyle = ImageIcon | TextIcon + +// nodes +export type NodeStyle = { + color?: string + icon?: IconStyle + stroke?: Stroke[] + badge?: { + position: number + radius: number + color: string + stroke?: string + strokeWidth?: number + icon?: IconStyle + }[] + label?: LabelStyle +} + +export type Node = { + id: string + radius: number + x?: number + y?: number + fx?: number + fy?: number + label?: string + style?: NodeStyle + subgraph?: { + nodes: Node[] + edges: Edge[] + options?: {} + } +} + +// edges +export type ArrowStyle = 'forward' | 'reverse' | 'both' | 'none' + +export type EdgeLabelStyle = LabelStyle & { + position?: Exclude +} + +export type EdgeStyle = { + width?: number + stroke?: string + strokeOpacity?: number + arrow?: ArrowStyle + label?: EdgeLabelStyle +} + +export type Edge = { + id: string + source: string + target: string + label?: string + style?: EdgeStyle +} + +// annotations +export type AnnotationStyle = FillStyle & { + stroke?: Stroke[] +} + +export type TextAnnotationStyle = AnnotationStyle & { + text?: Omit + padding?: number | [px: number, py: number] +} + +type AnnotationBase = { + type: Type + id: string + x: number + y: number + resize?: boolean +} + +export type CircleAnnotation = AnnotationBase<'circle'> & { + radius: number + style: AnnotationStyle +} + +export type RectangleAnnotation = AnnotationBase<'rectangle'> & { + width: number + height: number + style: AnnotationStyle +} + +export type TextAnnotation = AnnotationBase<'text'> & { + width: number + height: number + content: string + style: TextAnnotationStyle +} + +export type Annotation = CircleAnnotation | RectangleAnnotation | TextAnnotation diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 00000000..b01c65ab --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1,2 @@ +export * from './api' +export * from './internal' diff --git a/src/types/internal.ts b/src/types/internal.ts new file mode 100644 index 00000000..d31e657e --- /dev/null +++ b/src/types/internal.ts @@ -0,0 +1,19 @@ +import { BitmapText, Text as PixiText, RenderTexture } from 'pixi.js' + +export type Extend = { + [K in Exclude]: T[K] +} & R + +export type TextObject = PixiText | BitmapText + +export type Texture = { + get(...args: unknown[]): RenderTexture + delete(): void +} + +export interface RenderObjectLifecycle { + mounted: boolean + mount(index?: number): this + unmount(): this + delete(): void +} diff --git a/src/utils/api.ts b/src/utils/api.ts new file mode 100644 index 00000000..987211bd --- /dev/null +++ b/src/utils/api.ts @@ -0,0 +1,237 @@ +import { TWO_PI } from '../renderers/webgl/utils' +import type { Node, Annotation, Edge, Bounds, Viewport, Dimensions } from '../types' + +export const getSelectionBounds = (elements: (Node | Annotation)[], padding: number = 0): Bounds => { + let left = 0 + let top = 0 + let right = 0 + let bottom = 0 + + for (const el of elements) { + if ('radius' in el) { + const elementLeft = (el.x ?? 0) - el.radius + const elementTop = (el.y ?? 0) - el.radius + const elementRight = (el.x ?? 0) + el.radius + const elementBottom = (el.y ?? 0) + el.radius + if (elementLeft < left) left = elementLeft + if (elementTop < top) top = elementTop + if (elementRight > right) right = elementRight + if (elementBottom > bottom) bottom = elementBottom + } else if ('width' in el && 'height' in el) { + const elementLeft = el.x ?? 0 + const elementTop = el.y ?? 0 + const elementRight = (el.x ?? 0) + el.width + const elementBottom = (el.x ?? 0) + el.height + if (elementLeft < left) left = elementLeft + if (elementTop < top) top = elementTop + if (elementRight > right) right = elementRight + if (elementBottom > bottom) bottom = elementBottom + } + } + + return { + left: left - padding, + top: top - padding, + right: right + padding, + bottom: bottom + padding + } +} + +export const mergeBounds = (a: Bounds, b: Bounds, padding: number = 0): Bounds => { + return { + left: Math.min(a.left, b.left) - padding, + top: Math.min(a.top, b.top) - padding, + right: Math.max(a.right, b.right) + padding, + bottom: Math.max(a.bottom, b.bottom) + padding + } +} + +export const viewportToBounds = ({ x, y, zoom }: Viewport, { width, height }: Dimensions): Bounds => { + const xOffset = width / 2 / zoom + const yOffset = height / 2 / zoom + return { + left: -(x + xOffset), + top: -(y + yOffset), + right: -(x - xOffset), + bottom: -(y - yOffset) + } +} + +export const boundsToViewport = ({ left, top, right, bottom }: Bounds, { width, height }: Dimensions): Viewport => { + const targetWidth = right - left + const targetHeight = bottom - top + const x = targetWidth / 2 - right + const y = targetHeight / 2 - bottom + + if (targetWidth / targetHeight > width / height) { + // fit to width + return { x, y, zoom: width / targetWidth } + } else { + // fit to height + return { x, y, zoom: height / targetHeight } + } +} + +export const boundsToDimensions = ({ left, top, right, bottom }: Bounds, zoom: number): Dimensions => { + return { + width: (right - left) / zoom, + height: (bottom - top) / zoom + } +} + +export const clamp = (min: number, max: number, value: number) => Math.max(min, Math.min(max, value)) + +export const equals = (a: T, b: T) => { + if (a === b) { + return true + } else if (Array.isArray(a) && Array.isArray(b)) { + if (a.length !== b.length) { + return false + } + + for (let i = 0; i < a.length; i++) { + if (!equals(a[i], b[i])) { + return false + } + } + + return true + } else if (typeof a === 'object' && typeof b === 'object') { + if (Object.keys(a ?? {}).length !== Object.keys(b ?? {}).length) { + return false + } + + for (const key in a) { + if (!equals(a[key], b?.[key])) { + return false + } + } + + return true + } + + return false +} + +export const connectedComponents = (graph: { nodes: N[]; edges: E[] }): { nodes: N[]; edges: E[] }[] => { + const adjacencyList: Record> = Object.create(null) + const nodes: Record = {} + const visited = new Set() + const components: { nodes: Record; edges: Record }[] = [] + + for (const edge of graph.edges) { + if (adjacencyList[edge.source] === undefined) { + adjacencyList[edge.source] = {} + } + if (adjacencyList[edge.source][edge.target] === undefined) { + adjacencyList[edge.source][edge.target] = [] + } + if (adjacencyList[edge.target] === undefined) { + adjacencyList[edge.target] = {} + } + if (adjacencyList[edge.target][edge.source] === undefined) { + adjacencyList[edge.target][edge.source] = [] + } + + adjacencyList[edge.source][edge.target].push(edge) + adjacencyList[edge.target][edge.source].push(edge) + } + + for (const node of graph.nodes) { + nodes[node.id] = node + } + + for (const { id } of graph.nodes) { + if (visited.has(id)) { + continue + } + + visited.add(id) + const toVisit = [id] + const component: { nodes: Record; edges: Record } = { + nodes: { [id]: nodes[id] }, + edges: {} + } + + while (toVisit.length > 0) { + const next = adjacencyList[toVisit.pop()!] + if (next === undefined) { + continue + } + + for (const [adjacentNode, edges] of Object.entries(next)) { + for (const edge of edges) { + component.edges[edge.id] = edge + } + component.nodes[adjacentNode] = nodes[adjacentNode] + + if (!visited.has(adjacentNode)) { + toVisit.push(adjacentNode) + visited.add(adjacentNode) + } + } + } + + components.push(component) + } + + return components.map(({ nodes, edges }) => ({ + nodes: Object.values(nodes), + edges: Object.values(edges) + })) +} + +export function* bfs( + predicate: (node: N) => boolean, + graph: { nodes: N[]; edges: E[] } +): Generator { + const adjacencyList: Record = Object.create(null) + const nodes: Record = {} + const visited = new Set() + const queue = [graph.nodes[0].id] + + for (const edge of graph.edges) { + if (adjacencyList[edge.source] === undefined) { + adjacencyList[edge.source] = [] + } + if (adjacencyList[edge.target] === undefined) { + adjacencyList[edge.target] = [] + } + + adjacencyList[edge.source].push(edge.target) + adjacencyList[edge.target].push(edge.source) + } + + for (const node of graph.nodes) { + nodes[node.id] = node + } + + while (queue.length > 0) { + const node = queue.shift()! + + if (visited.has(node)) { + continue + } + + visited.add(node) + + if (predicate(nodes[node])) { + yield nodes[node] + } + + if (adjacencyList[node]) { + for (const adjacentNode of adjacencyList[node]) { + if (!visited.has(adjacentNode)) { + queue.push(adjacentNode) + } + } + } + } +} + +export const distance = (x0: number, y0: number, x1: number, y1: number) => Math.hypot(x1 - x0, y1 - y0) + +export const angle = (x0: number, y0: number, x1: number, y1: number) => { + const angle = Math.atan2(y0 - y1, x0 - x1) + return angle < 0 ? angle + TWO_PI : angle +} diff --git a/src/utils/constants.ts b/src/utils/constants.ts new file mode 100644 index 00000000..8e8bd810 --- /dev/null +++ b/src/utils/constants.ts @@ -0,0 +1,43 @@ +// zoom limits +// TODO - extends to renderer options +export const MIN_LABEL_ZOOM = 0.25 +export const MIN_NODE_STROKE_ZOOM = 0.3 +export const MIN_NODE_ICON_ZOOM = 0.3 +export const MIN_INTERACTION_ZOOM = 0.15 +export const MIN_EDGES_ZOOM = 0.1 +export const MIN_TEXTURE_ZOOM = 3 + +// style +export const DEFAULT_RESOLUTION = 2 +export const DEFAULT_OPACITY = 1 + +export const COLORS = { + BLACK: '#000000', + WHITE: '#FFFFFF' +} + +export const DEFAULT_TEXT_STYLE = { + margin: 2, + fontSize: 10, + color: COLORS.BLACK, + letterSpacing: 0.5, + fontName: 'Font', + fontFamily: 'sans-serif', + align: 'left' as const, + wordWrap: false as const, + position: 'center' as const, + fontWeight: 'normal' as const, + stroke: { color: COLORS.WHITE, width: 0 } +} + +export const DEFAULT_LABEL_STYLE = { + defaultTextStyle: { position: 'bottom' as const, align: 'center' as const } +} + +export const DEFAULT_HIGHLIGHT_STYLE = { + color: COLORS.WHITE, + opacity: DEFAULT_OPACITY, + padding: [8, 4] as [number, number] +} + +export const GENERIC_FONT_FAMILIES = new Set(['serif', 'sans-serif', 'monospace', 'cursive']) diff --git a/src/utils.ts b/src/utils/helpers.ts similarity index 97% rename from src/utils.ts rename to src/utils/helpers.ts index 1b959f3d..fd64f81c 100644 --- a/src/utils.ts +++ b/src/utils/helpers.ts @@ -227,3 +227,6 @@ export const Async = (executor: Executor) => { } } } + +export const isNumber = (value: unknown): value is number => typeof value === 'number' +export const isString = (value: unknown): value is string => typeof value === 'string'