diff --git a/packages/g6-extension-solid/README.md b/packages/g6-extension-solid/README.md new file mode 100644 index 00000000000..df38f0487a8 --- /dev/null +++ b/packages/g6-extension-solid/README.md @@ -0,0 +1,118 @@ +## SolidJS extension for G6 + + + +This extension allows you to define G6 node by SolidJS component and JSX syntax with fine-grained reactivity. + +## Features + +- **Familiar JSX syntax**: Write components using JSX just like React, but with SolidJS's reactive primitives +- **Fine-grained reactivity**: Unlike React's virtual DOM, SolidJS uses signals for surgical DOM updates +- **No re-renders**: Component props are reactive signals that update only the parts of the DOM that depend on them + +## Usage + +1. Install + +```bash +npm install @antv/g6-extension-solid +``` + +2. Import and Register + +```js +import { ExtensionCategory, register } from '@antv/g6'; +import { SolidNode } from '@antv/g6-extension-solid'; + +register(ExtensionCategory.NODE, 'solid-node', SolidNode); +``` + +3. Define Node + +SolidJS Node: + +```jsx +const SolidNode = (props) => { + return
node: {props.id}
; +}; +``` + +G Node with SolidJS: + +```jsx +import { Group, Rect, Text } from '@antv/g6-extension-solid'; + +const GNode = (props) => { + return + + + +}; +``` + +Reactive Node: + +```jsx +import { createSignal } from 'solid-js'; + +const ReactiveNode = (props) => { + const [count, setCount] = createSignal(0); + + return ( +
setCount(count() + 1)}> + Node {props.id}: {count()} clicks +
+ ); +}; +``` + +4. Use + +Use SolidNode: + +```jsx +const graph = new Graph({ + // ... other options + node: { + type: 'solid-node', + style: { + component: SolidNode, + }, + }, +}); +``` + +Use GNode: + +```jsx +const graph = new Graph({ + // ... other options + node: { + type: 'solid-node', + style: { + component: GNode, + }, + }, +}); +``` + +## Key Differences from React Extension + +1. **Reactivity Model**: SolidJS uses signals instead of virtual DOM, providing automatic fine-grained updates +2. **Performance**: No re-renders - only the specific DOM nodes that depend on changed signals are updated +3. **Props Updates**: Node attributes are automatically reactive through signals, no manual re-rendering needed + +## Q&A + +1. Difference between SolidNode and GNode + +SolidNode is a Solid JSX component that renders to regular DOM, while GNode supports JSX syntax but can only use G tag nodes for SVG/Canvas rendering. + +2. How does reactivity work? + +The extension automatically creates signals for node attributes. When attributes change in G6, the signals update, and SolidJS reactively updates only the parts of the DOM that depend on those signals. + +## Resources + +- [SolidJS Documentation](https://www.solidjs.com/) +- [G6 Custom Nodes](https://g6.antv.antgroup.com/examples/element/custom-node/) diff --git a/packages/g6-extension-solid/__tests__/.eslintrc b/packages/g6-extension-solid/__tests__/.eslintrc new file mode 100644 index 00000000000..34f481ae722 --- /dev/null +++ b/packages/g6-extension-solid/__tests__/.eslintrc @@ -0,0 +1,5 @@ +{ + "rules": { + "no-console": "off" + } +} \ No newline at end of file diff --git a/packages/g6-extension-solid/__tests__/dataset/euro-cup.json b/packages/g6-extension-solid/__tests__/dataset/euro-cup.json new file mode 100644 index 00000000000..699cc4ccc54 --- /dev/null +++ b/packages/g6-extension-solid/__tests__/dataset/euro-cup.json @@ -0,0 +1,114 @@ +{ + "nodes": [ + { + "id": "50251337", + "x": 50, + "y": 68, + "isTeamA": "1", + "player_id": "50251337", + "player_shirtnumber": "19", + "player_enName": "Justin Kluivert", + "player_name": "尤斯廷-克鲁伊维特" + }, + { + "id": "50436685", + "x": 25, + "y": 68, + "isTeamA": "1", + "player_id": "50436685", + "player_shirtnumber": "24", + "player_enName": "Antoine Semenyo", + "player_name": "塞门约" + }, + { + "id": "50204813", + "x": 50, + "y": 89, + "isTeamA": "1", + "player_id": "50204813", + "player_shirtnumber": "9", + "player_enName": "Dominic Solanke", + "player_name": "索兰克" + }, + { + "id": "50250175", + "x": 75, + "y": 68, + "isTeamA": "1", + "player_id": "50250175", + "player_shirtnumber": "16", + "player_enName": "Marcus Tavernier", + "player_name": "塔韦尼耶" + }, + { + "id": "50213675", + "x": 65, + "y": 48, + "isTeamA": "1", + "player_id": "50213675", + "player_shirtnumber": "4", + "player_enName": "Lewis Cook", + "player_name": "刘易斯-库克" + }, + { + "id": "50186648", + "x": 35, + "y": 48, + "isTeamA": "1", + "player_id": "50186648", + "player_shirtnumber": "10", + "player_enName": "Ryan Christie", + "player_name": "克里斯蒂" + }, + { + "id": "50279448", + "x": 38, + "y": 28, + "isTeamA": "1", + "player_id": "50279448", + "player_shirtnumber": "6", + "player_enName": "Chris Mepham", + "player_name": "迈帕姆" + }, + { + "id": "50061646", + "x": 15, + "y": 28, + "isTeamA": "1", + "player_id": "50061646", + "player_shirtnumber": "15", + "player_enName": "Adam Smith", + "player_name": "亚当-史密斯" + }, + { + "id": "50472140", + "x": 62, + "y": 28, + "isTeamA": "1", + "player_id": "50472140", + "player_shirtnumber": "27", + "player_enName": "Ilya Zabarnyi", + "player_name": "扎巴尔尼" + }, + { + "id": "50544346", + "x": 85, + "y": 28, + "isTeamA": "1", + "player_id": "50544346", + "player_shirtnumber": "3", + "player_enName": "Milos Kerkez", + "player_name": "科尔克兹" + }, + { + "id": "50062598", + "x": 50, + "y": 7, + "isTeamA": "1", + "player_id": "50062598", + "player_shirtnumber": "1", + "player_enName": "Neto", + "player_name": "内托" + } + ] +} diff --git a/packages/g6-extension-solid/__tests__/demos/euro-cup.tsx b/packages/g6-extension-solid/__tests__/demos/euro-cup.tsx new file mode 100644 index 00000000000..726ac9dc932 --- /dev/null +++ b/packages/g6-extension-solid/__tests__/demos/euro-cup.tsx @@ -0,0 +1,118 @@ +/* @jsx preserve */ +/* @jsxImportSource solid-js */ +import { ExtensionCategory, register } from '@antv/g6'; +import { SolidNode } from '@antv/g6-extension-solid'; +import styled from 'styled-components'; +import data from '../dataset/euro-cup.json'; +import { Graph } from '../graph'; + +const Player = styled.div` + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; +`; + +const Shirt = styled.div` + width: 40px; + height: 40px; + display: flex; + align-items: center; + justify-content: center; + position: relative; + + img { + width: 40px; + position: absolute; + left: 0; + top: 0; + } +`; + +const Number = styled.div` + color: #fff; + font-family: 'DingTalk-JinBuTi'; + font-size: 10px; + top: 20px; + left: 15px; + z-index: 1; + margin-top: 16px; + margin-left: -2px; +`; + +const Label = styled.div` + max-width: 120px; + padding: 0 8px; + color: #fff; + font-size: 10px; + background-image: url('https://mdn.alipayobjects.com/huamei_92awrc/afts/img/A*s2csQ48M0AkAAAAAAAAAAAAADsvfAQ/original'); + background-repeat: no-repeat; + background-size: cover; + display: flex; + justify-content: center; + overflow: hidden; + text-overflow: ellipsis; +`; + +const PlayerNode = ({ playerInfo }: any) => { + const { isTeamA, player_shirtnumber, player_name } = playerInfo; + return ( + + + + {player_shirtnumber} + + + + ); +}; + +register(ExtensionCategory.NODE, 'solid-node', SolidNode); + +export const EuroCup = () => { + return ( +
+ d.x * 3.5, + y: (d: any) => d.y * 3.5, + fill: 'transparent', + component: (data: any) => , + }, + }, + plugins: [ + { + type: 'background', + width: '480px', + height: '720px', + backgroundImage: + 'url(https://mdn.alipayobjects.com/huamei_qa8qxu/afts/img/A*EmPXQLrX2xIAAAAAAAAAAAAADmJ7AQ/original)', + backgroundRepeat: 'no-repeat', + backgroundSize: 'contain', + opacity: 1, + }, + ], + }} + /> +
+ ); +}; diff --git a/packages/g6-extension-solid/__tests__/demos/graph.tsx b/packages/g6-extension-solid/__tests__/demos/graph.tsx new file mode 100644 index 00000000000..a0d93b6f6b6 --- /dev/null +++ b/packages/g6-extension-solid/__tests__/demos/graph.tsx @@ -0,0 +1,26 @@ +/* @jsx preserve */ +/* @jsxImportSource solid-js */ +import { Graph } from '../graph'; + +export const G6Graph = () => { + return ( + { + console.log('render'); + }} + onDestroy={() => { + console.log('destroy'); + }} + /> + ); +}; diff --git a/packages/g6-extension-solid/__tests__/demos/index.tsx b/packages/g6-extension-solid/__tests__/demos/index.tsx new file mode 100644 index 00000000000..8718de31421 --- /dev/null +++ b/packages/g6-extension-solid/__tests__/demos/index.tsx @@ -0,0 +1,4 @@ +export * from './euro-cup'; +export * from './graph'; +export * from './performance-diagnosis'; +export * from './react-node'; diff --git a/packages/g6-extension-solid/__tests__/demos/performance-diagnosis.tsx b/packages/g6-extension-solid/__tests__/demos/performance-diagnosis.tsx new file mode 100644 index 00000000000..84732ad68d1 --- /dev/null +++ b/packages/g6-extension-solid/__tests__/demos/performance-diagnosis.tsx @@ -0,0 +1,154 @@ +/* @jsx preserve */ +/* @jsxImportSource solid-js */ +import { BugOutlined } from '@ant-design/icons'; +import type { EdgeData, Element, GraphData, GraphOptions, IPointerEvent, NodeData } from '@antv/g6'; +import { ExtensionCategory, HoverActivate, idOf, register } from '@antv/g6'; +import { Flex, Typography } from 'antd'; +import { type JSX, createSignal, createEffect } from 'solid-js'; +import { Graph } from '../graph'; + +const { Text } = Typography; + +const ACTIVE_COLOR = '#f6c523'; +const COLOR_MAP: Record = { + 'pre-inspection': '#3fc1c9', + problem: '#8983f3', + inspection: '#f48db4', + solution: '#ffaa64', +}; + +class HoverElement extends HoverActivate { + protected getActiveIds(event: IPointerEvent) { + const { model, graph } = this.context; + const elementId = event.target.id; + const { targetType: elementType } = event; + + const ids = [elementId]; + if (elementType === 'edge') { + const edge = model.getEdgeDatum(elementId); + ids.push(edge.source, edge.target); + } else if (elementType === 'node') { + ids.push(...model.getRelatedEdgesData(elementId).map(idOf)); + } + + graph.frontElement(ids); + + return ids; + } +} + +register(ExtensionCategory.BEHAVIOR, 'hover-element', HoverElement); + +const Node = ({ data }: { data: NodeData }) => { + const { text, type } = data.data as { text: string; type: string }; + + const isHovered = data.states?.includes('active'); + const isSelected = data.states?.includes('selected'); + const color = isHovered ? ACTIVE_COLOR : COLOR_MAP[type]; + + const containerStyle: JSX.CSSProperties = { + width: '100%', + height: '100%', + background: color, + border: `3px solid ${color}`, + borderRadius: '16px', + cursor: 'pointer', + }; + + if (isSelected) { + Object.assign(containerStyle, { border: `3px solid #000` }); + } + + return ( + + + {type === 'problem' && } + {text} + + + ); +}; + +export const PerformanceDiagnosis = () => { + const [data, setData] = useState(); + + createEffect(() => { + fetch('https://assets.antv.antgroup.com/g6/performance-diagnosis.json') + .then((res) => res.json()) + .then(setData); + }, []); + + const options: GraphOptions = { + data, + animation: false, + width: 800, + height: 600, + autoFit: 'view', + node: { + type: 'react', + style: (d: NodeData) => { + const style: NodeData['style'] = { + component: , + ports: [{ placement: 'top' }, { placement: 'bottom' }], + }; + + const size = { + 'pre-inspection': [240, 120], + problem: [200, 120], + inspection: [330, 100], + solution: [200, 120], + }[d.data!.type as string] || [200, 80]; + + Object.assign(style, { + size, + dx: -size[0] / 2, + dy: -size[1] / 2, + }); + return style; + }, + state: { + active: { + halo: false, + }, + selected: { + halo: false, + }, + }, + }, + edge: { + type: 'polyline', + style: { + lineWidth: 3, + radius: 20, + stroke: '#8b9baf', + endArrow: true, + labelText: (d: EdgeData) => d.data!.text as string, + labelFill: '#8b9baf', + labelFontWeight: 600, + labelBackground: true, + labelBackgroundFill: '#f8f8f8', + labelBackgroundOpacity: 1, + labelBackgroundLineWidth: 3, + labelBackgroundStroke: '#8b9baf', + labelPadding: [1, 10], + labelBackgroundRadius: 4, + router: { + type: 'orth', + }, + }, + state: { + active: { + stroke: ACTIVE_COLOR, + labelBackgroundStroke: ACTIVE_COLOR, + halo: false, + }, + }, + }, + layout: { + type: 'antv-dagre', + }, + behaviors: ['zoom-canvas', 'drag-canvas', 'hover-element', 'click-select'], + }; + + return ; +}; diff --git a/packages/g6-extension-solid/__tests__/demos/solid-node.tsx b/packages/g6-extension-solid/__tests__/demos/solid-node.tsx new file mode 100644 index 00000000000..37ea9037594 --- /dev/null +++ b/packages/g6-extension-solid/__tests__/demos/solid-node.tsx @@ -0,0 +1,213 @@ + +import type { Graph as G6Graph, GraphOptions, NodeData } from '@antv/g6'; +import { ExtensionCategory, register } from '@antv/g6'; +import { SolidNode } from '@antv/g6-extension-solid'; +import { createSignal } from 'solid-js'; +import { Graph } from '../graph'; + +const { Content, Footer } = Layout; +const { Text } = Typography; + +register(ExtensionCategory.NODE, 'solid-node', SolidNode); + +type Datum = { + name: string; + status: 'success' | 'error' | 'warning'; + type: 'local' | 'remote'; + url: string; +}; + +const Node = ({ data, onChange }: { data: NodeData; onChange?: (value: string) => void }) => { + const { status, type } = data.data as Datum; + + return ( + + + + + Server + {type} + + + + {data.id} + + + *URL: + + { + const url = event.target.value; + onChange?.(url); + }} + /> + + + ); +}; + +export const ReactNodeDemo = () => { + const graphRef = useRef(null); + + const [form] = Form.useForm(); + const isValidUrl = (url: string) => { + return /https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)/.test( + url, + ); + }; + + const [options, setOptions] = createSignal({ + data: { + nodes: [ + { + id: 'local-server-1', + data: { status: 'success', type: 'local', url: 'http://localhost:3000' }, + style: { x: 50, y: 50 }, + }, + { + id: 'remote-server-1', + data: { status: 'warning', type: 'remote' }, + style: { x: 350, y: 50 }, + }, + ], + edges: [{ source: 'local-server-1', target: 'remote-server-1' }], + }, + node: { + type: 'react', + style: { + size: [240, 100], + component: (data: NodeData) => ( + { + setOptions((prev) => { + if (!graphRef.current || graphRef.current.destroyed) return prev; + const nodes = graphRef.current.getNodeData(); + const index = nodes.findIndex((node) => node.id === data.id); + const node = nodes[index]; + const datum = { + ...node.data, + url, + status: url === '' ? 'warning' : isValidUrl(url) ? 'success' : 'error', + } as Datum; + nodes[index] = { ...node, data: datum }; + return { ...prev, data: { ...prev.data, nodes } }; + }); + }} + /> + ), + }, + }, + behaviors: ['drag-element', 'zoom-canvas', 'drag-canvas'], + }); + + return ( + + + (graphRef.current = graph)} /> + +