From 1e263d562e5ab9eedf4351f2e48b5e2c678e74ca Mon Sep 17 00:00:00 2001 From: Nut He <18328704+hetao92@users.noreply.github.com> Date: Tue, 15 Mar 2022 18:06:07 +0800 Subject: [PATCH] feat: update output graph (#143) mod: code review mod: code review mod: code review --- .eslintrc.js | 76 +- app/app.less | 2 +- app/components/Button/index.less | 18 + app/components/Button/index.tsx | 59 + app/components/ColorPicker/index.less | 14 + app/components/ColorPicker/index.tsx | 39 + .../ForceGraph/canvas-force-graph.js | 545 +++++++++ app/components/ForceGraph/color-tracker.js | 31 + app/components/ForceGraph/color-utils.js | 17 + app/components/ForceGraph/dagDepths.js | 51 + app/components/ForceGraph/force-graph.css | 23 + app/components/ForceGraph/force-graph.js | 702 ++++++++++++ app/components/ForceGraph/index.js | 2 + app/components/ForceGraph/kapsule-link.js | 34 + app/config/explore.ts | 155 +-- app/config/locale/en-US.json | 2 +- app/config/locale/zh-CN.json | 2 +- app/global.d.ts | 225 ++++ app/index.html | 2 +- app/index.tsx | 6 +- app/interfaces/graph.ts | 13 + app/pages/Console/FavoriteBtn.tsx | 2 +- app/pages/Console/HistoryBtn.tsx | 4 +- .../OutputBox/ForceGraph/BrushSelect/index.ts | 173 +++ .../DisplayPanel/ExpandForm/index.less | 34 + .../DisplayPanel/ExpandForm/index.tsx | 109 ++ .../DisplayPanel/ExpandItem/index.less | 62 + .../DisplayPanel/ExpandItem/index.tsx | 94 ++ .../ForceGraph/DisplayPanel/index.less | 30 + .../ForceGraph/DisplayPanel/index.tsx | 32 + .../ForceGraph/Menu/ColorChangeBtn/index.less | 21 + .../ForceGraph/Menu/ColorChangeBtn/index.tsx | 60 + .../Menu/NodeStyleSetBtn/index.less | 123 ++ .../ForceGraph/Menu/NodeStyleSetBtn/index.tsx | 85 ++ .../OutputBox/ForceGraph/Menu/index.less | 73 ++ .../OutputBox/ForceGraph/Menu/index.tsx | 60 + .../OutputBox/ForceGraph/OperationPanel.tsx | 34 + .../OutputBox/ForceGraph/Tootip/index.less | 64 ++ .../OutputBox/ForceGraph/Tootip/index.tsx | 127 +++ .../Console/OutputBox/ForceGraph/index.less | 32 + .../Console/OutputBox/ForceGraph/index.tsx | 69 ++ app/pages/Console/OutputBox/index.less | 60 +- app/pages/Console/OutputBox/index.tsx | 26 +- app/pages/Console/index.tsx | 15 +- app/pages/Import/FileUpload/index.tsx | 2 +- .../Import/TaskCreate/SchemaConfig/index.tsx | 4 +- app/pages/Import/TaskCreate/index.tsx | 8 +- .../TaskList/TaskItem/LogModal/index.tsx | 4 +- .../Import/TaskList/TemplateModal/index.tsx | 4 +- app/pages/Import/TaskList/index.tsx | 8 +- app/pages/Login/index.tsx | 2 +- app/pages/MainPage/Header/index.tsx | 4 +- .../Create/CommonCreate/index.tsx | 6 +- .../SchemaConfig/Create/IndexCreate/index.tsx | 4 +- .../Edit/CommonEdit/PropertiesForm.tsx | 4 +- .../SchemaConfig/Edit/CommonEdit/index.tsx | 12 +- .../SchemaConfig/List/CommonLayout/index.tsx | 2 +- .../Schema/SchemaConfig/List/Edge/index.tsx | 4 +- .../Schema/SchemaConfig/List/Index/index.tsx | 10 +- .../SchemaConfig/List/SpaceStats/index.tsx | 6 +- .../Schema/SchemaConfig/List/Tag/index.tsx | 4 +- app/pages/Schema/SchemaConfig/index.tsx | 6 +- app/pages/Schema/SpaceCreate/index.tsx | 2 +- app/pages/Schema/index.tsx | 6 +- app/stores/console.ts | 4 +- app/stores/files.ts | 8 +- app/stores/global.ts | 4 +- app/stores/graph.ts | 421 +++++++ app/stores/graphInstances.ts | 38 + app/stores/import.ts | 24 +- app/stores/index.ts | 3 +- app/stores/schema.ts | 77 +- app/stores/twoGraph.ts | 337 ++++++ app/stores/types.d.ts | 6 + app/utils/function.ts | 4 + app/utils/http.ts | 4 +- app/utils/index.ts | 31 + app/utils/parseData.ts | 173 ++- package-lock.json | 1010 +++++++++-------- package.json | 22 +- tsconfig.json | 1 + 81 files changed, 4811 insertions(+), 865 deletions(-) create mode 100644 app/components/Button/index.less create mode 100644 app/components/Button/index.tsx create mode 100644 app/components/ColorPicker/index.less create mode 100644 app/components/ColorPicker/index.tsx create mode 100644 app/components/ForceGraph/canvas-force-graph.js create mode 100644 app/components/ForceGraph/color-tracker.js create mode 100644 app/components/ForceGraph/color-utils.js create mode 100644 app/components/ForceGraph/dagDepths.js create mode 100644 app/components/ForceGraph/force-graph.css create mode 100644 app/components/ForceGraph/force-graph.js create mode 100644 app/components/ForceGraph/index.js create mode 100644 app/components/ForceGraph/kapsule-link.js create mode 100644 app/global.d.ts create mode 100644 app/interfaces/graph.ts create mode 100644 app/pages/Console/OutputBox/ForceGraph/BrushSelect/index.ts create mode 100644 app/pages/Console/OutputBox/ForceGraph/DisplayPanel/ExpandForm/index.less create mode 100644 app/pages/Console/OutputBox/ForceGraph/DisplayPanel/ExpandForm/index.tsx create mode 100644 app/pages/Console/OutputBox/ForceGraph/DisplayPanel/ExpandItem/index.less create mode 100644 app/pages/Console/OutputBox/ForceGraph/DisplayPanel/ExpandItem/index.tsx create mode 100644 app/pages/Console/OutputBox/ForceGraph/DisplayPanel/index.less create mode 100644 app/pages/Console/OutputBox/ForceGraph/DisplayPanel/index.tsx create mode 100644 app/pages/Console/OutputBox/ForceGraph/Menu/ColorChangeBtn/index.less create mode 100644 app/pages/Console/OutputBox/ForceGraph/Menu/ColorChangeBtn/index.tsx create mode 100644 app/pages/Console/OutputBox/ForceGraph/Menu/NodeStyleSetBtn/index.less create mode 100644 app/pages/Console/OutputBox/ForceGraph/Menu/NodeStyleSetBtn/index.tsx create mode 100644 app/pages/Console/OutputBox/ForceGraph/Menu/index.less create mode 100644 app/pages/Console/OutputBox/ForceGraph/Menu/index.tsx create mode 100644 app/pages/Console/OutputBox/ForceGraph/OperationPanel.tsx create mode 100644 app/pages/Console/OutputBox/ForceGraph/Tootip/index.less create mode 100644 app/pages/Console/OutputBox/ForceGraph/Tootip/index.tsx create mode 100644 app/pages/Console/OutputBox/ForceGraph/index.less create mode 100644 app/pages/Console/OutputBox/ForceGraph/index.tsx create mode 100644 app/stores/graph.ts create mode 100644 app/stores/graphInstances.ts create mode 100644 app/stores/twoGraph.ts create mode 100644 app/utils/index.ts diff --git a/.eslintrc.js b/.eslintrc.js index ef579c06..55f930cd 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -16,8 +16,7 @@ module.exports = { ], parser: '@typescript-eslint/parser', parserOptions: { - 'project': ['./tsconfig.json'], - 'tsconfigRootDir': __dirname, + 'project': 'tsconfig.json', 'sourceType': 'module' }, plugins: [ @@ -34,7 +33,7 @@ module.exports = { } }, rules: { - 'arrow-spacing':[ + 'arrow-spacing': [ 'error', { before: true, @@ -49,6 +48,7 @@ module.exports = { default: 'array' } ], + 'react/display-name': 'off', '@typescript-eslint/ban-types': [ 'error', { @@ -108,29 +108,14 @@ module.exports = { } } ], - '@typescript-eslint/member-ordering': 'error', - '@typescript-eslint/naming-convention': [ - 'error', - { selector: 'typeLike', format: ['PascalCase', 'UPPER_CASE'], filter: { 'regex': '^(__String|[A-Za-z]+_[A-Za-z]+)$', match: false } }, - { selector: 'interface', format: ['PascalCase'], 'custom': { 'regex': '^I[A-Z][a-zA-Z0-9]*', match: true }, filter: { 'regex': '^I(Arguments|TextWriter|O([A-Z][a-z]+[A-Za-z]*)?)$', match: false } }, - { selector: 'variable', format: ['camelCase', 'PascalCase', 'UPPER_CASE'], 'leadingUnderscore': 'allow', filter: { 'regex': '^(_{1,2}filename|_{1,2}dirname|_+|[A-Za-z]+_[A-Za-z]+)$', match: false } }, - { selector: 'function', format: ['camelCase', 'PascalCase'], 'leadingUnderscore': 'allow', filter: { 'regex': '^[A-Za-z]+_[A-Za-z]+$', match: false } }, - { selector: 'parameter', format: ['camelCase', 'PascalCase'], 'leadingUnderscore': 'allow', filter: { 'regex': '^(_+|[A-Za-z]+_[A-Z][a-z]+)$', match: false } }, - { selector: 'method', format: ['camelCase', 'PascalCase'], 'leadingUnderscore': 'allow', filter: { 'regex': '^[A-Za-z]+_[A-Za-z]+$', match: false } }, - { selector: 'memberLike', format: ['camelCase'], 'leadingUnderscore': 'allow', filter: { 'regex': '^[A-Za-z]+_[A-Za-z]+$', match: false } }, - { selector: 'enumMember', format: ['camelCase', 'PascalCase', 'UPPER_CASE'], 'leadingUnderscore': 'allow', filter: { 'regex': '^[A-Za-z]+_[A-Za-z]+$', match: false } }, - { selector: 'property', format: null } - ], - '@typescript-eslint/no-empty-function': 'error', + '@typescript-eslint/no-empty-function': 'off', '@typescript-eslint/no-empty-interface': 'off', '@typescript-eslint/no-explicit-any': 'off', '@typescript-eslint/no-misused-new': 'error', '@typescript-eslint/no-namespace': 'error', '@typescript-eslint/no-parameter-properties': 'off', - '@typescript-eslint/no-unused-expressions': ['error', { "allowTernary": true, "allowShortCircuit": true }], + '@typescript-eslint/no-this-alias': 'error', '@typescript-eslint/no-use-before-define': 'off', - '@typescript-eslint/no-var-requires': 'error', - '@typescript-eslint/prefer-for-of': 'error', '@typescript-eslint/prefer-function-type': 'error', '@typescript-eslint/prefer-namespace-keyword': 'error', '@typescript-eslint/quotes': [ @@ -165,13 +150,12 @@ module.exports = { 'error', 'smart' ], - 'guard-for-in': 'error', 'id-blacklist': 'error', 'id-match': 'error', + 'import/no-extraneous-dependencies': 'error', 'import/no-internal-modules': 'off', - 'sort-imports': 'error', + 'import/order': 'error', 'jsdoc/check-alignment': 'error', - 'jsdoc/check-indentation': 'error', 'jsdoc/newline-after-description': 'error', 'max-classes-per-file': [ 'error', @@ -220,7 +204,7 @@ module.exports = { 'no-new-wrappers': 'error', 'no-redeclare': 'error', 'no-return-await': 'error', - 'no-sequences': 'error', + 'no-sequences': 'off', 'no-shadow': [ 'off', { @@ -239,16 +223,11 @@ module.exports = { 'error', 'never' ], - "no-multi-spaces": ["error", { ignoreEOLComments: true }], - "comma-spacing": ["error"], + 'no-multi-spaces': ['error', { ignoreEOLComments: true }], + 'comma-spacing': ['error'], 'prefer-const': 'error', 'prefer-object-spread': 'error', - 'radix': 'error', 'react/display-name': 'error', - 'react/jsx-boolean-value': [ - 'error', - 'always' - ], 'react/jsx-curly-spacing': 'off', 'react/jsx-equals-spacing': 'off', 'react/jsx-key': 'error', @@ -265,7 +244,7 @@ module.exports = { 'react/jsx-uses-react': 'error', 'react/jsx-uses-vars': 'error', 'react/jsx-wrap-multilines': 'off', - 'react/no-children-prop': 'off', + 'react/no-children-prop': 'error', 'react/no-danger-with-children': 'error', 'react/no-deprecated': 'error', 'react/no-direct-mutation-state': 'error', @@ -276,10 +255,14 @@ module.exports = { 'react/no-unescaped-entities': 'error', 'react/no-unknown-property': 'error', 'react/no-unsafe': 'off', - 'react/prop-types': 'error', + 'react/prop-types': 'off', 'react/react-in-jsx-scope': 'error', 'react/require-render-return': 'error', 'react/self-closing-comp': ['error'], + 'key-spacing': ['error', { + 'beforeColon': false, + 'afterColon': true + }], 'space-in-parens': [ 'error', 'never' @@ -293,25 +276,18 @@ module.exports = { ] } ], - 'key-spacing': ["error", { - "beforeColon": false, - "afterColon": true - }], - 'object-property-newline': ["error", { "allowAllPropertiesOnSameLine": true }], - "space-infix-ops": "error", + 'space-infix-ops': 'error', semi: 1, - 'block-spacing': "error", - 'space-before-blocks': "error", - 'space-before-function-paren': ["error", "never"], - 'object-curly-spacing': ['error','always'], + 'block-spacing': 'error', + 'space-before-blocks': 'error', + 'space-before-function-paren': ['error', { + 'anonymous': 'never', + 'named': 'never', + 'asyncArrow': 'always' + }], + 'object-curly-spacing': ['error', 'always'], 'use-isnan': 'error', 'valid-typeof': 'off', - 'jsx-quotes': ['error', 'prefer-double'], - 'sort-imports': ['error', { - ignoreCase: false, - ignoreDeclarationSort: true, - ignoreMemberSort: false, - memberSyntaxSortOrder: ['none', 'all', 'multiple', 'single'] - }] + 'jsx-quotes': ['error', 'prefer-double'] } }; diff --git a/app/app.less b/app/app.less index 09b557b7..36409835 100644 --- a/app/app.less +++ b/app/app.less @@ -25,7 +25,7 @@ src: url(~@app/static/fonts/Roboto-Regular.ttf); } -#app { +#studio-app { font-family: Roboto-Regular, sans-serif; } diff --git a/app/components/Button/index.less b/app/components/Button/index.less new file mode 100644 index 00000000..8f81312c --- /dev/null +++ b/app/components/Button/index.less @@ -0,0 +1,18 @@ +@import '@app/common.less'; + +svg.btn-icon { + fill: #262626; + cursor: pointer; +} + +svg.btn-actived { + fill: @blue; +} + +svg.btn-disabled { + cursor: not-allowed; +} + +svg.rotate-btn { + transform: rotate(180deg); +} diff --git a/app/components/Button/index.tsx b/app/components/Button/index.tsx new file mode 100644 index 00000000..5dc381ce --- /dev/null +++ b/app/components/Button/index.tsx @@ -0,0 +1,59 @@ +import classnames from 'classnames'; +import React from 'react'; + +import Icon from '@app/components/Icon'; +import './index.less'; +interface IBtnProps { + disabled?: boolean; + action?: () => void; + mouseDownAction?: () => void; + mouseUpAction?: () => void; + icon?: string; + title?: string; + className?: string; + actived?: boolean; + component?: any; + trackCategory?: string; + trackAction?: string; + trackLabel?: string; +} + +interface IMenuButton extends IBtnProps { + tips?: string; + id?:string; +} +const MenuButton: React.FC = (props: IMenuButton) => { + const { icon, action, disabled, title, actived, component, className, trackCategory, trackAction, trackLabel } = + props; + return
{ + e.preventDefault(); + if (!disabled && action) { + action(); + } + }} + data-track-category={trackCategory} + data-track-action={trackAction} + data-track-label={trackLabel} + > + {icon && ( + + )} + {component} + {title && {title}} +
; +}; +export default MenuButton; diff --git a/app/components/ColorPicker/index.less b/app/components/ColorPicker/index.less new file mode 100644 index 00000000..dd2d4f24 --- /dev/null +++ b/app/components/ColorPicker/index.less @@ -0,0 +1,14 @@ +.custom-picker { + border: none !important; + box-shadow: initial !important; + border-radius: 0 !important; + + > div { + padding: 0 !important; + + > span > div { + width: 24px !important; + height: 24px !important; + } + } +} diff --git a/app/components/ColorPicker/index.tsx b/app/components/ColorPicker/index.tsx new file mode 100644 index 00000000..a2da4854 --- /dev/null +++ b/app/components/ColorPicker/index.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import { TwitterPicker } from 'react-color'; + +import { COLOR_PICK_LIST } from '@app/config/explore'; + +import './index.less'; + +interface IProps { + onChangeComplete?: (color: string) => void; + onChange?: (color: string) => void; +} + +const ColorPicker: React.FC = (props: IProps) => { + const { onChange, onChangeComplete } = props; + const handleChange = color => { + if (onChange) { + onChange(color); + } + }; + + const handleChangeComplete = (color, _event) => { + if (onChangeComplete) { + onChangeComplete(color); + } + }; + + return ( + + ); +}; + +export default ColorPicker; diff --git a/app/components/ForceGraph/canvas-force-graph.js b/app/components/ForceGraph/canvas-force-graph.js new file mode 100644 index 00000000..f1493a49 --- /dev/null +++ b/app/components/ForceGraph/canvas-force-graph.js @@ -0,0 +1,545 @@ +import { + forceSimulation as d3ForceSimulation, + forceLink as d3ForceLink, + forceManyBody as d3ForceManyBody, + forceCenter as d3ForceCenter, + forceRadial as d3ForceRadial +} from 'd3-force-3d'; + +import { Bezier } from 'bezier-js'; + +import Kapsule from 'kapsule'; +import accessorFn from 'accessor-fn'; +import indexBy from 'index-array-by'; + +import { autoColorObjects } from './color-utils'; +import getDagDepths from './dagDepths'; + +// + +const DAG_LEVEL_NODE_RATIO = 2; + +// whenever styling props are changed that require a canvas redraw +const notifyRedraw = (_, state) => state.onNeedsRedraw && state.onNeedsRedraw(); + +export default Kapsule({ + + props: { + graphData: { + default: { + nodes: [], + links: [] + }, + onChange(_, state) { + state.engineRunning = false; + } // Pause simulation + }, + dagMode: { + onChange(dagMode, state) { // td, bu, lr, rl, radialin, radialout + !dagMode && (state.graphData.nodes || []).forEach(n => n.fx = n.fy = undefined); // unfix nodes when disabling dag mode + } + }, + dagLevelDistance: {}, + dagNodeFilter: { default: node => true }, + onDagError: { triggerUpdate: false }, + nodeRelSize: { default: 4, triggerUpdate: false, onChange: notifyRedraw }, // area per val unit + nodeId: { default: 'id' }, + nodeVal: { default: 'val', triggerUpdate: false, onChange: notifyRedraw }, + nodeColor: { default: 'color', triggerUpdate: false, onChange: notifyRedraw }, + nodeAutoColorBy: {}, + nodeCanvasObject: { triggerUpdate: false, onChange: notifyRedraw }, + nodeCanvasObjectMode: { default: () => 'replace', triggerUpdate: false, onChange: notifyRedraw }, + nodeVisibility: { default: true, triggerUpdate: false, onChange: notifyRedraw }, + linkSource: { default: 'source' }, + linkTarget: { default: 'target' }, + linkVisibility: { default: true, triggerUpdate: false, onChange: notifyRedraw }, + linkColor: { default: 'color', triggerUpdate: false, onChange: notifyRedraw }, + linkAutoColorBy: {}, + linkLineDash: { triggerUpdate: false, onChange: notifyRedraw }, + linkWidth: { default: 1, triggerUpdate: false, onChange: notifyRedraw }, + linkCurvature: { default: 0, triggerUpdate: false, onChange: notifyRedraw }, + linkCanvasObject: { triggerUpdate: false, onChange: notifyRedraw }, + linkCanvasObjectMode: { default: () => 'replace', triggerUpdate: false, onChange: notifyRedraw }, + linkDirectionalArrowLength: { default: 0, triggerUpdate: false, onChange: notifyRedraw }, + linkDirectionalArrowColor: { triggerUpdate: false, onChange: notifyRedraw }, + linkDirectionalArrowRelPos: { default: 0.5, triggerUpdate: false, onChange: notifyRedraw }, // value between 0<>1 indicating the relative pos along the (exposed) line + linkDirectionalParticles: { default: 0 }, // animate photons travelling in the link direction + linkDirectionalParticleSpeed: { default: 0.01, triggerUpdate: false }, // in link length ratio per frame + linkDirectionalParticleWidth: { default: 4, triggerUpdate: false }, + linkDirectionalParticleColor: { triggerUpdate: false }, + globalScale: { default: 1, triggerUpdate: false }, + d3AlphaMin: { default: 0, triggerUpdate: false }, + d3AlphaDecay: { default: 0.0228, triggerUpdate: false, onChange(alphaDecay, state) { state.forceLayout.alphaDecay(alphaDecay) } }, + d3AlphaTarget: { default: 0, triggerUpdate: false, onChange(alphaTarget, state) { state.forceLayout.alphaTarget(alphaTarget) } }, + d3VelocityDecay: { default: 0.4, triggerUpdate: false, onChange(velocityDecay, state) { state.forceLayout.velocityDecay(velocityDecay) } }, + warmupTicks: { default: 0, triggerUpdate: false }, // how many times to tick the force engine at init before starting to render + cooldownTicks: { default: Infinity, triggerUpdate: false }, + cooldownTime: { default: 15000, triggerUpdate: false }, // ms + onUpdate: { default: () => { }, triggerUpdate: false }, + onFinishUpdate: { default: () => { }, triggerUpdate: false }, + onEngineTick: { default: () => { }, triggerUpdate: false }, + onEngineStop: { default: () => { }, triggerUpdate: false }, + onNeedsRedraw: { triggerUpdate: false }, + isShadow: { default: false, triggerUpdate: false } + }, + + methods: { + // Expose d3 forces for external manipulation + d3Force: function (state, forceName, forceFn) { + if (forceFn === undefined) { + return state.forceLayout.force(forceName); // Force getter + } + state.forceLayout.force(forceName, forceFn); // Force setter + return this; + }, + d3ReheatSimulation: function (state) { + state.forceLayout.alpha(1); + this.resetCountdown(); + return this; + }, + // reset cooldown state + resetCountdown: function (state) { + state.cntTicks = 0; + state.startTickTime = new Date(); + state.engineRunning = true; + return this; + }, + isEngineRunning: state => !!state.engineRunning, + tickFrame: function (state) { + !state.isShadow && layoutTick(); + paintLinks(); + !state.isShadow && paintArrows(); + !state.isShadow && paintPhotons(); + paintNodes(); + + return this; + + // + + function layoutTick() { + if (state.engineRunning) { + if ( + ++state.cntTicks > state.cooldownTicks || + (new Date()) - state.startTickTime > state.cooldownTime || + (state.d3AlphaMin > 0 && state.forceLayout.alpha() < state.d3AlphaMin) + ) { + state.engineRunning = false; // Stop ticking graph + state.onEngineStop(); + } else { + state.forceLayout.tick(); // Tick it + state.onEngineTick(); + } + } + } + + function paintNodes() { + const getVisibility = accessorFn(state.nodeVisibility); + const getVal = accessorFn(state.nodeVal); + const getColor = accessorFn(state.nodeColor); + const getNodeCanvasObjectMode = accessorFn(state.nodeCanvasObjectMode); + + const ctx = state.ctx; + + // Draw wider nodes by 1px on shadow canvas for more precise hovering (due to boundary anti-aliasing) + const padAmount = state.isShadow / state.globalScale; + + const visibleNodes = state.graphData.nodes.filter(getVisibility); + + ctx.save(); + visibleNodes.forEach(node => { + const nodeCanvasObjectMode = getNodeCanvasObjectMode(node); + + if (state.nodeCanvasObject && (nodeCanvasObjectMode === 'before' || nodeCanvasObjectMode === 'replace')) { + // Custom node before/replace paint + state.nodeCanvasObject(node, ctx, state.globalScale, state.isShadow); + + if (nodeCanvasObjectMode === 'replace') { + ctx.restore(); + return; + } + } + + // Draw wider nodes by 1px on shadow canvas for more precise hovering (due to boundary anti-aliasing) + const r = Math.sqrt(Math.max(0, getVal(node) || 1)) * state.nodeRelSize + padAmount; + + ctx.beginPath(); + ctx.arc(node.x, node.y, r, 0, 2 * Math.PI, false); + ctx.fillStyle = getColor(node) || 'rgba(31, 120, 180, 0.92)'; + ctx.fill(); + + if (state.nodeCanvasObject && nodeCanvasObjectMode === 'after') { + // Custom node after paint + state.nodeCanvasObject(node, state.ctx, state.globalScale); + } + }); + ctx.restore(); + } + + function paintLinks() { + const getVisibility = accessorFn(state.linkVisibility); + const getColor = accessorFn(state.linkColor); + const getWidth = accessorFn(state.linkWidth); + const getLineDash = accessorFn(state.linkLineDash); + const getCurvature = accessorFn(state.linkCurvature); + const getLinkCanvasObjectMode = accessorFn(state.linkCanvasObjectMode); + + const ctx = state.ctx; + + // Draw wider lines by 2px on shadow canvas for more precise hovering (due to boundary anti-aliasing) + const padAmount = state.isShadow * 2; + + const visibleLinks = state.graphData.links.filter(getVisibility); + + visibleLinks.forEach(calcLinkControlPoints); // calculate curvature control points for all visible links + + let beforeCustomLinks = [], afterCustomLinks = [], defaultPaintLinks = visibleLinks; + if (state.linkCanvasObject) { + const replaceCustomLinks = [], otherCustomLinks = []; + + visibleLinks.forEach(d => + ({ + before: beforeCustomLinks, + after: afterCustomLinks, + replace: replaceCustomLinks + }[getLinkCanvasObjectMode(d)] || otherCustomLinks).push(d) + ); + defaultPaintLinks = [...beforeCustomLinks, ...afterCustomLinks, ...otherCustomLinks]; + beforeCustomLinks = beforeCustomLinks.concat(replaceCustomLinks); + } + + // Custom link before paints + ctx.save(); + beforeCustomLinks.forEach(link => state.linkCanvasObject(link, ctx, state.globalScale, state.isShadow)); + ctx.restore(); + + // Bundle strokes per unique color/width/dash for performance optimization + const linksPerColor = indexBy(defaultPaintLinks, [getColor, getWidth, getLineDash]); + + ctx.save(); + Object.entries(linksPerColor).forEach(([color, linksPerWidth]) => { + const lineColor = !color || color === 'undefined' ? 'rgba(0,0,0,0.15)' : color; + Object.entries(linksPerWidth).forEach(([width, linesPerLineDash]) => { + const lineWidth = (width || 1) / state.globalScale + padAmount; + Object.entries(linesPerLineDash).forEach(([dashSegments, links]) => { + const lineDashSegments = getLineDash(links[0]); + ctx.beginPath(); + links.forEach(link => { + const start = link.source; + const end = link.target; + if (!start || !end || !start.hasOwnProperty('x') || !end.hasOwnProperty('x')) return; // skip invalid link + + ctx.moveTo(start.x, start.y); + + const controlPoints = link.__controlPoints; + + if (!controlPoints) { // Straight line + ctx.lineTo(end.x, end.y); + } else { + // Use quadratic curves for regular lines and bezier for loops + ctx[controlPoints.length === 2 ? 'quadraticCurveTo' : 'bezierCurveTo'](...controlPoints, end.x, end.y); + } + }); + ctx.strokeStyle = lineColor; + ctx.lineWidth = lineWidth; + ctx.setLineDash(lineDashSegments || []); + ctx.stroke(); + }); + }); + }); + ctx.restore(); + + // Custom link after paints + ctx.save(); + afterCustomLinks.forEach(link => state.linkCanvasObject(link, ctx, state.globalScale)); + ctx.restore(); + + // + + function calcLinkControlPoints(link) { + const curvature = getCurvature(link); + + if (!curvature) { // straight line + link.__controlPoints = null; + return; + } + + const start = link.source; + const end = link.target; + if (!start || !end || !start.hasOwnProperty('x') || !end.hasOwnProperty('x')) return; // skip invalid link + + const l = Math.sqrt(Math.pow(end.x - start.x, 2) + Math.pow(end.y - start.y, 2)); // line length + + if (l > 0) { + const a = Math.atan2(end.y - start.y, end.x - start.x); // line angle + const d = l * curvature; // control point distance + + const cp = { // control point + x: (start.x + end.x) / 2 + d * Math.cos(a - Math.PI / 2), + y: (start.y + end.y) / 2 + d * Math.sin(a - Math.PI / 2) + }; + + link.__controlPoints = [cp.x, cp.y]; + } else { // Same point, draw a loop + const d = curvature * 70; + link.__controlPoints = [end.x, end.y - d, end.x + d, end.y]; + } + } + } + + function paintArrows() { + const ARROW_WH_RATIO = 1.6; + const ARROW_VLEN_RATIO = 0.2; + + const getLength = accessorFn(state.linkDirectionalArrowLength); + const getRelPos = accessorFn(state.linkDirectionalArrowRelPos); + const getVisibility = accessorFn(state.linkVisibility); + const getColor = accessorFn(state.linkDirectionalArrowColor || state.linkColor); + const getNodeVal = accessorFn(state.nodeVal); + const ctx = state.ctx; + + ctx.save(); + state.graphData.links.filter(getVisibility).forEach(link => { + const arrowLength = getLength(link); + if (!arrowLength || arrowLength < 0) return; + + const start = link.source; + const end = link.target; + + if (!start || !end || !start.hasOwnProperty('x') || !end.hasOwnProperty('x')) return; // skip invalid link + + const startR = Math.sqrt(Math.max(0, getNodeVal(start) || 1)) * state.nodeRelSize; + const endR = Math.sqrt(Math.max(0, getNodeVal(end) || 1)) * state.nodeRelSize; + + const arrowRelPos = Math.min(1, Math.max(0, getRelPos(link))); + const arrowColor = getColor(link) || 'rgba(0,0,0,0.28)'; + const arrowHalfWidth = arrowLength / ARROW_WH_RATIO / 2; + + // Construct bezier for curved lines + const bzLine = link.__controlPoints && new Bezier(start.x, start.y, ...link.__controlPoints, end.x, end.y); + + const getCoordsAlongLine = bzLine + ? t => bzLine.get(t) // get position along bezier line + : t => ({ // straight line: interpolate linearly + x: start.x + (end.x - start.x) * t || 0, + y: start.y + (end.y - start.y) * t || 0 + }); + + const lineLen = bzLine + ? bzLine.length() + : Math.sqrt(Math.pow(end.x - start.x, 2) + Math.pow(end.y - start.y, 2)); + + const posAlongLine = startR + arrowLength + (lineLen - startR - endR - arrowLength) * arrowRelPos; + + const arrowHead = getCoordsAlongLine(posAlongLine / lineLen); + const arrowTail = getCoordsAlongLine((posAlongLine - arrowLength) / lineLen); + const arrowTailVertex = getCoordsAlongLine((posAlongLine - arrowLength * (1 - ARROW_VLEN_RATIO)) / lineLen); + + const arrowTailAngle = Math.atan2(arrowHead.y - arrowTail.y, arrowHead.x - arrowTail.x) - Math.PI / 2; + + ctx.beginPath(); + + ctx.moveTo(arrowHead.x, arrowHead.y); + ctx.lineTo(arrowTail.x + arrowHalfWidth * Math.cos(arrowTailAngle), arrowTail.y + arrowHalfWidth * Math.sin(arrowTailAngle)); + ctx.lineTo(arrowTailVertex.x, arrowTailVertex.y); + ctx.lineTo(arrowTail.x - arrowHalfWidth * Math.cos(arrowTailAngle), arrowTail.y - arrowHalfWidth * Math.sin(arrowTailAngle)); + + ctx.fillStyle = arrowColor; + ctx.fill(); + }); + ctx.restore(); + } + + function paintPhotons() { + const getNumPhotons = accessorFn(state.linkDirectionalParticles); + const getSpeed = accessorFn(state.linkDirectionalParticleSpeed); + const getDiameter = accessorFn(state.linkDirectionalParticleWidth); + const getVisibility = accessorFn(state.linkVisibility); + const getColor = accessorFn(state.linkDirectionalParticleColor || state.linkColor); + const ctx = state.ctx; + + ctx.save(); + state.graphData.links.filter(getVisibility).forEach(link => { + const numCyclePhotons = getNumPhotons(link); + + if (!link.hasOwnProperty('__photons') || !link.__photons.length) return; + + const start = link.source; + const end = link.target; + + if (!start || !end || !start.hasOwnProperty('x') || !end.hasOwnProperty('x')) return; // skip invalid link + + const particleSpeed = getSpeed(link); + const photons = link.__photons || []; + const photonR = Math.max(0, getDiameter(link) / 2) / Math.sqrt(state.globalScale); + const photonColor = getColor(link) || 'rgba(0,0,0,0.28)'; + + ctx.fillStyle = photonColor; + + // Construct bezier for curved lines + const bzLine = link.__controlPoints + ? new Bezier(start.x, start.y, ...link.__controlPoints, end.x, end.y) + : null; + + let cyclePhotonIdx = 0; + let needsCleanup = false; // whether some photons need to be removed from list + photons.forEach(photon => { + const singleHop = !!photon.__singleHop; + + if (!photon.hasOwnProperty('__progressRatio')) { + photon.__progressRatio = singleHop ? 0 : cyclePhotonIdx / numCyclePhotons; + } + + !singleHop && cyclePhotonIdx++; // increase regular photon index + + photon.__progressRatio += particleSpeed; + + if (photon.__progressRatio >= 1) { + if (!singleHop) { + photon.__progressRatio = photon.__progressRatio % 1; + } else { + needsCleanup = true; + return; + } + } + + const photonPosRatio = photon.__progressRatio; + + const coords = bzLine + ? bzLine.get(photonPosRatio) // get position along bezier line + : { // straight line: interpolate linearly + x: start.x + (end.x - start.x) * photonPosRatio || 0, + y: start.y + (end.y - start.y) * photonPosRatio || 0 + }; + + ctx.beginPath(); + ctx.arc(coords.x, coords.y, photonR, 0, 2 * Math.PI, false); + ctx.fill(); + }); + + if (needsCleanup) { + // remove expired single hop photons + link.__photons = link.__photons.filter(photon => !photon.__singleHop || photon.__progressRatio <= 1); + } + }); + ctx.restore(); + } + }, + emitParticle: function (state, link) { + if (link) { + !link.__photons && (link.__photons = []); + link.__photons.push({ __singleHop: true }); // add a single hop particle + } + + return this; + } + }, + + stateInit: () => ({ + forceLayout: d3ForceSimulation() + .force('link', d3ForceLink()) + .force('charge', d3ForceManyBody()) + .force('dagRadial', null) + .stop(), + engineRunning: false + }), + + init(canvasCtx, state) { + // Main canvas object to manipulate + state.ctx = canvasCtx; + }, + + update(state) { + state.engineRunning = false; // Pause simulation + state.onUpdate(); + + if (state.nodeAutoColorBy !== null) { + // Auto add color to uncolored nodes + autoColorObjects(state.graphData.nodes, accessorFn(state.nodeAutoColorBy), state.nodeColor); + } + if (state.linkAutoColorBy !== null) { + // Auto add color to uncolored links + autoColorObjects(state.graphData.links, accessorFn(state.linkAutoColorBy), state.linkColor); + } + + // parse links + state.graphData.links.forEach(link => { + link.source = link[state.linkSource]; + link.target = link[state.linkTarget]; + }); + + if (!state.isShadow) { + // Add photon particles + const linkParticlesAccessor = accessorFn(state.linkDirectionalParticles); + state.graphData.links.forEach(link => { + const numPhotons = Math.round(Math.abs(linkParticlesAccessor(link))); + if (numPhotons) { + link.__photons = [...Array(numPhotons)].map(() => ({})); + } else { + delete link.__photons; + } + }); + } + + // Feed data to force-directed layout + state.forceLayout + .stop() + .alpha(1) // re-heat the simulation + .nodes(state.graphData.nodes); + + // add links (if link force is still active) + const linkForce = state.forceLayout.force('link'); + if (linkForce) { + linkForce + .id(d => d[state.nodeId]) + .links(state.graphData.links); + } + + // setup dag force constraints + const nodeDepths = state.dagMode && getDagDepths( + state.graphData, + node => node[state.nodeId], + { + nodeFilter: state.dagNodeFilter, + onLoopError: state.onDagError || undefined + } + ); + const maxDepth = Math.max(...Object.values(nodeDepths || [])); + const dagLevelDistance = state.dagLevelDistance || ( + state.graphData.nodes.length / (maxDepth || 1) * DAG_LEVEL_NODE_RATIO + * (['radialin', 'radialout'].indexOf(state.dagMode) !== -1 ? 0.7 : 1) + ); + + // Fix nodes to x,y for dag mode + if (state.dagMode) { + const getFFn = (fix, invert) => node => !fix + ? undefined + : (nodeDepths[node[state.nodeId]] - maxDepth / 2) * dagLevelDistance * (invert ? -1 : 1); + + const fxFn = getFFn(['lr', 'rl'].indexOf(state.dagMode) !== -1, state.dagMode === 'rl'); + const fyFn = getFFn(['td', 'bu'].indexOf(state.dagMode) !== -1, state.dagMode === 'bu'); + + state.graphData.nodes.filter(state.dagNodeFilter).forEach(node => { + node.fx = fxFn(node); + node.fy = fyFn(node); + }); + } + + // Use radial force for radial dags + state.forceLayout.force('dagRadial', + ['radialin', 'radialout'].indexOf(state.dagMode) !== -1 + ? d3ForceRadial(node => { + const nodeDepth = nodeDepths[node[state.nodeId]] || -1; + return (state.dagMode === 'radialin' ? maxDepth - nodeDepth : nodeDepth) * dagLevelDistance; + }) + .strength(node => state.dagNodeFilter(node) ? 1 : 0) + : null + ); + + for (let i = 0; (i < state.warmupTicks) && !(state.d3AlphaMin > 0 && state.forceLayout.alpha() < state.d3AlphaMin); i++) { + state.forceLayout.tick(); + } // Initial ticks before starting to render + + this.resetCountdown(); + state.onFinishUpdate(); + } +}); diff --git a/app/components/ForceGraph/color-tracker.js b/app/components/ForceGraph/color-tracker.js new file mode 100644 index 00000000..623ed5c7 --- /dev/null +++ b/app/components/ForceGraph/color-tracker.js @@ -0,0 +1,31 @@ +const int2HexColor = (num) => `#${num.toString(16).padStart(6, '0')}`; +const rgb2Int = (r, g, b) => r * 256 * 256 + g * 256 + b; +let name = 0; +class ColorTracker { + constructor() { + this.name = name++; + this.index = 0; + this.max = 255 * 255 * 255 - 1; + this.registry = {}; + } + + register(obj) { + this.index += 5; + const total = this.index; + const color = int2HexColor(total); + this.registry[total] = obj; + if (this.index >= this.max) { + this.index = 0; + } + return color; + } + + lookup(color) { + return this.registry[rgb2Int(...color)]; + } + clear() { + this.index = 0; + this.registry = {}; + } +} +export default ColorTracker; diff --git a/app/components/ForceGraph/color-utils.js b/app/components/ForceGraph/color-utils.js new file mode 100644 index 00000000..68a8b127 --- /dev/null +++ b/app/components/ForceGraph/color-utils.js @@ -0,0 +1,17 @@ +import { scaleOrdinal } from 'd3-scale'; +import { schemePaired } from 'd3-scale-chromatic'; + +const autoColorScale = scaleOrdinal(schemePaired); + +// Autoset attribute colorField by colorByAccessor property +// If an object has already a color, don't set it +// Objects can be nodes or links +function autoColorObjects(objects, colorByAccessor, colorField) { + if (!colorByAccessor || typeof colorField !== 'string') return; + + objects.filter(obj => !obj[colorField]).forEach(obj => { + obj[colorField] = autoColorScale(colorByAccessor(obj)); + }); +} + +export { autoColorObjects }; diff --git a/app/components/ForceGraph/dagDepths.js b/app/components/ForceGraph/dagDepths.js new file mode 100644 index 00000000..4e23b990 --- /dev/null +++ b/app/components/ForceGraph/dagDepths.js @@ -0,0 +1,51 @@ +export default function({ nodes, links }, idAccessor, { + nodeFilter = () => true, + onLoopError = loopIds => { throw `Invalid DAG structure! Found cycle in node path: ${loopIds.join(' -> ')}.` } +} = {}) { + // linked graph + const graph = {}; + + nodes.forEach(node => graph[idAccessor(node)] = { data: node, out : [], depth: -1, skip: !nodeFilter(node) }); + links.forEach(({ source, target }) => { + const sourceId = getNodeId(source); + const targetId = getNodeId(target); + if (!graph.hasOwnProperty(sourceId)) throw `Missing source node with id: ${sourceId}`; + if (!graph.hasOwnProperty(targetId)) throw `Missing target node with id: ${targetId}`; + const sourceNode = graph[sourceId]; + const targetNode = graph[targetId]; + + sourceNode.out.push(targetNode); + + function getNodeId(node) { + return typeof node === 'object' ? idAccessor(node) : node; + } + }); + + const foundLoops = []; + traverse(Object.values(graph)); + + const nodeDepths = Object.assign({}, ...Object.entries(graph) + .filter(([, node]) => !node.skip) + .map(([id, node]) => ({ [id]: node.depth })) + ); + + return nodeDepths; + + function traverse(nodes, nodeStack = [], currentDepth = 0) { + for (let i=0, l=nodes.length; i idAccessor(d.data)); + if (!foundLoops.some(foundLoop => foundLoop.length === loop.length && foundLoop.every((id, idx) => id === loop[idx]))) { + foundLoops.push(loop); + onLoopError(loop); + } + continue; + } + if (currentDepth > node.depth) { // Don't unnecessarily revisit chunks of the graph + node.depth = currentDepth; + traverse(node.out, [...nodeStack, node], currentDepth + (node.skip ? 0 : 1)); + } + } + } +} \ No newline at end of file diff --git a/app/components/ForceGraph/force-graph.css b/app/components/ForceGraph/force-graph.css new file mode 100644 index 00000000..8ea34c4a --- /dev/null +++ b/app/components/ForceGraph/force-graph.css @@ -0,0 +1,23 @@ +.force-graph-container canvas { + display: block; + user-select: none; + outline: none; + -webkit-tap-highlight-color: transparent; +} + +.force-graph-container .clickable { + cursor: pointer; +} + +.force-graph-container .grabbable { + cursor: move; + cursor: grab; + cursor: -moz-grab; + cursor: -webkit-grab; +} + +.force-graph-container .grabbable:active { + cursor: grabbing; + cursor: -moz-grabbing; + cursor: -webkit-grabbing; +} diff --git a/app/components/ForceGraph/force-graph.js b/app/components/ForceGraph/force-graph.js new file mode 100644 index 00000000..a95693e0 --- /dev/null +++ b/app/components/ForceGraph/force-graph.js @@ -0,0 +1,702 @@ +import { select as d3Select } from 'd3-selection'; +import { zoom as d3Zoom, zoomTransform as d3ZoomTransform } from 'd3-zoom'; +import { drag as d3Drag } from 'd3-drag'; +import { max as d3Max, min as d3Min } from 'd3-array'; +import throttle from 'lodash.throttle'; +import TWEEN from '@tweenjs/tween.js'; +import Kapsule from 'kapsule'; +import accessorFn from 'accessor-fn'; +import ColorTracker from './color-tracker'; + +import CanvasForceGraph from './canvas-force-graph'; +import linkKapsule from './kapsule-link.js'; + +const HOVER_CANVAS_THROTTLE_DELAY = 800; // ms to throttle shadow canvas updates for perf improvement +const ZOOM2NODES_FACTOR = 4; + +// Expose config from forceGraph +const bindFG = linkKapsule('forceGraph', CanvasForceGraph); +const bindBoth = linkKapsule(['forceGraph', 'shadowGraph'], CanvasForceGraph); +const linkedProps = Object.assign( + ...[ + 'nodeColor', + 'nodeAutoColorBy', + 'nodeCanvasObject', + 'nodeCanvasObjectMode', + 'linkColor', + 'linkAutoColorBy', + 'linkLineDash', + 'linkWidth', + 'linkCanvasObject', + 'linkCanvasObjectMode', + 'linkDirectionalArrowLength', + 'linkDirectionalArrowColor', + 'linkDirectionalArrowRelPos', + 'linkDirectionalParticles', + 'linkDirectionalParticleSpeed', + 'linkDirectionalParticleWidth', + 'linkDirectionalParticleColor', + 'dagMode', + 'dagLevelDistance', + 'dagNodeFilter', + 'onDagError', + 'd3AlphaMin', + 'd3AlphaDecay', + 'd3VelocityDecay', + 'warmupTicks', + 'cooldownTicks', + 'cooldownTime', + 'onEngineTick', + 'onEngineStop', + ].map((p) => ({ [p]: bindFG.linkProp(p) })), + ...[ + 'nodeRelSize', + 'nodeId', + 'nodeVal', + 'nodeVisibility', + 'linkSource', + 'linkTarget', + 'linkVisibility', + 'linkCurvature', + ].map((p) => ({ [p]: bindBoth.linkProp(p) })) +); +const linkedMethods = Object.assign( + ...['d3Force', 'd3ReheatSimulation', 'emitParticle'].map((p) => ({ [p]: bindFG.linkMethod(p) })) +); + +function adjustCanvasSize(state) { + if (state.canvas) { + let curWidth = state.canvas.width; + let curHeight = state.canvas.height; + if (curWidth === 300 && curHeight === 150) { + // Default canvas dimensions + curWidth = curHeight = 0; + } + + const pxScale = window.devicePixelRatio; // 2 on retina displays + curWidth /= pxScale; + curHeight /= pxScale; + // Resize canvases + [state.canvas, state.shadowCanvas].forEach((canvas) => { + // Element size + canvas.style.width = `${state.width}px`; + canvas.style.height = `${state.height}px`; + + // Memory size (scaled to avoid blurriness) + canvas.width = state.width * pxScale; + canvas.height = state.height * pxScale; + + // Normalize coordinate system to use css pixels (on init only) + if (!curWidth && !curHeight) { + canvas.getContext('2d').scale(pxScale, pxScale); + } + }); + + // Relative center panning based on 0,0 + const k = d3ZoomTransform(state.canvas).k; + state.zoom.translateBy(state.zoom.__baseElem, (state.width - curWidth) / 2 / k, (state.height - curHeight) / 2 / k); + state.needsRedraw = true; + } +} + +function resetTransform(ctx) { + const pxRatio = window.devicePixelRatio; + ctx.setTransform(pxRatio, 0, 0, pxRatio, 0, 0); +} + +function clearCanvas(ctx, width, height) { + ctx.save(); + resetTransform(ctx); // reset transform + ctx.clearRect(0, 0, width, height); + ctx.restore(); //restore transforms +} + +// + +export default Kapsule({ + props: { + width: { default: 1060, onChange: (_, state) => adjustCanvasSize(state), triggerUpdate: false }, + height: { default: 400, onChange: (_, state) => adjustCanvasSize(state), triggerUpdate: false }, + graphData: { + default: { nodes: [], links: [] }, + onChange: (d, state) => { + if (state.enablePointerInteraction) { + state.colorTracker.clear(); + [ + { type: 'Node', objs: d.nodes }, + { type: 'Link', objs: d.links }, + ].forEach(hexIndex); + } + state.forceGraph.graphData(d); + state.shadowGraph.graphData(d); + + function hexIndex({ type, objs }) { + objs.forEach((d) => { + d.__indexColor = state.colorTracker.register({ type, d }); + }); + } + }, + triggerUpdate: false, + }, + backgroundColor: { + onChange(color, state) { + state.canvas && color && (state.canvas.style.background = color); + }, + triggerUpdate: false, + }, + nodeLabel: { default: 'name', triggerUpdate: false }, + nodePointerAreaPaint: { + onChange(paintFn, state) { + state.shadowGraph.nodeCanvasObject( + !paintFn ? null : (node, ctx, globalScale) => paintFn(node, node.__indexColor, ctx, globalScale) + ); + state.flushShadowCanvas && state.flushShadowCanvas(); + }, + triggerUpdate: false, + }, + linkPointerAreaPaint: { + onChange(paintFn, state) { + state.shadowGraph.linkCanvasObject( + !paintFn ? null : (link, ctx, globalScale) => paintFn(link, link.__indexColor, ctx, globalScale) + ); + state.flushShadowCanvas && state.flushShadowCanvas(); + }, + triggerUpdate: false, + }, + linkLabel: { default: 'name', triggerUpdate: false }, + linkHoverPrecision: { default: 4, triggerUpdate: false }, + minZoom: { + default: 0.01, + onChange(minZoom, state) { + state.zoom.scaleExtent([minZoom, state.zoom.scaleExtent()[1]]); + }, + triggerUpdate: false, + }, + maxZoom: { + default: 1000, + onChange(maxZoom, state) { + state.zoom.scaleExtent([state.zoom.scaleExtent()[0], maxZoom]); + }, + triggerUpdate: false, + }, + enableNodeDrag: { default: true, triggerUpdate: false }, + enableZoomInteraction: { default: true, triggerUpdate: false }, + enablePanInteraction: { default: true, triggerUpdate: false }, + enableZoomPanInteraction: { default: true, triggerUpdate: false }, // to be deprecated + enablePointerInteraction: { + default: true, + onChange(_, state) { + state.hoverObj = null; + }, + triggerUpdate: false, + }, + autoPauseRedraw: { default: true, triggerUpdate: false }, + onNodeDrag: { default: () => {}, triggerUpdate: false }, + onNodeDragEnd: { default: () => {}, triggerUpdate: false }, + onNodeClick: { triggerUpdate: false }, + onNodeRightClick: { triggerUpdate: false }, + onNodeHover: { triggerUpdate: false }, + onLinkClick: { triggerUpdate: false }, + onLinkRightClick: { triggerUpdate: false }, + onLinkHover: { triggerUpdate: false }, + onBackgroundClick: { triggerUpdate: false }, + onBackgroundRightClick: { triggerUpdate: false }, + onZoom: { triggerUpdate: false }, + onZoomEnd: { triggerUpdate: false }, + onRenderFramePre: { triggerUpdate: false }, + onRenderFramePost: { triggerUpdate: false }, + ...linkedProps, + }, + + aliases: { + // Prop names supported for backwards compatibility + stopAnimation: 'pauseAnimation', + }, + + methods: { + graph2ScreenCoords: function (state, x, y) { + const t = d3ZoomTransform(state.canvas); + return { x: x * t.k + t.x, y: y * t.k + t.y }; + }, + screen2GraphCoords: function (state, x, y) { + const t = d3ZoomTransform(state.canvas); + return { x: (x - t.x) / t.k, y: (y - t.y) / t.k }; + }, + centerAt: function (state, x, y, transitionDuration) { + if (!state.canvas) return null; // no canvas yet + + // setter + if (x !== undefined || y !== undefined) { + const finalPos = Object.assign({}, x !== undefined ? { x } : {}, y !== undefined ? { y } : {}); + if (!transitionDuration) { + // no animation + setCenter(finalPos); + } else { + new TWEEN.Tween(getCenter()) + .to(finalPos, transitionDuration) + .easing(TWEEN.Easing.Quadratic.Out) + .onUpdate(setCenter) + .start(); + } + return this; + } + + // getter + return getCenter(); + + // + + function getCenter() { + const t = d3ZoomTransform(state.canvas); + return { x: (state.width / 2 - t.x) / t.k, y: (state.height / 2 - t.y) / t.k }; + } + + function setCenter({ x, y }) { + state.zoom.translateTo( + state.zoom.__baseElem, + x === undefined ? getCenter().x : x, + y === undefined ? getCenter().y : y + ); + state.needsRedraw = true; + } + }, + zoom: function (state, k, transitionDuration) { + if (!state.canvas) return null; // no canvas yet + // setter + if (k !== undefined) { + if (!transitionDuration) { + // no animation + setZoom(k); + } else { + new TWEEN.Tween({ k: getZoom() }) + .to({ k }, transitionDuration) + .easing(TWEEN.Easing.Quadratic.Out) + .onUpdate(({ k }) => setZoom(k)) + .start(); + } + return this; + } + + // getter + return getZoom(); + + // + + function getZoom() { + return d3ZoomTransform(state.canvas).k; + } + + function setZoom(k) { + state.zoom.scaleTo(state.zoom.__baseElem, k); + state.needsRedraw = true; + } + }, + zoomToFit: function (state, transitionDuration = 0, padding = 10, ...bboxArgs) { + const bbox = this.getGraphBbox(...bboxArgs); + if (bbox) { + const center = { + x: (bbox.x[0] + bbox.x[1]) / 2, + y: (bbox.y[0] + bbox.y[1]) / 2, + }; + + const zoomK = Math.max( + 1e-12, + Math.min( + 1e12, + (state.width - padding * 2) / (bbox.x[1] - bbox.x[0]), + (state.height - padding * 2) / (bbox.y[1] - bbox.y[0]) + ) + ); + + this.centerAt(center.x, center.y, transitionDuration); + this.zoom(zoomK, transitionDuration); + } + + return this; + }, + getGraphBbox: function (state, nodeFilter = () => true) { + const getVal = accessorFn(state.nodeVal); + const getR = (node) => Math.sqrt(Math.max(0, getVal(node) || 1)) * state.nodeRelSize; + + const nodesPos = state.graphData.nodes.filter(nodeFilter).map((node) => ({ + x: node.x, + y: node.y, + r: getR(node), + })); + return !nodesPos.length + ? null + : { + x: [d3Min(nodesPos, (node) => node.x - node.r), d3Max(nodesPos, (node) => node.x + node.r)], + y: [d3Min(nodesPos, (node) => node.y - node.r), d3Max(nodesPos, (node) => node.y + node.r)], + }; + }, + pauseAnimation: function (state) { + if (state.animationFrameRequestId) { + cancelAnimationFrame(state.animationFrameRequestId); + state.animationFrameRequestId = null; + } + return this; + }, + resumeAnimation: function (state) { + if (!state.animationFrameRequestId) { + this._animationCycle(); + } + return this; + }, + _destructor: function (state) { + this.pauseAnimation(); + this.graphData({ nodes: [], links: [] }); + }, + getState: (state) => { + return state; + }, + ...linkedMethods, + }, + + stateInit: () => ({ + lastSetZoom: 1, + zoom: d3Zoom(), + forceGraph: new CanvasForceGraph(), + shadowGraph: new CanvasForceGraph() + .cooldownTicks(0) + .nodeColor('__indexColor') + .linkColor('__indexColor') + .isShadow(true), + colorTracker: new ColorTracker(), // indexed objects for rgb lookup + }), + + init: function (domNode, state) { + // Wipe DOM + domNode.innerHTML = ''; + state.domNode = domNode; + // Container anchor for canvas and tooltip + const container = document.createElement('div'); + container.classList.add('force-graph-container'); + container.style.position = 'relative'; + domNode.appendChild(container); + + state.canvas = document.createElement('canvas'); + if (state.backgroundColor) state.canvas.style.background = state.backgroundColor; + container.appendChild(state.canvas); + + state.shadowCanvas = document.createElement('canvas'); + + // Show shadow canvas + state.shadowCanvas.style.position = 'absolute'; + state.shadowCanvas.style.top = '0'; + state.shadowCanvas.style.left = '0'; + state.shadowCanvas.style.pointerEvents = 'none'; + state.shadowCanvas.style.opacity = 0.5; + // container.appendChild(state.shadowCanvas); + + const ctx = state.canvas.getContext('2d'); + const shadowCtx = state.shadowCanvas.getContext('2d'); + + const pointerPos = { x: -1e12, y: -1e12 }; + const getObjUnderPointer = () => { + let obj = null; + const pxScale = window.devicePixelRatio; + const px = + pointerPos.x > 0 && pointerPos.y > 0 + ? shadowCtx.getImageData(pointerPos.x * pxScale, pointerPos.y * pxScale, 1, 1) + : null; + if (px && px.data && px.data[3] !== 0) { + } + // Lookup object per pixel color + px && (obj = state.colorTracker.lookup(px.data)); + // if (obj) { + // console.log(px.data, obj.d) + // } + return obj; + }; + + // Setup node drag interaction + d3Select(state.canvas).call( + d3Drag() + .subject(() => { + if (!state.enableNodeDrag) { + return null; + } + const obj = getObjUnderPointer(); + return obj && obj.type === 'Node' ? obj.d : null; // Only drag nodes + }) + .on('start', (ev) => { + const obj = ev.subject; + obj.__initialDragPos = { x: obj.x, y: obj.y, fx: obj.fx, fy: obj.fy }; + + // keep engine running at low intensity throughout drag + if (!ev.active) { + obj.fx = obj.x; + obj.fy = obj.y; // Fix points + } + + // drag cursor + state.canvas.classList.add('grabbable'); + }) + .on('drag', (ev) => { + const obj = ev.subject; + const initPos = obj.__initialDragPos; + const dragPos = ev; + + const k = d3ZoomTransform(state.canvas).k; + const translate = { + x: initPos.x + (dragPos.x - initPos.x) / k - obj.x, + y: initPos.y + (dragPos.y - initPos.y) / k - obj.y, + }; + + // Move fx/fy (and x/y) of nodes based on the scaled drag distance since the drag start + ['x', 'y'].forEach((c) => (obj[`f${c}`] = obj[c] = initPos[c] + (dragPos[c] - initPos[c]) / k)); + + // prevent freeze while dragging + state.forceGraph + .d3AlphaTarget(0.3) // keep engine running at low intensity throughout drag + .resetCountdown(); // prevent freeze while dragging + + state.isPointerDragging = true; + + obj.__dragged = true; + state.onNodeDrag(obj, translate); + }) + .on('end', (ev) => { + const obj = ev.subject; + const initPos = obj.__initialDragPos; + const translate = { x: obj.x - initPos.x, y: obj.y - initPos.y }; + + if (initPos.fx === undefined) { + obj.fx = undefined; + } + if (initPos.fy === undefined) { + obj.fy = undefined; + } + delete obj.__initialDragPos; + + if (state.forceGraph.d3AlphaTarget()) { + state.forceGraph + .d3AlphaTarget(0) // release engine low intensity + .resetCountdown(); // let the engine readjust after releasing fixed nodes + } + + // drag cursor + state.canvas.classList.remove('grabbable'); + + state.isPointerDragging = false; + + if (obj.__dragged) { + delete obj.__dragged; + state.onNodeDragEnd(obj, translate); + } + }) + ); + + // Setup zoom / pan interaction + state.zoom((state.zoom.__baseElem = d3Select(state.canvas))); // Attach controlling elem for easy access + + state.zoom.__baseElem.on('dblclick.zoom', null); // Disable double-click to zoom + + state.zoom + .filter( + (ev) => + // disable zoom interaction + !ev.button && + state.enableZoomPanInteraction && + (state.enableZoomInteraction || ev.type !== 'wheel') && + (state.enablePanInteraction || ev.type === 'wheel') + ) + .on('zoom', (ev) => { + const t = ev.transform; + [ctx, shadowCtx].forEach((c) => { + resetTransform(c); + c.translate(t.x, t.y); + c.scale(t.k, t.k); + }); + state.onZoom && state.onZoom({ ...t, ...this.centerAt() }); // report x,y coordinates relative to canvas center + state.needsRedraw = true; + }) + .on('end', (ev) => state.onZoomEnd && state.onZoomEnd({ ...ev.transform, ...this.centerAt() })); + + adjustCanvasSize(state); + + state.forceGraph + .onNeedsRedraw(() => (state.needsRedraw = true)) + .onFinishUpdate(() => { + // re-zoom, if still in default position (not user modified) + if (d3ZoomTransform(state.canvas).k === state.lastSetZoom && state.graphData.nodes.length) { + state.zoom.scaleTo( + state.zoom.__baseElem, + (state.lastSetZoom = ZOOM2NODES_FACTOR / Math.cbrt(state.graphData.nodes.length)) + ); + state.needsRedraw = true; + } + }); + + // Setup tooltip + const toolTipElem = document.createElement('div'); + // toolTipElem.classList.add('graph-tooltip'); + // container.appendChild(toolTipElem); + + // Capture pointer coords on move or touchstart + ['pointermove', 'pointerdown'].forEach((evType) => + container.addEventListener( + evType, + (ev) => { + if (evType === 'pointerdown') { + state.isPointerPressed = true; // track click state + state.pointerDownEvent = ev; + } + + // detect pointer drag on canvas pan + !state.isPointerDragging && + ev.type === 'pointermove' && + state.onBackgroundClick && // only bother detecting drags this way if background clicks are enabled (so they don't trigger accidentally on canvas panning) + (ev.pressure > 0 || state.isPointerPressed) && // ev.pressure always 0 on Safari, so we use the isPointerPressed tracker + (ev.pointerType !== 'touch' || + ev.movementX === undefined || + [ev.movementX, ev.movementY].some((m) => Math.abs(m) > 1)) && // relax drag trigger sensitivity on touch events + (state.isPointerDragging = true); + + // update the pointer pos + const offset = getOffset(container); + pointerPos.x = ev.pageX - offset.left; + pointerPos.y = ev.pageY - offset.top; + + // Move tooltip + toolTipElem.style.top = `${pointerPos.y}px`; + toolTipElem.style.left = `${pointerPos.x}px`; + + // + + function getOffset(el) { + const rect = el.getBoundingClientRect(), + scrollLeft = window.pageXOffset || document.documentElement.scrollLeft, + scrollTop = window.pageYOffset || document.documentElement.scrollTop; + return { top: rect.top + scrollTop, left: rect.left + scrollLeft }; + } + }, + { passive: true } + ) + ); + + // Handle click/touch events on nodes/links + container.addEventListener( + 'pointerup', + (ev) => { + state.isPointerPressed = false; + if (state.isPointerDragging) { + state.isPointerDragging = false; + return; // don't trigger click events after pointer drag (pan / node drag functionality) + } + + const cbEvents = [ev, state.pointerDownEvent]; + requestAnimationFrame(() => { + // trigger click events asynchronously, to allow hoverObj to be set (on frame) + if (ev.button === 0) { + // mouse left-click or touch + if (state.hoverObj) { + const fn = state[`on${state.hoverObj.type}Click`]; + fn && fn(state.hoverObj.d, ...cbEvents); + } else { + state.onBackgroundClick && state.onBackgroundClick(...cbEvents); + } + } + + if (ev.button === 2) { + // mouse right-click + if (state.hoverObj) { + const fn = state[`on${state.hoverObj.type}RightClick`]; + fn && fn(state.hoverObj.d, ...cbEvents); + } else { + state.onBackgroundRightClick && state.onBackgroundRightClick(...cbEvents); + } + } + }); + }, + { passive: true } + ); + + container.addEventListener('contextmenu', (ev) => { + if (!state.onBackgroundRightClick && !state.onNodeRightClick && !state.onLinkRightClick) return true; // default contextmenu behavior + ev.preventDefault(); + return false; + }); + + state.forceGraph(ctx); + state.shadowGraph(shadowCtx); + state.ctx = ctx; + // + + const refreshShadowCanvas = throttle(() => { + // wipe canvas + clearCanvas(shadowCtx, state.width, state.height); + + // Adjust link hover area + state.shadowGraph.linkWidth((l) => accessorFn(state.linkWidth)(l) + state.linkHoverPrecision); + + // redraw + const t = d3ZoomTransform(state.canvas); + state.shadowGraph.globalScale(t.k).tickFrame(); + }, HOVER_CANVAS_THROTTLE_DELAY); + state.flushShadowCanvas = refreshShadowCanvas.flush; // hook to immediately invoke shadow canvas paint + + // Kick-off renderer + (this._animationCycle = function animate() { + // IIFE + const doRedraw = + !state.autoPauseRedraw || + !!state.needsRedraw || + state.forceGraph.isEngineRunning() || + state.graphData.links.some((d) => d.__photons && d.__photons.length); + state.needsRedraw = false; + + if (state.enablePointerInteraction) { + doRedraw && refreshShadowCanvas(); + + // Update tooltip and trigger onHover events + const obj = !state.isPointerDragging ? getObjUnderPointer() : null; // don't hover during drag + if (obj !== state.hoverObj) { + const prevObj = state.hoverObj; + const prevObjType = prevObj ? prevObj.type : null; + const objType = obj ? obj.type : null; + + if (prevObjType && prevObjType !== objType) { + // Hover out + const fn = state[`on${prevObjType}Hover`]; + fn && fn(null, prevObj.d); + } + if (objType) { + // Hover in + const fn = state[`on${objType}Hover`]; + fn && fn(obj.d, prevObjType === objType ? prevObj.d : null); + } + + const tooltipContent = obj ? accessorFn(state[`${obj.type.toLowerCase()}Label`])(obj.d) || '' : ''; + toolTipElem.style.visibility = tooltipContent ? 'visible' : 'hidden'; + toolTipElem.innerHTML = tooltipContent; + + // set pointer if hovered object is clickable + state.canvas.classList[ + (obj && state[`on${objType}Click`]) || (!obj && state.onBackgroundClick) ? 'add' : 'remove' + ]('clickable'); + + state.hoverObj = obj; + } + } + + if (doRedraw) { + // Wipe canvas + clearCanvas(ctx, state.width, state.height); + + // Frame cycle + const globalScale = d3ZoomTransform(state.canvas).k; + state.onRenderFramePre && state.onRenderFramePre(ctx, globalScale); + state.forceGraph.globalScale(globalScale).tickFrame(); + state.onRenderFramePost && state.onRenderFramePost(ctx, globalScale); + } + + TWEEN.update(); // update canvas animation tweens + + state.animationFrameRequestId = requestAnimationFrame(animate); + })(); + }, + + update: function updateFn(state) {}, +}); diff --git a/app/components/ForceGraph/index.js b/app/components/ForceGraph/index.js new file mode 100644 index 00000000..015068f1 --- /dev/null +++ b/app/components/ForceGraph/index.js @@ -0,0 +1,2 @@ +import './force-graph.css'; +export { default } from "./force-graph"; \ No newline at end of file diff --git a/app/components/ForceGraph/kapsule-link.js b/app/components/ForceGraph/kapsule-link.js new file mode 100644 index 00000000..41cff6e9 --- /dev/null +++ b/app/components/ForceGraph/kapsule-link.js @@ -0,0 +1,34 @@ +export default function(kapsulePropNames, kapsuleType) { + + const propNames = kapsulePropNames instanceof Array ? kapsulePropNames : [kapsulePropNames]; + + const dummyK = new kapsuleType(); // To extract defaults + + return { + linkProp: function(prop) { // link property config + return { + default: dummyK[prop](), + onChange(v, state) { propNames.forEach(propName => state[propName][prop](v)) }, + triggerUpdate: false + } + }, + linkMethod: function(method) { // link method pass-through + return function(state, ...args) { + const returnVals = []; + propNames.forEach(propName => { + const kapsuleInstance = state[propName]; + const returnVal = kapsuleInstance[method](...args); + + if (returnVal !== kapsuleInstance) { + returnVals.push(returnVal); + } + }); + + return returnVals.length + ? returnVals[0] + : this; // chain based on the parent object, not the inner kapsule + } + } + } + +} \ No newline at end of file diff --git a/app/config/explore.ts b/app/config/explore.ts index e7461073..2524c182 100644 --- a/app/config/explore.ts +++ b/app/config/explore.ts @@ -1,57 +1,13 @@ +import { NodeObject } from '@app/components/ForceGraph'; import BigNumber from 'bignumber.js'; import JSONBigint from 'json-bigint'; import json2csv from 'json2csv'; +import { remove } from 'lodash'; +export const LINE_LENGTH = 150; +export const FONT_SIZE = 10; +export const NODE_SIZE = 18; +export const NODE_AREA = NODE_SIZE * NODE_SIZE; -import { INode, IPath } from '#app/utils/interface'; - -export const MIN_SCALE = 0.3; -export const MAX_SCALE = 1; - -export const HOT_KEYS = intl => [ - { - operation: `Shift + 'Enter'`, - desc: intl.get('explore.expand'), - }, - { - operation: `Shift + '-'`, - desc: intl.get('common.zoomOut'), - }, - { - operation: `Shift + '+'`, - desc: intl.get('common.zoomIn'), - }, - { - operation: `Shift + 'l'`, - desc: intl.get('common.show'), - }, - { - operation: `Shift + 'z'`, - desc: intl.get('common.rollback'), - }, - { - operation: intl.get('common.selected') + ` + Shift + 'del'`, - desc: intl.get('common.delete'), - }, -]; - -export const GRAPH_ALOGORITHM = intl => [ - { - label: intl.get('explore.allPath'), - value: 'ALL', - }, - { - label: intl.get('explore.shortestPath'), - value: 'SHORTEST', - }, - { - label: intl.get('explore.noLoopPath'), - value: 'NOLOOP', - }, -]; - -export const DEFAULT_COLOR_PICKER = '#5CDBD3'; -export const DEFAULT_COLOR_MIX = - 'linear-gradient(225deg, #32C5FF 0%, #B620E0 51%, #F7B500 100%)'; export const COLOR_PICK_LIST = [ '#B93431', '#B95C31', @@ -167,7 +123,7 @@ export const downloadCSVFiles = ({ headers, tables, title }) => { const BOM = '\uFEFF'; const csvData = new Blob([BOM + result], { type: 'text/csv' }); // @ts-ignore - navigator.msSaveBlob(csvData, `test.csv`); + navigator.msSaveBlob?.(csvData, `test.csv`); } else { // Non-Internet Explorer const csvContent = 'data:text/csv;charset=utf-8,\uFEFF' + result; @@ -184,7 +140,7 @@ export const downloadCSVFiles = ({ headers, tables, title }) => { } }; -export const parseData = (data: INode[] | IPath[], type: 'vertex' | 'edge') => { +export const parseData = (data, type: 'vertex' | 'edge') => { const fields = type === 'vertex' ? ['vid', 'attributes'] @@ -192,17 +148,16 @@ export const parseData = (data: INode[] | IPath[], type: 'vertex' | 'edge') => { const tables: any = []; data.forEach((item: any) => { const _result = {} as any; - const properties = - type === 'vertex' ? item.nodeProp.properties : item.edgeProp.properties; + const properties = item.properties; const { result } = flattenData(properties) as any; if (type === 'vertex') { - _result.vid = item.name; + _result.vid = item.id; _result.attributes = JSONBigint.stringify(result); tables.push(_result); } else if (type === 'edge') { - _result.type = item.type; - _result.srcId = item.source.name; - _result.dstId = item.target.name; + _result.type = item.edgeType; + _result.srcId = item.source; + _result.dstId = item.target; _result.rank = item.rank; _result.attributes = JSONBigint.stringify(result); tables.push(_result); @@ -212,17 +167,85 @@ export const parseData = (data: INode[] | IPath[], type: 'vertex' | 'edge') => { }; export const exportDataToCSV = ( - data: INode[] | IPath[], + data, type: 'vertex' | 'edge', ) => { const { headers, tables } = parseData(data, type); downloadCSVFiles({ headers, tables, title: type }); }; -export const DEFAULT_EXPLORE_RULES = { - edgeTypes: [], - edgeDirection: 'outgoing', - stepsType: 'single', - step: 1, - vertexStyle: 'colorGroupByTag', - quantityLimit: 100, + +export const updateTagMap = (tagMap, vertexes) => { + Object.keys(tagMap).forEach(tag => { + const colorGroup = tagMap[tag]; + colorGroup.forEach(colorMap => { + colorMap.countIds = []; + }); + }); + vertexes.forEach(vertex => { + const { color, tags = [], id } = vertex; + const group = tags.sort().join('-'); + const colorMap = tagMap[group]; + if (colorMap) { + const hasColor = colorMap.some(item => { + if (item.color === color && !item.countIds.includes(id)) { + item.countIds.push(String(id)); + return true; + } + }); + if (!hasColor) { + colorMap.push({ + color, + countIds: [String(id)] + }); + } + } else { + tagMap[group] = [{ + color, + countIds: [String(id)] + }]; + } + }); + // remove color without data, but need to remain one + Object.keys(tagMap).forEach(tag => { + const colorGroup = tagMap[tag]; + const noDataList = colorGroup.filter(item => item.countIds.length === 0).map(item => item.color); + const removeList = colorGroup.length === noDataList.length ? noDataList.slice(1) : noDataList; + remove(tagMap[tag], (item: any) => removeList.includes(item.color)); + }); + return { ...tagMap }; +}; + +export const updateEdgeMap = (edgeMap, edges) => { + Object.keys(edgeMap).forEach(item => { + edgeMap[item] = { + countIds: [] + }; + }); + edges.forEach(edge => { + const { edgeType, id } = edge; + edgeMap[edgeType].countIds = [...edgeMap[edgeType].countIds, id]; + }); + return { ...edgeMap }; +}; +export const makeRoundPosition = (data: NodeObject[] | Set, center: { x: number; y: number }) => { + const nodes = [...data]; + const length = nodes.length; + const radius = Math.min(Math.max((length * NODE_SIZE) / 2, LINE_LENGTH), window.innerHeight / 2); + // when nodes.length>50 use sphere layout + if (nodes.length < 50) { + nodes.forEach((node, index) => { + const angle = (index / length) * Math.PI * 2; + node.x = radius * Math.sin(angle) + center.x; + node.y = radius * Math.cos(angle) + center.y; + }); + } else { + nodes.forEach((node) => { + const angle = Math.random() * Math.PI * 2; + const r = radius * Math.random(); + node.x = r * Math.sin(angle) + center.x; + node.y = r * Math.cos(angle) + center.y; + }); + } + }; +export const CANVAS_HIDE_LABEL_SCALE = 1.0; \ No newline at end of file diff --git a/app/config/locale/en-US.json b/app/config/locale/en-US.json index d702932f..4f8c933b 100644 --- a/app/config/locale/en-US.json +++ b/app/config/locale/en-US.json @@ -170,7 +170,7 @@ "showTags": "Show Tags", "showEdges": "Show Edges", "confirm": "Confirm", - "vertexStyle": "Vertex Color/Icon", + "vertexStyle": "Vertex Color", "quantityLimit": "Query Limit", "colorGroupByTag": "Group by vertex tag", "noVertexPrompt": "No vertices on the board. ", diff --git a/app/config/locale/zh-CN.json b/app/config/locale/zh-CN.json index 7dc9f84f..974295d7 100644 --- a/app/config/locale/zh-CN.json +++ b/app/config/locale/zh-CN.json @@ -169,7 +169,7 @@ "showTags": "显示点", "showEdges": "显示边", "confirm": "确定", - "vertexStyle": "节点颜色/图标", + "vertexStyle": "节点颜色", "quantityLimit": "结果数量限制", "colorGroupByTag": "按标签类型分类", "noVertexPrompt": "当前画板没有点数据,请", diff --git a/app/global.d.ts b/app/global.d.ts new file mode 100644 index 00000000..7f35229d --- /dev/null +++ b/app/global.d.ts @@ -0,0 +1,225 @@ +declare module '*.svg'; +declare module '*.png'; +declare module '*.jpg'; +declare module '*.jpeg'; +declare module '*.gif'; +declare module '*.bmp'; +declare module '*.tiff'; + +declare module '@app/components/ForceGraph' { + export interface GraphData { + nodes: NodeObject[]; + links: LinkObject[]; + } + + export type NodeObject = { + [s: string]: any; + id: string; + x?: number; + y?: number; + degree?: number; + z?: number; + vx?: number; + vy?: number; + vz?: number; + fx?: number; + fy?: number; + }; + + export type LinkObject = { + id?: string; + source?: string | number | NodeObject; + target?: string | number | NodeObject; + [s: string]: any; + }; + + type Accessor = Out | string | ((obj: In) => Out); + type NodeAccessor = Accessor; + type LinkAccessor = Accessor; + + type CanvasCustomRenderMode = 'replace' | 'before' | 'after'; + type CanvasCustomRenderFn = ( + obj: T, + canvasContext: CanvasRenderingContext2D, + globalScale: number, + isShadow: boolean, + ) => void; + type CanvasPointerAreaPaintFn = ( + obj: T, + paintColor: string, + canvasContext: CanvasRenderingContext2D, + globalScale: number, + ) => void; + + export type DagMode = 'td' | 'bu' | 'lr' | 'rl' | 'radialout' | 'radialin'; + + interface ForceFn { + (alpha: number): void; + initialize?: (nodes: NodeObject[]) => void; + [key: string]: any; + } + + export interface ForceGraphGenericInstance { + (element: HTMLElement): ChainableInstance; + resetProps(): ChainableInstance; + _destructor(): void; + + // Data input + graphData(): GraphData; + graphData(data: GraphData): ChainableInstance; + nodeId(): string; + nodeId(id: string): ChainableInstance; + linkSource(): string; + linkSource(source: string): ChainableInstance; + linkTarget(): string; + linkTarget(target: string): ChainableInstance; + + // Container layout + width(): number; + width(width: number): ChainableInstance; + height(): number; + height(height: number): ChainableInstance; + backgroundColor(): string; + backgroundColor(color?: string): ChainableInstance; + + // Node styling + nodeRelSize(): number; + nodeRelSize(size: number): ChainableInstance; + nodeVal(): NodeAccessor; + nodeVal(valAccessor: NodeAccessor): ChainableInstance; + nodeLabel(): NodeAccessor; + nodeLabel(labelAccessor: NodeAccessor): ChainableInstance; + nodeVisibility(): NodeAccessor; + nodeVisibility(visibilityAccessor: NodeAccessor): ChainableInstance; + nodeColor(): NodeAccessor; + nodeColor(colorAccessor: NodeAccessor): ChainableInstance; + nodeAutoColorBy(): NodeAccessor; + nodeAutoColorBy(colorByAccessor: NodeAccessor): ChainableInstance; + nodeCanvasObject(): CanvasCustomRenderFn; + nodeCanvasObject(renderFn: CanvasCustomRenderFn): ChainableInstance; + nodeCanvasObjectMode(): string | ((obj: NodeObject) => CanvasCustomRenderMode); + nodeCanvasObjectMode(modeAccessor: string | ((obj: NodeObject) => CanvasCustomRenderMode)): ChainableInstance; + nodePointerAreaPaint(): CanvasPointerAreaPaintFn; + nodePointerAreaPaint(renderFn: CanvasPointerAreaPaintFn): ChainableInstance; + + // Link styling + linkLabel(): LinkAccessor; + linkLabel(labelAccessor: LinkAccessor): ChainableInstance; + linkVisibility(): LinkAccessor; + linkVisibility(visibilityAccessor: LinkAccessor): ChainableInstance; + linkColor(): LinkAccessor; + linkColor(colorAccessor: LinkAccessor): ChainableInstance; + linkAutoColorBy(): LinkAccessor; + linkAutoColorBy(colorByAccessor: LinkAccessor): ChainableInstance; + linkLineDash(): LinkAccessor; + linkLineDash(linkLineDashAccessor: LinkAccessor): ChainableInstance; + linkWidth(): LinkAccessor; + linkWidth(widthAccessor: LinkAccessor): ChainableInstance; + linkCurvature(): LinkAccessor; + linkCurvature(curvatureAccessor: LinkAccessor): ChainableInstance; + linkCanvasObject(): CanvasCustomRenderFn; + linkCanvasObject(renderFn: CanvasCustomRenderFn): ChainableInstance; + linkCanvasObjectMode(): string | ((obj: LinkObject) => CanvasCustomRenderMode); + linkCanvasObjectMode(modeAccessor: string | ((obj: LinkObject) => CanvasCustomRenderMode)): ChainableInstance; + linkDirectionalArrowLength(): LinkAccessor; + linkDirectionalArrowLength(lengthAccessor: LinkAccessor): ChainableInstance; + linkDirectionalArrowColor(): LinkAccessor; + linkDirectionalArrowColor(colorAccessor: LinkAccessor): ChainableInstance; + linkDirectionalArrowRelPos(): LinkAccessor; + linkDirectionalArrowRelPos(fractionAccessor: LinkAccessor): ChainableInstance; + linkDirectionalParticles(): LinkAccessor; + linkDirectionalParticles(numParticlesAccessor: LinkAccessor): ChainableInstance; + linkDirectionalParticleSpeed(): LinkAccessor; + linkDirectionalParticleSpeed(relDistancePerFrameAccessor: LinkAccessor): ChainableInstance; + linkDirectionalParticleWidth(): LinkAccessor; + linkDirectionalParticleWidth(widthAccessor: LinkAccessor): ChainableInstance; + linkDirectionalParticleColor(): LinkAccessor; + linkDirectionalParticleColor(colorAccessor: LinkAccessor): ChainableInstance; + emitParticle(link: LinkObject): ChainableInstance; + linkPointerAreaPaint(): CanvasPointerAreaPaintFn; + linkPointerAreaPaint(renderFn: CanvasPointerAreaPaintFn): ChainableInstance; + + // Render control + autoPauseRedraw(): boolean; + autoPauseRedraw(enable?: boolean): ChainableInstance; + pauseAnimation(): ChainableInstance; + resumeAnimation(): ChainableInstance; + centerAt(): { x: number; y: number }; + centerAt(x?: number, y?: number, durationMs?: number): ChainableInstance; + zoom(): number; + zoom(scale: number, durationMs?: number): ChainableInstance; + zoomToFit(durationMs?: number, padding?: number, nodeFilter?: (node: NodeObject) => boolean): ChainableInstance; + minZoom(): number; + minZoom(scale: number): ChainableInstance; + maxZoom(): number; + maxZoom(scale: number): ChainableInstance; + onRenderFramePre( + callback: (canvasContext: CanvasRenderingContext2D, globalScale: number) => void, + ): ChainableInstance; + onRenderFramePost( + callback: (canvasContext: CanvasRenderingContext2D, globalScale: number) => void, + ): ChainableInstance; + + // Force engine (d3-force) configuration + dagMode(): DagMode; + dagMode(mode: DagMode): ChainableInstance; + dagLevelDistance(): number | null; + dagLevelDistance(distance: number): ChainableInstance; + dagNodeFilter(): (node: NodeObject) => boolean; + dagNodeFilter(filterFn: (node: NodeObject) => boolean): ChainableInstance; + onDagError(): (loopNodeIds: (string | number)[]) => void; + onDagError(errorHandleFn: (loopNodeIds: (string | number)[]) => void): ChainableInstance; + d3AlphaMin(): number; + d3AlphaMin(alphaMin: number): ChainableInstance; + d3AlphaDecay(): number; + d3AlphaDecay(alphaDecay: number): ChainableInstance; + d3VelocityDecay(): number; + d3VelocityDecay(velocityDecay: number): ChainableInstance; + d3Force(forceName: 'link' | 'charge' | 'center' | string): ForceFn | undefined; + d3Force(forceName: 'link' | 'charge' | 'center' | string, forceFn: ForceFn): ChainableInstance; + d3ReheatSimulation(): ChainableInstance; + warmupTicks(): number; + warmupTicks(ticks: number): ChainableInstance; + cooldownTicks(): number; + cooldownTicks(ticks: number): ChainableInstance; + cooldownTime(): number; + cooldownTime(milliseconds: number): ChainableInstance; + onEngineTick(callback: () => void): ChainableInstance; + onEngineStop(callback: () => void): ChainableInstance; + + // Interaction + onNodeClick(callback: (node: NodeObject, event: MouseEvent) => void): ChainableInstance; + onNodeRightClick(callback: (node: NodeObject, event: MouseEvent) => void): ChainableInstance; + onNodeHover(callback: (node: NodeObject | null, previousNode: NodeObject | null) => void): ChainableInstance; + onNodeDrag(callback: (node: NodeObject, translate: { x: number; y: number }) => void): ChainableInstance; + onNodeDragEnd(callback: (node: NodeObject, translate: { x: number; y: number }) => void): ChainableInstance; + onLinkClick(callback: (link: LinkObject, event: MouseEvent) => void): ChainableInstance; + onLinkRightClick(callback: (link: LinkObject, event: MouseEvent) => void): ChainableInstance; + onLinkHover(callback: (link: LinkObject | null, previousLink: LinkObject | null) => void): ChainableInstance; + linkHoverPrecision(): number; + linkHoverPrecision(precision: number): ChainableInstance; + onBackgroundClick(callback: (event: MouseEvent) => void): ChainableInstance; + onBackgroundRightClick(callback: (event: MouseEvent) => void): ChainableInstance; + onZoom(callback: (transform: { k: number; x: number; y: number }) => void): ChainableInstance; + onZoomEnd(callback: (transform: { k: number; x: number; y: number }) => void): ChainableInstance; + enableNodeDrag(): boolean; + enableNodeDrag(enable: boolean): ChainableInstance; + enableZoomInteraction(): boolean; + enableZoomInteraction(enable: boolean): ChainableInstance; + enablePanInteraction(): boolean; + enablePanInteraction(enable: boolean): ChainableInstance; + enablePointerInteraction(): boolean; + enablePointerInteraction(enable?: boolean): ChainableInstance; + getState(): any; + // Utility + getGraphBbox(nodeFilter?: (node: NodeObject) => boolean): { x: [number, number]; y: [number, number] }; + screen2GraphCoords(x: number, y: number): { x: number; y: number }; + graph2ScreenCoords(x: number, y: number): { x: number; y: number }; + } + + export type ForceGraphInstance = ForceGraphGenericInstance; + + declare function ForceGraph(): ForceGraphInstance; + + export default ForceGraph; +} diff --git a/app/index.html b/app/index.html index 85abb767..f6e993f8 100644 --- a/app/index.html +++ b/app/index.html @@ -74,7 +74,7 @@ -
+
diff --git a/app/index.tsx b/app/index.tsx index 18927d0f..0bf3588b 100644 --- a/app/index.tsx +++ b/app/index.tsx @@ -4,14 +4,14 @@ import React, { Suspense, lazy, useState } from 'react'; import ReactDom from 'react-dom'; import { Route, BrowserRouter as Router, Switch, useHistory } from 'react-router-dom'; import { observer } from 'mobx-react-lite'; -import rootStore, { StoreProvider } from './stores'; import dayjs from 'dayjs'; import intl from 'react-intl-universal'; import duration from 'dayjs/plugin/duration'; -import AuthorizedRoute from './AuthorizedRoute'; import Cookie from 'js-cookie'; import { INTL_LOCALES } from '@app/config/constants'; import { LanguageContext } from '@app/context'; +import AuthorizedRoute from './AuthorizedRoute'; +import rootStore, { StoreProvider } from './stores'; const Login = lazy(() => import('@app/pages/Login')); const MainPage = lazy(() => import('@app/pages/MainPage')); @@ -73,4 +73,4 @@ const App = () => { const HotPageRoot = hot(PageRoot); -ReactDom.render(, document.getElementById('app')); +ReactDom.render(, document.getElementById('studio-app')); diff --git a/app/interfaces/graph.ts b/app/interfaces/graph.ts new file mode 100644 index 00000000..8af03b5e --- /dev/null +++ b/app/interfaces/graph.ts @@ -0,0 +1,13 @@ +import { LinkObject, NodeObject } from '@app/components/ForceGraph'; + +export interface IDataMap { + [key: string]: NodeObject | LinkObject; +} + +export interface Pointer { + top: number; + left: number; + event?: any; + node?: NodeObject; + showContextMenu?: boolean; +} diff --git a/app/pages/Console/FavoriteBtn.tsx b/app/pages/Console/FavoriteBtn.tsx index 789d05ab..66e7253e 100644 --- a/app/pages/Console/FavoriteBtn.tsx +++ b/app/pages/Console/FavoriteBtn.tsx @@ -21,7 +21,7 @@ const FavoriteBtn = (props: IProps) => { useEffect(() => { if (favorites[username] && favorites[username][host]) { - setData(favorites[username][host].reverse()); + setData(favorites[username][host]); } }, [favorites]); diff --git a/app/pages/Console/HistoryBtn.tsx b/app/pages/Console/HistoryBtn.tsx index 35bac1ff..0370e601 100644 --- a/app/pages/Console/HistoryBtn.tsx +++ b/app/pages/Console/HistoryBtn.tsx @@ -14,14 +14,14 @@ const HistoryBtn = (props: IProps) => { const [data, setData] = useState([]); const handleView = () => { const data = getHistory(); - setData(data.reverse()); + setData(data); setVisible(true); }; const getHistory = () => { const value: string | null = localStorage.getItem('history'); if (value && value !== 'undefined' && value !== 'null') { - return JSON.parse(value).slice(-15); + return JSON.parse(value).slice(0, 15); } return []; }; diff --git a/app/pages/Console/OutputBox/ForceGraph/BrushSelect/index.ts b/app/pages/Console/OutputBox/ForceGraph/BrushSelect/index.ts new file mode 100644 index 00000000..339c6876 --- /dev/null +++ b/app/pages/Console/OutputBox/ForceGraph/BrushSelect/index.ts @@ -0,0 +1,173 @@ +import { GraphStore } from '@app/stores/graph'; +import graphInstancesStore from '@app/stores/graphInstances'; +import { NodeObject } from '@app/components/ForceGraph'; +import { trackEvent } from '@app/utils/stat'; +const brushCss = { + position: 'absolute', + 'z-index': 300000, + border: 'dotted 1px #3e74cc', + 'background-color': 'rgba(255, 255, 255, 0.5)', + 'pointer-events': 'none', +}; + +const brushCssText = Object.keys(brushCss) + .reduce((ret, key) => (ret.push(`${key}:${brushCss[key]};`), ret), [] as string[]) + .join(''); + +interface BrushSelectProps { + graphStore: GraphStore; + container: HTMLElement; +} + +export default class BrushSelect { + graphStore: BrushSelectProps['graphStore']; + container: BrushSelectProps['container']; + + selectDom?: HTMLDivElement; + selectStart?: { x: number; y: number }; + + isMoving = false; + isRightSelecting = false; + + constructor({ graphStore, container }: BrushSelectProps) { + this.graphStore = graphStore; + this.container = container; + this.#addEventListener(); + } + + #initSelectDom(e: PointerEvent) { + this.selectDom = document.createElement('div'); + // this.selectDom.classList.add(styles.graphBoxSelect); + this.selectDom.style.cssText = brushCssText; + this.selectDom.style.left = e.offsetX.toString() + 'px'; + this.selectDom.style.top = e.offsetY.toString() + 'px'; + this.container.appendChild(this.selectDom); + } + + #removeSelectDom() { + this.selectDom?.remove(); + this.selectDom = undefined; + } + + #addEventListener() { + (['pointerdown', 'pointermove', 'pointerup', 'pointerout'] as const).forEach((eventName) => { + this.container.addEventListener(eventName, this[eventName]); + }); + } + + pointerdown = (e: PointerEvent) => { + if (!e.shiftKey && e.button !== 2) { + return; + } + if (e.button === 2) { + this.isRightSelecting = true; + } + + e.preventDefault(); + this.#initSelectDom(e); + this.selectStart = { + x: e.offsetX, + y: e.offsetY, + }; + }; + + pointermove = (e: PointerEvent) => { + if ((!e.shiftKey && !this.isRightSelecting) || !this.selectDom) { + return; + } + + e.preventDefault(); + this.isMoving = true; + if (e.offsetX < this.selectStart!.x) { + this.selectDom.style.left = e.offsetX.toString() + 'px'; + this.selectDom.style.width = (this.selectStart!.x - e.offsetX).toString() + 'px'; + } else { + this.selectDom.style.left = this.selectStart!.x.toString() + 'px'; + this.selectDom.style.width = (e.offsetX - this.selectStart!.x).toString() + 'px'; + } + if (e.offsetY < this.selectStart!.y) { + this.selectDom.style.top = e.offsetY.toString() + 'px'; + this.selectDom.style.height = (this.selectStart!.y - e.offsetY).toString() + 'px'; + } else { + this.selectDom.style.top = this.selectStart!.y.toString() + 'px'; + this.selectDom.style.height = (e.offsetY - this.selectStart!.y).toString() + 'px'; + } + }; + + pointerup = (e: PointerEvent) => { + this.isRightSelecting = false; + if (!this.selectDom || !this.isMoving) { + this.#removeSelectDom(); + return; + } + + e.preventDefault(); + + const { top, left, width, height } = this.selectDom.style; + const rect = { + left: parseInt(left), + top: parseInt(top), + right: parseInt(left) + parseInt(width), + bottom: parseInt(top) + parseInt(height), + }; + + if (e.shiftKey) { + if (e.offsetX < this.selectStart!.x) { + rect.left = e.offsetX; + rect.right = this.selectStart!.x; + } else { + rect.left = this.selectStart!.x; + rect.right = e.offsetX; + } + + if (e.offsetY < this.selectStart!.y) { + rect.top = e.offsetY; + rect.bottom = this.selectStart!.y; + } else { + rect.top = this.selectStart!.y; + rect.bottom = e.offsetY; + } + } + + this.isMoving = false; + this.#removeSelectDom(); + const { nodes, links } = this.runBoxSelect(rect); + this.graphStore.selectNodes(nodes); + this.graphStore.selectLinks(links); + trackEvent('canvas', 'brushSelect'); + }; + + pointerout = (e: PointerEvent) => { + if (this.selectDom) { + e.preventDefault(); + this.#removeSelectDom(); + } + }; + + runBoxSelect = ({ left, bottom, top, right }: Record) => { + const { twoGraph } = this.graphStore; + const tl = twoGraph!.instance.screen2GraphCoords(left, top); + const br = twoGraph!.instance.screen2GraphCoords(right, bottom); + const { nodes, links } = twoGraph!.instance.graphData(); + return { + nodes: nodes.filter((node) => tl.x < node.x! && node.x! < br.x && br.y > node.y! && node.y! > tl.y), + links: links.filter((link) => { + const source = link.source as NodeObject; + const target = link.target as NodeObject; + const topLeft = { x: Math.min(source.x!, target.x!), y: Math.min(source.y!, target.y!) }; + const bottomRight = { x: Math.max(source.x!, target.x!), y: Math.max(source.y!, target.y!) }; + return tl.x < topLeft.x && tl.y < topLeft.y && br.x > bottomRight.x && br.y > bottomRight.y; + }), + }; + }; + + distroy() { + (['pointerdown', 'pointermove', 'pointerup', 'pointerout'] as const).forEach((eventName) => { + this.container.removeEventListener(eventName, this[eventName]); + }); + } +} + +export const initBrushSelect = ({ container, id }) => new BrushSelect({ container, graphStore: graphInstancesStore.graphs[id] }); + + diff --git a/app/pages/Console/OutputBox/ForceGraph/DisplayPanel/ExpandForm/index.less b/app/pages/Console/OutputBox/ForceGraph/DisplayPanel/ExpandForm/index.less new file mode 100644 index 00000000..f6daf1ab --- /dev/null +++ b/app/pages/Console/OutputBox/ForceGraph/DisplayPanel/ExpandForm/index.less @@ -0,0 +1,34 @@ +.display-expand { + width: 240px; + height: 100%; + background-color: #fff; + overflow: auto; + .header-tab { + > .ant-tabs-nav { + margin-bottom: 0; + } + .ant-tabs-nav-list { + width: 100%; + } + .ant-tabs-tab { + width: 50%; + margin: 0; + display: flex; + justify-content: center; + } + .ant-tabs-content-holder { + display: none; + } + } + + .content { + overflow: auto; + position: relative; + + .row { + min-height: 40px; + display: flex; + align-items: center; + } + } +} diff --git a/app/pages/Console/OutputBox/ForceGraph/DisplayPanel/ExpandForm/index.tsx b/app/pages/Console/OutputBox/ForceGraph/DisplayPanel/ExpandForm/index.tsx new file mode 100644 index 00000000..6ebb99d2 --- /dev/null +++ b/app/pages/Console/OutputBox/ForceGraph/DisplayPanel/ExpandForm/index.tsx @@ -0,0 +1,109 @@ +import { Tabs } from 'antd'; +import React, { useEffect, useState } from 'react'; +import intl from 'react-intl-universal'; +import { LinkObject, NodeObject } from '@app/components/ForceGraph'; +import ExpandItem from '../ExpandItem'; +import './index.less'; +const TabPane = Tabs.TabPane; + +interface IProps { + data: { + nodes: NodeObject[]; + links: LinkObject[]; + }, + spaceVidType: string; +} + +const DisplayComponent = (props: IProps) => { + const [tab, setTab] = useState('nodes'); + const { data, spaceVidType } = props; + + const { nodes, links } = data; + const [list, setList] = useState<{ + nodes: NodeObject[]; + links: LinkObject[]; + }>({ + nodes: [], + links: [] + }); + useEffect(() => { + setList({ + nodes: flattenVertex(nodes), + links: flattenEdge(links) + }); + }, [data]); + + const flattenVertex = (data) => { + return data.map(item => { + const _data = [ + { + key: 'Tag', + value: item.tags, + }, + { + key: 'VID', + value: item.id, + vidType: spaceVidType, + }, + ] as any; + const properties = item.properties; + Object.keys(properties).forEach(property => { + const valueObj = properties[property]; + Object.keys(valueObj).forEach(field => { + _data.push({ + key: `${property}.${field}`, + value: valueObj[field], + }); + }); + }); + return _data; + }); + }; + + const flattenEdge = data => { + return data.map(item => { + const _data = [ + { + key: 'id', + value: item.id, + }, + ]; + const name = item.edgeType; + const properties = item.properties; + Object.keys(properties).forEach(property => { + const value = properties[property]; + _data.push({ + key: `${name}.${property}`, + value, + }); + }); + return _data; + }); + }; + return ( +
+ + + + +
+ {list[tab].length > 0 && list[tab].map((item: NodeObject | LinkObject, index) => ( + + ))} +
+
+ ); +}; + +export default DisplayComponent; diff --git a/app/pages/Console/OutputBox/ForceGraph/DisplayPanel/ExpandItem/index.less b/app/pages/Console/OutputBox/ForceGraph/DisplayPanel/ExpandItem/index.less new file mode 100644 index 00000000..59804275 --- /dev/null +++ b/app/pages/Console/OutputBox/ForceGraph/DisplayPanel/ExpandItem/index.less @@ -0,0 +1,62 @@ +@import '~@app/common.less'; +.display-row-item { + background: #fff; + + .item-header { + cursor: pointer; + padding: 0 14px; + background: @lightBlue; + + .display-header-title { + margin-left: 8px; + font-size: 12px; + } + } + + .item-content { + padding: 0 30px 10px; + background: #fff; + + .item-key { + word-break: break-all; + font-size: 12px; + margin-right: 5px; + } + + .item-value { + font-size: 12px; + color: #000; + word-break: break-all; + } + + .item-label { + padding: 4px 8px; + border-radius: 12.25px; + } + } + + div:nth-child(2) { + padding-top: 10px; + } + + .item-operation { + cursor: pointer; + background: #fff; + justify-content: center; + font-size: 12px; + color: #0091FF; + + .btn-toggle { + display: flex; + align-items: flex-start; + .anticon { + margin-right: 4px; + svg { + fill: #0091FF; + width: 16px; + height: 16px; + } + } + } + } +} diff --git a/app/pages/Console/OutputBox/ForceGraph/DisplayPanel/ExpandItem/index.tsx b/app/pages/Console/OutputBox/ForceGraph/DisplayPanel/ExpandItem/index.tsx new file mode 100644 index 00000000..5f5cf679 --- /dev/null +++ b/app/pages/Console/OutputBox/ForceGraph/DisplayPanel/ExpandItem/index.tsx @@ -0,0 +1,94 @@ +import React, { useState } from 'react'; +import intl from 'react-intl-universal'; + +import Icon from '@app/components/Icon'; +import { convertBigNumberToString, removeNullCharacters } from '@app/utils/function'; + +import './index.less'; + +interface IProps { + data: any; + title: string; + index: number; +} + +interface IProperty { + data: { + key: string, + value: any, + } +} + + +const EXPAND_NUM = 3; + +const Property: React.FC = (props: IProperty) => { + const { data } = props; + const handleValueShow = data => { + const { key, value } = data; + if (typeof value === 'string') { + if (key === 'VID' && data.vidType === 'INT64') { + return value; + } else { + return JSON.stringify(value, (_, v) => { + return removeNullCharacters(v); + }); + } + } else if (typeof value === 'boolean') { + return value.toString(); + } else if (key === 'Tag' && key === 'Tag') { + return value.join(' | '); + } else { + return convertBigNumberToString(value); + } + }; + return
+ {data.key} : + {handleValueShow(data)} +
; +}; + + +const RowItem = (props: IProps) => { + const { data, title, index } = props; + if(!data) { + return null; + } + const [dataUnfolded, setDataUnfoldedStatus] = useState(index === 0); + const needUnfoldMore = data.length > EXPAND_NUM; + const [hasUnfoldAll, setUnfoldAllStatus] = useState(false); + return
+
setDataUnfoldedStatus(!dataUnfolded)} + > + {dataUnfolded ? : } + {title} +
+ {dataUnfolded && ( + <> + {data.slice(0, EXPAND_NUM).map(item => )} + {needUnfoldMore && ( + <> + {hasUnfoldAll && data.slice(EXPAND_NUM).map(item => )} +
setUnfoldAllStatus(!hasUnfoldAll)} + > + {hasUnfoldAll ?
+ + {intl.get('explore.collapseItem')} +
:
+ + {intl.get('explore.expandItem')} +
} +
+ + )} + + )} +
; +}; + + +export default RowItem; diff --git a/app/pages/Console/OutputBox/ForceGraph/DisplayPanel/index.less b/app/pages/Console/OutputBox/ForceGraph/DisplayPanel/index.less new file mode 100644 index 00000000..1b2220ab --- /dev/null +++ b/app/pages/Console/OutputBox/ForceGraph/DisplayPanel/index.less @@ -0,0 +1,30 @@ +@import '~@app/common.less'; + +.display-panel { + position: absolute; + top: 0; + right: 0; + height: 100%; + display: flex; + align-items: center; + .btn-toggle-panel { + width: 24px; + height: 50px; + background: #FFFFFF; + box-shadow: -5px 2px 18px rgba(0, 0, 0, 0.0989); + border-radius: 10px 0px 0px 10px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + user-select: none; + } + .display-drawer { + transition: width 0.5s ease-out; + width: 0; + height: 100%; + } + .active { + width: 240px; + } +} \ No newline at end of file diff --git a/app/pages/Console/OutputBox/ForceGraph/DisplayPanel/index.tsx b/app/pages/Console/OutputBox/ForceGraph/DisplayPanel/index.tsx new file mode 100644 index 00000000..24b902df --- /dev/null +++ b/app/pages/Console/OutputBox/ForceGraph/DisplayPanel/index.tsx @@ -0,0 +1,32 @@ +import React, { useState } from 'react'; +import classNames from 'classnames'; +import Icon from '@app/components/Icon'; +import { LinkObject, NodeObject } from '@app/components/ForceGraph'; +import Expand from './ExpandForm'; +import './index.less'; + + +interface IProps { + data: { + nodes: NodeObject[], + links: LinkObject[] + } + spaceVidType: string; +} + +const DisplayPanel = (props: IProps) => { + const [visible, setVisible] = useState(false); + const { data, spaceVidType } = props; + return ( +
+
setVisible(!visible)}> + +
+
+ +
+
+ ); +}; + +export default DisplayPanel; diff --git a/app/pages/Console/OutputBox/ForceGraph/Menu/ColorChangeBtn/index.less b/app/pages/Console/OutputBox/ForceGraph/Menu/ColorChangeBtn/index.less new file mode 100644 index 00000000..03bb5387 --- /dev/null +++ b/app/pages/Console/OutputBox/ForceGraph/Menu/ColorChangeBtn/index.less @@ -0,0 +1,21 @@ +.menu-color { + > .color-group { + display: inline-flex; + cursor: not-allowed; + } + + >.btn-nodeStyle-set, + >.btn-nodeStyle-set > span, + .btn-title { + display: inline-flex; + align-items: center; + width: 100%; + } + + .circle { + display: inline-block; + width: 25px; + height: 25px; + border-radius: 25px; + } +} diff --git a/app/pages/Console/OutputBox/ForceGraph/Menu/ColorChangeBtn/index.tsx b/app/pages/Console/OutputBox/ForceGraph/Menu/ColorChangeBtn/index.tsx new file mode 100644 index 00000000..5d23a6ce --- /dev/null +++ b/app/pages/Console/OutputBox/ForceGraph/Menu/ColorChangeBtn/index.tsx @@ -0,0 +1,60 @@ +import React from 'react'; +import intl from 'react-intl-universal'; +import { observer } from 'mobx-react-lite'; +import MenuButton from '@app/components/Button'; +import { uniq } from 'lodash'; +import { GraphStore } from '@app/stores/graph'; +import NodeStyleSetBtn from '../NodeStyleSetBtn'; + +import './index.less'; +interface IProps { + graph: GraphStore; + onClose?: () => void; +} + +const DEFAULT_CONFIG = ['#ece9e8']; +const ColorChangeBtn: React.FC = (props: IProps) => { + const { onClose, graph } = props; + const { nodesSelected } = graph; + + const handleUpdateVertex = (value, nodesSelected, type) => { + nodesSelected.forEach(vertex => { + vertex[type] = value; + }); + graph.replaceNodeSelected([...nodesSelected]); + graph.initData({ nodes: [...graph.nodes], links: [...graph.links] }); + }; + + const handleColorUpdate = (color: string) => { + handleUpdateVertex(color, nodesSelected, 'color'); + if (onClose) { + onClose(); + } + }; + + let colorList; + if (nodesSelected.size === 0) { + colorList = DEFAULT_CONFIG; + } else { + colorList = uniq([...nodesSelected].map(item => item.color)); + } + return ( + + } + trackCategory="explore" + trackAction="change_color" + trackLabel="from_panel" + disabled={nodesSelected.size === 0} + /> + ); +}; + +export default observer(ColorChangeBtn); diff --git a/app/pages/Console/OutputBox/ForceGraph/Menu/NodeStyleSetBtn/index.less b/app/pages/Console/OutputBox/ForceGraph/Menu/NodeStyleSetBtn/index.less new file mode 100644 index 00000000..61201ea5 --- /dev/null +++ b/app/pages/Console/OutputBox/ForceGraph/Menu/NodeStyleSetBtn/index.less @@ -0,0 +1,123 @@ +.btn-nodeStyle-set { + display: flex; + align-items: center; + + > span { + display: inline-flex; + } + + .color-group { + display: inline-flex; + position: relative; + + .icon-selected { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + fill: white; + z-index: 10; + width: 24px; + height: 24px; + } + } + + .circle { + display: inline-block; + width: 30px; + height: 30px; + border-radius: 25px; + cursor: pointer; + z-index: 3; + + &:nth-child(2) { + position: absolute; + left: 5px; + z-index: 2; + } + + &:nth-child(3) { + position: absolute; + left: 10px; + z-index: 1; + } + } + + .btn-title { + padding-left: 10px; + } +} + +.nodeStyle-popover { + width: 240px; +} + +.tab-type-set { + .size-radio{ + width: 100%; + .ant-radio-wrapper{ + display: flex; + align-items: baseline; + justify-content: center; + .size-radio-item{ + display: flex; + align-items: center; + height: 40px; + justify-content: space-between; + .item-title{ + width: 30px; + } + .item-content{ + width: 125px; + flex: 1; + display: flex; + justify-content: center; + align-items: center; + } + } + .circle{ + width: 40px; + height: 40px; + background-color: #68B931; + border-radius: 50%; + } + } + } +} + +.icon-group { + padding-bottom: 20px; + height: 240px; + + .icon-box { + display: inline-block; + width: 40px; + height: 40px; + padding: 5px; + background-color: #f3f2f2; + cursor: pointer; + margin: 5px; + } +} + +.ant-carousel .slick-dots-bottom { + bottom: 0; + height: 12px; + margin: 0; + + li { + width: 24px; + } + + li button { + width: 10px; + height: 10px; + border-radius: 10px; + background-color: #cec9c9; + } + + li.slick-active button { + width: 10px; + background-color: gray; + } +} diff --git a/app/pages/Console/OutputBox/ForceGraph/Menu/NodeStyleSetBtn/index.tsx b/app/pages/Console/OutputBox/ForceGraph/Menu/NodeStyleSetBtn/index.tsx new file mode 100644 index 00000000..89881533 --- /dev/null +++ b/app/pages/Console/OutputBox/ForceGraph/Menu/NodeStyleSetBtn/index.tsx @@ -0,0 +1,85 @@ +import React, { useState } from 'react'; +import intl from 'react-intl-universal'; +import { Popover, Tabs } from 'antd'; +import ColorPicker from '@app/components/ColorPicker'; + +import './index.less'; + +const TabPane = Tabs.TabPane; + +interface IProps { + onColorChange?: (color) => void; + title?: string; + colorList: any; + disabled?: boolean; +} + +interface ISetProps { + onColorChange: (color: string) => void; +} + +const SetContent: React.FC = (props: ISetProps) => { + const { onColorChange } = props; + const [tagType, setTagType] = useState('color'); + + return ( +
+ + + + + +
+ ); +}; + +const NodeStyleSetBtn: React.FC = (props: IProps) => { + const { title, colorList, onColorChange, disabled } = props; + const [visible, setVisible] = useState(false); + const handleColorUpdate = (color) => { + setVisible(false); + if (onColorChange) { + onColorChange(color.hex); + } + }; + + if (disabled === true) { + return ( + <> +
+ {colorList + .filter(Boolean) + .slice(0, 3) + .map((item) => ( + + ))} +
+ {title && {title}} + + ); + } + + return ( +
+ + } + trigger={'click'} + visible={visible} + onVisibleChange={setVisible} + > +
+ {colorList.slice(0, 3).map((item) => ( + + ))} +
+ {title && {title}} +
+
+ ); +}; +export default NodeStyleSetBtn; diff --git a/app/pages/Console/OutputBox/ForceGraph/Menu/index.less b/app/pages/Console/OutputBox/ForceGraph/Menu/index.less new file mode 100644 index 00000000..14efdb77 --- /dev/null +++ b/app/pages/Console/OutputBox/ForceGraph/Menu/index.less @@ -0,0 +1,73 @@ +@import '@app/common.less'; + +.context-menu { + position: absolute; + z-index: 1001; + background: #fff; + border: 1px solid #d7d7d7; + box-shadow: 0px 4px 10px rgba(0, 0, 0, 0.15); + + .btn-disabled { + cursor: not-allowed; + color: #9490904d; + + svg { + fill: #9490904d; + cursor: not-allowed; + } + } + + > div { + display: flex; + width: 200px; + height: 43px; + padding: 8px 9px; + align-items: center; + cursor: pointer; + + .btn-icon { + position: absolute; + + svg { + width: 27px; + height: 27px; + } + } + + > span { + width: 100%; + text-align: left; + font-size: 14px; + text-indent: 40px; + letter-spacing: 0; + font-weight: 400; + } + } + + .menu-color > span { + text-indent: 15px; + } + + > div:not(:first-child) { + border-top: none; + } + + > div:not(:last-child) { + border-bottom: none; + } + + > div:hover { + border-color: initial; + } + + + > div:not(.btn-disabled):hover, + .menu-color:not(.btn-disabled):hover { + background: @blue; + color: #fff; + + svg { + fill: #fff; + } + } +} diff --git a/app/pages/Console/OutputBox/ForceGraph/Menu/index.tsx b/app/pages/Console/OutputBox/ForceGraph/Menu/index.tsx new file mode 100644 index 00000000..ebbc6665 --- /dev/null +++ b/app/pages/Console/OutputBox/ForceGraph/Menu/index.tsx @@ -0,0 +1,60 @@ +import React, { Fragment, useEffect } from 'react'; +import './index.less'; +import { observer } from 'mobx-react-lite'; +import { useStore } from '@app/stores'; +import ColorChangeBtn from './ColorChangeBtn'; +interface IProps { + id: string; +} +const Menu = (props: IProps) => { + const { id } = props; + const { graphInstances: { graphs } } = useStore(); + const graph = graphs[id]; + const { + pointer: { left: x, top: y, showContextMenu }, + } = graph; + const hide = () => { + graph.setPointer({ + showContextMenu: false, + }); + }; + useEffect(() => { + const close = e => { + const path = e.path || (e.composedPath && e.composedPath()); // safari has no e.path + const isMenu = path.find(each => each.className === 'context-menu'); + if (isMenu) return; + hide(); + }; + const container = document.getElementById(id); + container?.addEventListener('click', close); + container?.addEventListener('contextmenu', close); + return () => { + container?.removeEventListener('click', close); + container?.removeEventListener('contextmenu', close); + }; + }, []); + const menuConfig = [ + { + component: , + }, + ]; + const containerWidth = 200; + const containerHeight = menuConfig.length * 43; // size is fixed in css + const width = document.getElementById(id)!.clientWidth; + const height = document.getElementById(id)!.clientHeight; + const style = { left: 0, top: 0, display: 'block' }; + style.left = width - x - 30 > containerWidth ? x + 30 : x - containerWidth; + style.top = height - y - 30 > containerHeight ? y + 30 : y - containerHeight; + if (!showContextMenu) { + style.display = 'none'; + } + + return ( +
+ {menuConfig.map((item, index) => ( + {item.component} + ))} +
+ ); +}; +export default observer(Menu); diff --git a/app/pages/Console/OutputBox/ForceGraph/OperationPanel.tsx b/app/pages/Console/OutputBox/ForceGraph/OperationPanel.tsx new file mode 100644 index 00000000..03d63bab --- /dev/null +++ b/app/pages/Console/OutputBox/ForceGraph/OperationPanel.tsx @@ -0,0 +1,34 @@ +import React, { useRef } from 'react'; +import { GraphStore } from '@app/stores/graph'; +import Icon from '@app/components/Icon'; + +import './index.less'; + +interface IProps { + graph: GraphStore +} +const OperationPanel = (props: IProps) => { + const { graph } = props; + const timer = useRef(); + + const handleZoom = (type) => { + timer.current = requestAnimationFrame(() => { + graph.twoGraph!.zoom(type); + handleZoom(type); + }); + }; + + const endZoom = () => { + cancelAnimationFrame(timer.current!); + timer.current = undefined; + }; + + return ( +
+ handleZoom('in')} onMouseUp={endZoom} type="icon-studio-console-zoomin" /> + handleZoom('out')} onMouseUp={endZoom} type="icon-studio-console-zoomout" /> +
+ ); +}; + +export default OperationPanel; diff --git a/app/pages/Console/OutputBox/ForceGraph/Tootip/index.less b/app/pages/Console/OutputBox/ForceGraph/Tootip/index.less new file mode 100644 index 00000000..878f96f1 --- /dev/null +++ b/app/pages/Console/OutputBox/ForceGraph/Tootip/index.less @@ -0,0 +1,64 @@ +.tooltip { + position: absolute; + background: rgba(240, 245, 255, 0.9); + box-shadow: 0 4px 10px 0 rgba(0, 0, 0, 0.15); + border-radius: 3px; + max-height: 100%; + font-size: 12px; + line-height: 14px; + padding: 16px 24px; + text-align: left; + z-index: 100; + width: 300px; + max-height: 250px; + overflow: auto; + transform: translate(16px, 16px); + &:hover{ + display: block!important; + } + h4 { + font-size: 18px; + color: #000; + font-weight: 400; + } + + .tag { + display: inline-block; + padding: 5px 10px; + border-radius: 20px; + margin: 10px 0; + } + + .edgeType { + margin: 10px 0 0; + position: relative; + display: flex; + align-items: center; + + .edge { + margin: 0 5px; + overflow: hidden; + text-overflow: ellipsis; + } + + .line { + width: 8px; + height: 3px; + background-color: #494949; + } + } + + div { + padding: 12px 0; + overflow: hidden; + text-overflow: ellipsis; + font-size: 12px; + font-weight: 400; + user-select:text; + -webkit-user-select: text!important; + } + + div:first-child { + font-weight: 600; + } +} diff --git a/app/pages/Console/OutputBox/ForceGraph/Tootip/index.tsx b/app/pages/Console/OutputBox/ForceGraph/Tootip/index.tsx new file mode 100644 index 00000000..a39b334b --- /dev/null +++ b/app/pages/Console/OutputBox/ForceGraph/Tootip/index.tsx @@ -0,0 +1,127 @@ +import ReactDOM from 'react-dom'; +import React from 'react'; +import { observer } from 'mobx-react-lite'; +import { LinkObject, NodeObject } from '@app/components/ForceGraph'; +import { onPointerMove } from '@app/utils'; +import { convertBigNumberToString, removeNullCharacters } from '@app/utils/function'; +import './index.less'; +import rootStore, { useStore } from '@app/stores'; + +function NodeTooltip({ node, style, show }: { node: NodeObject; style: React.CSSProperties, show: boolean }) { + if(!show) { + return null; + } + const { id = '', spaceVidType, properties = {}, tags = [], style: nodeStyle } = node || {}; + + const propertyElement = Object.keys(properties).reduce((ret, property) => { + const valueObj = properties[property]; + Object.keys(valueObj).forEach(fields => { + const fildValue = valueObj[fields]?.toString(); + const key = `${property}.${fields}`; + ret.push( +
+ {`${key}: `} + + {typeof fildValue !== 'string' + ? convertBigNumberToString(fildValue) + : JSON.stringify(fildValue, (_, v) => (typeof v === 'string' ? removeNullCharacters(v) : v))} + +
, + ); + }); + return ret; + }, [] as JSX.Element[]); + + return ( +
+

Vertex Details

+ + {(tags as string[])?.join(' | ')} + +
+ vid: + {spaceVidType === 'INT64' ? id : JSON.stringify(id)} +
+ {propertyElement} +
+ ); +} + +function LinkTooltipo({ link, style, show }: { link: LinkObject; style: React.CSSProperties, show: boolean }) { + if(!show) { + return null; + } + const { properties = {}, id, edgeType } = link || {}; + if(!edgeType) { + return null; + } + const propertyElement = Object.keys(properties).map(property => ( +
+ {`${edgeType}.${property}: `} + {properties[property]?.toString()} +
+ )); + + return ( +
+

Edge Details

+
+ + {edgeType} + +
+
+ id: + ${id} +
+ {propertyElement} +
+ ); +} +let visible = ''; +let style = {}; +let hovering; +const Tooltip = observer(function Tooltip(props: { id: string }) { + const { graphInstances: { graphs } } = useStore(); + if(!graphs[props.id]) { + return null; + } + const { nodeHovering, linkHovering, pointer } = graphs[props.id]; + const { showContextMenu } = pointer; + if ((nodeHovering || linkHovering) && !showContextMenu) { + if (!visible) { + style = { left: pointer.left, top: pointer.top }; + visible = nodeHovering ? 'node' : 'link'; + } + hovering = nodeHovering || linkHovering; + } else { + setTimeout(() => { + visible = ''; + }, 200); + } + return ( + <> + + + + ); +}); + +export default Tooltip; + +export function initTooltip({ container, id }: { container: HTMLElement, id: string}) { + const { graphs } = rootStore.graphInstances; + const { setPointer } = graphs[id]; + + const dom = document.createElement('div'); + const disposer = onPointerMove(container, setPointer); + + ReactDOM.render(, dom); + container.appendChild(dom); + + return () => { + disposer(); + container.removeChild(dom); + ReactDOM.unmountComponentAtNode(container); + }; +} diff --git a/app/pages/Console/OutputBox/ForceGraph/index.less b/app/pages/Console/OutputBox/ForceGraph/index.less new file mode 100644 index 00000000..6dabb15c --- /dev/null +++ b/app/pages/Console/OutputBox/ForceGraph/index.less @@ -0,0 +1,32 @@ +@import '~@app/common.less'; +.force-graph { + position: relative; + width: 100%; + flex: auto; +} +.graph-loading { + position: absolute; + top: 50%; + left: 50%; +} + +.canvas-operations { + float: right; + .anticon { + width: 38px; + height: 38px; + background: #FFFFFF; + box-shadow: 0px 2px 6px rgba(0, 0, 0, 0.15); + border-radius: 6px; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + svg { + fill: @darkBlue; + } + &:not(:last-child) { + margin-right: 8px; + } + } +} \ No newline at end of file diff --git a/app/pages/Console/OutputBox/ForceGraph/index.tsx b/app/pages/Console/OutputBox/ForceGraph/index.tsx new file mode 100644 index 00000000..d3ecaf3d --- /dev/null +++ b/app/pages/Console/OutputBox/ForceGraph/index.tsx @@ -0,0 +1,69 @@ +import { observer } from 'mobx-react-lite'; +import { Spin } from 'antd'; +import React, { useEffect, useRef, useState } from 'react'; +import { parseSubGraph } from '@app/utils/parseData'; +import { v4 as uuidv4 } from 'uuid'; +import { useStore } from '@app/stores'; +import { initTooltip } from './Tootip'; +import DisplayPanel from './DisplayPanel'; +import OperationPanel from './OperationPanel'; +import Menu from './Menu'; +import { initBrushSelect } from './BrushSelect'; +import './index.less'; + +interface IProps { + data: any; + spaceVidType: string; +} +const ForceGraphBox = (props: IProps) => { + const [uuid ] = useState(uuidv4()); + const { graphInstances: { graphs, initGraph, clearGraph } } = useStore(); + const grapfDomRef = useRef(); + const { data, spaceVidType } = props; + const [loading, setLoading] = useState(false); + const init = async () => { + const { vertexes, edges } = parseSubGraph(data, spaceVidType); + setLoading(true); + await initGraph({ + container: grapfDomRef.current, + id: uuid, + data: { vertexes, edges } + }); + initTooltip({ container: grapfDomRef.current, id: uuid }); + initBrushSelect({ container: grapfDomRef.current, id: uuid }); + setLoading(false); + }; + + useEffect(() => { + init(); + return () => { + clearGraph(uuid); + }; + }, []); + + const currentGraph = graphs[uuid]; + const { nodes, links, nodesSelected, linksSelected } = currentGraph || {}; + const selected = nodesSelected && (nodesSelected.size > 0 || linksSelected.size > 0); + return ( +
+ {loading && } +
+ {currentGraph && <> + + + + } +
+ ); +}; + +export default observer(ForceGraphBox); diff --git a/app/pages/Console/OutputBox/index.less b/app/pages/Console/OutputBox/index.less index 0cabd898..3e7ef007 100644 --- a/app/pages/Console/OutputBox/index.less +++ b/app/pages/Console/OutputBox/index.less @@ -57,46 +57,48 @@ } .tab-container { + position: relative; border-top: 1px solid #E0E0E0; flex: 1; overflow: auto; - .ant-tabs { + .output-tab { min-height: 300px; - } - .ant-tabs-nav { - width: 80px; - .ant-tabs-tab { - padding: 0; - height: 80px; - display: flex; - align-items: center; - justify-content: center; - margin-top: 0; - .ant-tabs-tab-btn { + > .ant-tabs-nav { + width: 80px; + .ant-tabs-tab { + padding: 0; + height: 80px; display: flex; - flex-direction: column; align-items: center; justify-content: center; - color: @darkGray; - .anticon { - margin-right: 0; + margin-top: 0; + .ant-tabs-tab-btn { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + color: @darkGray; + .anticon { + margin-right: 0; + } } - } - &.ant-tabs-tab-active { - background: @lightBlue; - .ant-tabs-tab-btn { - color: @blue; + &.ant-tabs-tab-active { + background: @lightBlue; + .ant-tabs-tab-btn { + color: @blue; + } } } } - } - .ant-tabs-content-holder { - background-color: @lightBlue; - } - .ant-tabs-content { - padding: 20px; - .ant-tabs-tabpane { - padding-left: 0; + .ant-tabs-content-holder { + position: relative; + background-color: @lightBlue; + } + .ant-tabs-content { + padding: 20px; + .ant-tabs-tabpane { + padding-left: 0; + } } } table { diff --git a/app/pages/Console/OutputBox/index.tsx b/app/pages/Console/OutputBox/index.tsx index f7e65d28..706e494e 100644 --- a/app/pages/Console/OutputBox/index.tsx +++ b/app/pages/Console/OutputBox/index.tsx @@ -7,11 +7,12 @@ import { useStore } from '@app/stores'; import { trackEvent } from '@app/utils/stat'; import { v4 as uuidv4 } from 'uuid'; import Icon from '@app/components/Icon'; -import Graphviz from './Graphviz'; import { parseSubGraph } from '@app/utils/parseData'; +import classNames from 'classnames'; +import Graphviz from './Graphviz'; +import ForceGraph from './ForceGraph'; import './index.less'; -import classNames from 'classnames'; interface IProps { index: number; @@ -36,6 +37,7 @@ const OutputBox = (props: IProps) => { const [columns, setColumns] = useState([]); const [dataSource, setDataSource] = useState([]); const [isFavorited, setIsFavorited] = useState(false); + const [showGraph, setShowGraph] = useState(false); const initData = () => { let _columns = [] as any; let _dataSource = [] as any; @@ -45,6 +47,10 @@ const OutputBox = (props: IProps) => { const { headers, tables, localParams } = data; if (tables.length > 0) { _dataSource = data.tables; + setShowGraph(_dataSource.filter(item => + item._verticesParsedList || + item._edgesParsedList || + item._pathsParsedList).length > 0); } if(headers.length > 0) { _columns = data.headers.map(column => { @@ -113,7 +119,7 @@ const OutputBox = (props: IProps) => { } const _favorites = { ...favorites }; if (_favorites[username] && _favorites[username][host]) { - _favorites[username][host].push(gql); + _favorites[username][host].unshift(gql); } else { _favorites[username] = { [host]: [gql], @@ -236,6 +242,7 @@ const OutputBox = (props: IProps) => { {visible && <>
{ {} )} + {showGraph && ( + + + {intl.get('common.graph')} + + } + key="graph" + > + + + )} {code !== 0 && ( { getSpaces(); getParams(); }, []); - + const handleSpaceSwitch = (space: string) => { + switchSpace(space); + update({ + results: [] + }); + }; const checkSwitchSpaceGql = (query: string) => { const queryList = query.split(SEMICOLON_REG).filter(Boolean); @@ -77,12 +82,12 @@ const Console = (props: IProps) => { const handleSaveQuery = (query: string) => { if (query !== '') { const history = getHistory(); - history.push(query); + history.unshift(query); localStorage.setItem('history', JSON.stringify(history)); } }; - const handleRun = async() => { + const handleRun = async () => { if(editor.current) { const query = editor.current!.editor.getValue(); if (!query) { @@ -122,7 +127,7 @@ const Console = (props: IProps) => {
{intl.get('common.currentSpace')} - {spaces.map(space => (