Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found

Target

Select target project
  • graphpolaris/frontend-v2
  • rijkheere/frontend-v-2-reordering-paoh
2 results
Show changes
Commits on Source (2)
Showing
with 182 additions and 125 deletions
No preview for this file type
......@@ -87,6 +87,7 @@
"reorder.js": "^2.2.6",
"sass": "^1.83.0",
"scss": "^0.2.4",
"sortablejs": "^1.15.6",
"ts-common": "link:ts-common",
"use-immer": "^0.11.0"
},
......
......@@ -97,7 +97,7 @@ type AccordionBodyProps = {
export function AccordionBody({ isOpen = false, children, className = '' }: AccordionBodyProps) {
return (
<div
className={`overflow-hidden transition-max-height duration-300 ease-in-out w-full box-border ml-2 ${isOpen ? 'max-h-screen' : 'max-h-0'} ${className}`}
className={`overflow-hidden transition-max-height duration-300 ease-in-out box-border ml-2 ${isOpen ? 'max-h-screen' : 'max-h-0'} ${className}`}
>
{isOpen && <div>{children}</div>}
</div>
......
import React from 'react';
import { visualizationColors } from '@/config';
import { Popover, PopoverTrigger, PopoverContent } from '@/lib/components/layout/Popover';
const hexToRgb = (hex: string): [number, number, number] => {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
return [r, g, b];
};
type Props = {
value: [number, number, number];
onChange: (val: [number, number, number]) => void;
onChange: (val: number) => void;
};
export function ColorPicker({ value, onChange }: Props) {
......@@ -33,8 +25,7 @@ export function ColorPicker({ value, onChange }: Props) {
e.stopPropagation();
}}
>
{visualizationColors.GPCat.colors[14].map(hexColor => {
const [r, g, b] = hexToRgb(hexColor);
{visualizationColors.GPCat.colors[14].map((hexColor, i) => {
return (
<div
key={hexColor}
......@@ -42,7 +33,7 @@ export function ColorPicker({ value, onChange }: Props) {
style={{ backgroundColor: hexColor }}
onClick={e => {
e.stopPropagation();
onChange([r, g, b]);
onChange(i);
}}
/>
);
......
......@@ -15,3 +15,4 @@ export * from './LoadingSpinner';
export * from './Popup';
export * from './layout';
export * from './pills';
export * from './tabs';
import React from 'react';
import { ButtonProps } from '../buttons';
import React, { forwardRef } from 'react';
import { ButtonProps } from '@/lib/components/buttons';
type TabTypes = 'inline' | 'rounded' | 'simple';
......@@ -10,25 +10,31 @@ const TabContext = React.createContext<ContextType>({
tabType: 'inline',
});
export const Tabs = (props: { children: React.ReactNode; tabType?: TabTypes; className?: string; }) => {
const tabType = props.tabType || 'inline';
let className = '';
export const Tabs = forwardRef<HTMLDivElement, {
children: React.ReactNode;
tabType?: TabTypes;
className?: string;
}>((props, ref) => {
const { children, tabType = 'inline', className = '' } = props;
let baseClass = '';
if (tabType === 'inline') {
className = 'flex items-stretch';
baseClass = 'flex items-stretch';
} else if (tabType === 'rounded') {
className = 'flex gap-x-1 relative before:w-full before:h-px before:absolute before:bottom-0 before:bg-secondary-200 overflow-hidden';
baseClass = 'flex gap-x-1 relative before:w-full before:h-px before:absolute before:bottom-0 before:bg-secondary-200 overflow-hidden';
} else if (tabType === 'simple') {
className = 'flex';
baseClass = 'flex';
}
const combinedClassName = `${className} ${props.className || ''}`;
const combinedClass = `${baseClass} ${className}`.trim();
return (
<TabContext.Provider value={{ tabType: tabType }}>
<div className={combinedClassName.trim()}>
{props.children}
<TabContext.Provider value={{ tabType : tabType}}>
<div ref={ref} className={combinedClass}>
{children}
</div>
</TabContext.Provider>
);
};
});
Tabs.displayName = 'Tabs';
export const Tab = ({
activeTab,
......@@ -47,18 +53,29 @@ export const Tab = ({
if (context.tabType === 'inline') {
className += ` px-2 gap-1 relative h-full max-w-64 flex-nowrap before:content-['']
before:absolute before:left-0 before:bottom-0 before:h-[2px] before:w-full
${activeTab ? 'before:bg-primary-500 hover:bg-inherit text-dark ' : ' text-secondary-600 hover:text-dark hover:bg-secondary-200 hover:bg-opacity-50 before:bg-transparent hover:before:bg-secondary-300'}`;
${activeTab
? 'before:bg-primary-500 text-dark'
: ' text-secondary-600 hover:text-dark hover:bg-secondary-200 hover:bg-opacity-50 before:bg-transparent hover:before:bg-secondary-300'
}`;
} else if (context.tabType === 'rounded') {
className += ` py-1.5 px-3 -mb-px text-sm flex-nowrap text-center border border-secondary-200 rounded-t
${activeTab ? 'active z-[2] text-dark bg-light' : 'bg-secondary-100 hover:text-dark border-secondary-100 text-secondary-600'}`;
${activeTab
? 'active z-[2] text-dark bg-light'
: 'bg-secondary-100 hover:text-dark border-secondary-100 text-secondary-600'
}`;
} else if (context.tabType === 'simple') {
className += ` px-2 py-1 gap-1 ${activeTab ? 'active bg-secondary-100 text-dark' : 'text-secondary-600 hover:text-dark'}`;
className += ` px-2 py-1 gap-1 ${activeTab ? 'active text-dark' : 'text-secondary-500 hover:text-dark'}`;
}
return (
<div className={`cursor-pointer relative text-xs font-medium flex items-center justify-start ${className}`} {...props} tabIndex={0}>
<div
className={` cursor-pointer relative text-xs font-medium flex items-center justify-start ${className}`}
{...props}
tabIndex={0}
data-type="tab"
>
{IconComponent && <IconComponent className="h-4 w-4 shrink-0 pointer-events-none" />}
<span className="truncate">{text}</span>
<span className="truncate select-none">{text}</span>
{props.children}
</div>
);
......
......@@ -14,6 +14,13 @@ export const initialState: VisState = {
activeVisualizationIndex: -1,
openVisualizationArray: [],
};
function generateUUID(): string {
if (typeof crypto?.randomUUID === "function") {
return crypto.randomUUID();
}
return `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
}
export const visualizationSlice = createSlice({
name: 'visualization',
......@@ -43,8 +50,13 @@ export const visualizationSlice = createSlice({
},
setVisualizationState: (state, action: PayloadAction<VisState>) => {
if (action.payload.activeVisualizationIndex !== undefined && !isEqual(action.payload, state)) {
state.openVisualizationArray = action.payload.openVisualizationArray || [];
state.activeVisualizationIndex = Math.min(action.payload.activeVisualizationIndex, state.openVisualizationArray.length - 1);
state.openVisualizationArray = (action.payload.openVisualizationArray || []).map(item => ({
...item, uuid: item.uuid || generateUUID(),
}));
state.activeVisualizationIndex = Math.min(
action.payload.activeVisualizationIndex,
state.openVisualizationArray.length - 1
);
}
},
updateVisualization: (state, action: PayloadAction<{ id: number; settings: VisualizationSettingsType }>) => {
......@@ -54,7 +66,13 @@ export const visualizationSlice = createSlice({
}
},
addVisualization: (state, action: PayloadAction<VisualizationSettingsType>) => {
state.openVisualizationArray.push(action.payload);
const newItem = {
...action.payload,
uuid: action.payload.uuid || generateUUID(),
};
state.openVisualizationArray.push(newItem);
// state.openVisualizationArray.push(action.payload);
state.activeVisualizationIndex = state.openVisualizationArray.length - 1;
},
updateActiveVisualization: (state, action: PayloadAction<VisualizationSettingsType>) => {
......@@ -82,6 +100,7 @@ export const visualizationSlice = createSlice({
const settingsCopy = [...state.openVisualizationArray];
const [movedItem] = settingsCopy.splice(id, 1);
settingsCopy.splice(newPosition, 0, movedItem);
state.openVisualizationArray = settingsCopy;
if (state.activeVisualizationIndex === id) {
......
......@@ -5,6 +5,7 @@ import type { AppDispatch } from '../../data-access';
import { ML, GraphStatistics, NodeQueryResult, EdgeQueryResult, XYPosition } from 'ts-common';
export type VisualizationSettingsType = {
uuid: string; // unique identifier for the visualization
id: string;
name: string;
[id: string]: any;
......
import React, { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useRef } from 'react';
import { Button, DropdownContainer, DropdownItem, DropdownItemContainer, DropdownTrigger } from '../../components';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../../components/tooltip';
import { ControlContainer } from '../../components/controls';
import { Tabs, Tab } from '../../components/tabs';
import { useActiveSaveState, useActiveSaveStateAuthorization, useAppDispatch, useSessionCache, useVisualization } from '../../data-access';
import { Tabs, Tab } from "@/lib/components";
import { useActiveSaveStateAuthorization, useAppDispatch, useVisualization } from '../../data-access';
import { addVisualization, removeVisualization, reorderVisState, setActiveVisualization } from '../../data-access/store/visualizationSlice';
import { VisualizationsConfig } from './config';
import { Visualizations } from './VisualizationPanel';
import Sortable from 'sortablejs';
export default function VisualizationTabBar(props: { fullSize: () => void; exportImage: () => void; handleSelect: () => void }) {
const { activeVisualizationIndex, openVisualizationArray } = useVisualization();
......@@ -14,30 +15,40 @@ export default function VisualizationTabBar(props: { fullSize: () => void; expor
const [open, setOpen] = useState(false);
const dispatch = useAppDispatch();
const handleDragStart = (e: React.DragEvent<HTMLDivElement>, i: number) => {
e.dataTransfer.setData('text/plain', i.toString());
};
const handleDragOver = (e: React.DragEvent<HTMLButtonElement>) => {
e.preventDefault();
};
const tabsRef = useRef<HTMLDivElement | null>(null);
const handleDrop = (e: React.DragEvent<HTMLButtonElement>, i: number) => {
e.preventDefault();
const draggedVisIndex = e.dataTransfer.getData('text/plain');
dispatch(reorderVisState({ id: Number(draggedVisIndex), newPosition: i }));
};
const onSelect = async (id?: number) => {
if (id === undefined) return;
dispatch(setActiveVisualization(id));
};
useEffect(() => {
if (!tabsRef.current) return;
const sortable = new Sortable(tabsRef.current, {
animation: 150,
draggable: "[data-type=\"tab\"]",
ghostClass: "bg-secondary-300",
dragClass: "bg-secondary-100",
onEnd: (evt) => {
if (
evt.oldIndex != null &&
evt.newIndex != null &&
evt.oldIndex !== evt.newIndex
) {
dispatch(
reorderVisState({
id: evt.oldIndex,
newPosition: evt.newIndex,
})
);
}
},
});
const onDelete = (id: number) => {
dispatch(removeVisualization(id));
props.handleSelect();
};
return () => {
sortable.destroy();
};
}, [dispatch, openVisualizationArray]);
/**
* User can export image with Ctrl+S
*/
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
......@@ -53,6 +64,16 @@ export default function VisualizationTabBar(props: { fullSize: () => void; expor
};
}, [props]);
const onSelect = async (idx?: number) => {
if (idx === undefined) return;
dispatch(setActiveVisualization(idx));
};
const onDelete = (idx: number) => {
dispatch(removeVisualization(idx));
props.handleSelect();
};
return (
<div className="absolute shrink-0 top-0 left-0 right-0 flex items-stretch justify-start h-7 bg-secondary-100 border-b border-secondary-200 max-w-full">
<div className="flex items-center px-2">
......@@ -94,48 +115,44 @@ export default function VisualizationTabBar(props: { fullSize: () => void; expor
{openVisualizationArray.length > 0 && (
<Tabs className="-my-px overflow-x-auto overflow-y-hidden no-scrollbar divide-x divide-secondary-200 border-x">
<Tabs ref={tabsRef} className="-my-px overflow-x-auto overflow-y-hidden no-scrollbar divide-x divide-secondary-200 border-x">
{openVisualizationArray.map((vis, i) => {
const isActive = activeVisualizationIndex === i;
const config = VisualizationsConfig[vis.id];
const IconComponent = config?.icons.sm;
return (
<Tab
key={i}
activeTab={isActive}
text={vis.name}
IconComponent={IconComponent}
className="group"
onClick={() => onSelect(i)}
onDragStart={e => handleDragStart(e, i)}
onDragOver={e => handleDragOver(e)}
onDrop={e => handleDrop(e, i)}
draggable
>
<Button
variantType="secondary"
variant="ghost"
disabled={!saveStateAuthorization.database?.W}
rounded
size="2xs"
iconComponent="icon-[ic--baseline-close]"
className={!isActive ? 'opacity-50 group-hover:opacity-100 group-focus-within:opacity-100' : ''}
onClick={e => {
e.stopPropagation();
onDelete(i);
}}
/>
</Tab>
);
})}
</Tabs>
return (
<Tab
key={vis.uuid}
activeTab={isActive}
text={vis.name}
IconComponent={IconComponent}
className="group"
onClick={() => onSelect(i)}
>
{/*{vis.id},{config.id}*/}
<Button
variantType="secondary"
variant="ghost"
disabled={!saveStateAuthorization.database?.W}
rounded
size="2xs"
iconComponent="icon-[ic--baseline-close]"
className={!isActive ? 'opacity-50 group-hover:opacity-100 group-focus-within:opacity-100' : ''}
onClick={(e) => {
e.stopPropagation();
onDelete(i);
}}
/>
</Tab>
);
})}
</Tabs>
)}
{openVisualizationArray.length > 0 && (
<div className="shrink-0 sticky right-0 px-0.5 ml-auto flex">
<ControlContainer>
<div className="shrink-0 sticky right-0 px-0.5 ml-auto flex">
<ControlContainer>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
......@@ -166,9 +183,8 @@ export default function VisualizationTabBar(props: { fullSize: () => void; expor
</TooltipContent>
</Tooltip>
</TooltipProvider>
</ControlContainer>
</div>
</ControlContainer>
</div>
)}
</div>
);
......
import React from 'react';
import { CompositeLayer, Layer } from 'deck.gl';
import { LineLayer, ScatterplotLayer, TextLayer } from '@deck.gl/layers';
import { CompositeLayerType, Coordinate, LayerProps } from '../../mapvis.types';
import { BrushingExtension, CollisionFilterExtension } from '@deck.gl/extensions';
import { scaleLinear, ScaleLinear, color, interpolateRgb } from 'd3';
import { Node } from '@/lib/data-access';
import { nodeColorRGB } from '../../utils';
import { CategoricalStats } from 'ts-common';
interface ColorScales {
[label: string]: ScaleLinear<string, string>;
......@@ -43,8 +44,12 @@ export class NodeLinkLayer extends CompositeLayer<CompositeLayerType> {
const colorAttribute = nodeSettings.colorAttribute;
const attributeData = nodeDistribution[colorAttribute];
if (nodeSettings.colorAttributeType === 'numerical' && attributeData) {
colorScales[label] = this.setNumericalColor(attributeData.min, attributeData.max, nodeSettings.colorScale);
if (nodeSettings.colorAttributeType === 'number' && attributeData) {
colorScales[label] = this.setNumericalColor(
attributeData.statistics.min,
attributeData.statistics.max,
nodeSettings.colorScale,
);
}
}
});
......@@ -134,10 +139,14 @@ export class NodeLinkLayer extends CompositeLayer<CompositeLayerType> {
pickable: true,
getFillColor: d => {
if (layerSettings?.nodes[label].colorByAttribute) {
let attributeValue = d.attributes[layerSettings?.nodes[label].colorAttribute];
if (layerSettings?.nodes[label].colorAttributeType === 'categorical') {
return layerSettings?.nodes[label]?.colorMapping[attributeValue];
} else if (layerSettings?.nodes[label].colorAttributeType === 'numerical') {
const attribute = layerSettings?.nodes[label].colorAttribute;
let attributeValue = d.attributes[attribute];
if (['string', 'categorical'].includes(layerSettings?.nodes[label].colorAttributeType)) {
return nodeColorRGB(
layerSettings?.nodes[label]?.colorMapping[attributeValue] ??
(graphMetadata.nodes.types[label].attributes[attribute].statistics as CategoricalStats).values.indexOf(attributeValue),
);
} else if (layerSettings?.nodes[label].colorAttributeType === 'number') {
if (typeof attributeValue === 'string') {
const numericValue = parseFloat(attributeValue.replace(/[^0-9.]/g, ''));
if (!isNaN(numericValue)) {
......@@ -148,7 +157,7 @@ export class NodeLinkLayer extends CompositeLayer<CompositeLayerType> {
return this.rgbStringToArray(colorScale(attributeValue));
}
}
return layerSettings?.nodes[label].color;
return nodeColorRGB(layerSettings?.nodes[label].color);
},
getPosition: (d: Node) => getNodeLocation(d._id),
getRadius: () => layerSettings?.nodes[label]?.size,
......@@ -168,7 +177,7 @@ export class NodeLinkLayer extends CompositeLayer<CompositeLayerType> {
data: nodes,
getPosition: (d: Node) => getNodeLocation(d._id),
getText: (d: Node) => d.label,
getSize: (d: Node) => (layerSettings?.nodes[label]?.size * 2) / d.label.length,
getSize: (d: Node) => (layerSettings?.nodes[label]?.size * 2.5) / d.label.length,
getAlignmentBaseline: 'center',
getRadius: 10,
radiusScale: 20,
......@@ -176,7 +185,7 @@ export class NodeLinkLayer extends CompositeLayer<CompositeLayerType> {
sizeUnits: 'meters',
sizeMaxPixels: 64,
characterSet: 'auto',
fontFamily: 'monospace',
fontFamily: 'InterVariable',
billboard: false,
getAngle: () => 0,
collisionGroup: 'textLabels',
......
......@@ -9,7 +9,7 @@ import { isEqual } from 'lodash-es';
import { CategoricalStats } from '@/lib/statistics';
const defaultNodeSettings = (index: number) => ({
color: nodeColorRGB(index + 1),
color: index,
colorMapping: {},
colorScale: undefined,
colorByAttribute: false,
......@@ -121,11 +121,11 @@ export function NodeLinkOptions({
<AccordionItem>
<AccordionHead>
<div className="flex justify-between items-center">
<div className="flex w-full justify-between items-center">
<span className="font-semibold">Color</span>
{!nodeSettings?.colorByAttribute && (
<ColorPicker
value={nodeSettings?.color}
value={nodeColorRGB(nodeSettings?.color)}
onChange={val => {
updateLayerSettings({
nodes: { ...layerSettings.nodes, [nodeType]: { ...nodeSettings, color: val } },
......@@ -170,7 +170,7 @@ export function NodeLinkOptions({
})
}
/>
{nodeSettings.colorAttributeType === 'numerical' ? (
{nodeSettings.colorAttributeType === 'number' ? (
<div>
<p>Select color scale:</p>
<DropdownColorLegend
......@@ -192,11 +192,11 @@ export function NodeLinkOptions({
{(
graphMetadata.nodes.types[nodeType]?.attributes?.[nodeSettings.colorAttribute]
?.statistics as CategoricalStats
).values.map((attr: string) => (
).values.map((attr: string, i: number) => (
<div key={attr} className="flex items-center justify-between">
<p className="truncate w-18">{attr.length > 0 ? attr : 'Empty val'}</p>
<ColorPicker
value={(nodeSettings?.colorMapping ?? {})[attr] ?? [0, 0, 0]}
value={nodeColorRGB((nodeSettings?.colorMapping ?? {})[attr] ?? i)}
onChange={val => {
updateLayerSettings({
nodes: {
......
......@@ -10,7 +10,7 @@ import { Attribution, ActionBar, MapTooltip, MapSettings } from './components';
import { useSelectionLayer, useCoordinateLookup } from './hooks';
import { VisualizationTooltip } from '@/lib/components/VisualizationTooltip';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/lib/components/tooltip';
import { isGeoJsonType, rgbToHex } from './utils';
import { isGeoJsonType, nodeColorHex, rgbToHex } from './utils';
import { NodeType } from '../nodelinkvis/types';
import { ChoroplethLayer } from './layers/choropleth-layer/ChoroplethLayer';
......@@ -207,10 +207,10 @@ export const MapVis = forwardRef((props: VisualizationPropTypes<MapProps>, refEx
// Handle clicking on a node (when it's not a feature in the choropleth layer)
if (
Object.prototype.hasOwnProperty.call(object, 'attributes') &&
Object.prototype.hasOwnProperty.call(object, 'id') &&
Object.prototype.hasOwnProperty.call(object, '_id') &&
Object.prototype.hasOwnProperty.call(object, 'label')
) {
const objectLocation: Coordinate = coordinateLookup[object.id];
const objectLocation: Coordinate = coordinateLookup[object._id];
props.handleSelect({ nodes: [object] });
if (objectLocation) {
......@@ -253,7 +253,7 @@ export const MapVis = forwardRef((props: VisualizationPropTypes<MapProps>, refEx
// If the feature contains node IDs, handle selecting the corresponding nodes
const ids = object.properties.nodes;
if (ids && ids.length > 0) {
const nodes = props.data.nodes.filter(node => ids.includes((node as unknown as { id: string }).id));
const nodes = props.data.nodes.filter(node => ids.includes(node._id));
props.handleSelect({ nodes: [...nodes] });
}
}
......@@ -308,11 +308,7 @@ export const MapVis = forwardRef((props: VisualizationPropTypes<MapProps>, refEx
}
colorHeader={
node.selectedType === 'node'
? rgbToHex(
props?.settings?.nodelink?.nodes?.[node.label]?.color?.[0] ?? 0,
props?.settings?.nodelink?.nodes?.[node.label]?.color?.[1] ?? 0,
props?.settings?.nodelink?.nodes?.[node.label]?.color?.[2] ?? 0,
)
? nodeColorHex(props?.settings?.nodelink?.nodes?.[node.label]?.color)
: node.selectedType === 'area'
? rgbToHex(node.color[0], node.color[1], node.color[2])
: 'hsl(var(--clr-node))'
......
......@@ -9,7 +9,7 @@ export type Coordinate = [number, number];
export type LocationInfo = { lat: string; lon: string };
export type MapNodeData = {
color: [number, number, number];
color: number;
hidden: boolean;
fixed: boolean;
min: number;
......@@ -25,7 +25,7 @@ export type MapNodeData = {
colorAttribute?: string | undefined;
colorAttributeType?: string | undefined;
colorScale: string;
colorMapping?: { [label: string]: [number, number, number] };
colorMapping?: { [label: string]: number };
};
export type MapEdgeData = {
......
......@@ -3,8 +3,8 @@ import { NodeType } from '../nodelinkvis/types';
import { GeoJsonType } from './mapvis.types';
import { SearchResultType } from './mapvis.types';
export function nodeColorRGB(num: number) {
const colorVal = visualizationColors.GPCat.colors[14][num % visualizationColors.GPCat.colors[14].length];
export function nodeColorRGB(num?: number): [number, number, number] {
const colorVal = nodeColorHex(num);
const hex = colorVal.replace(/^#/, '');
const r = parseInt(hex.substring(0, 2), 16);
const g = parseInt(hex.substring(2, 4), 16);
......@@ -12,6 +12,12 @@ export function nodeColorRGB(num: number) {
return [r, g, b];
}
export function nodeColorHex(num?: number): string {
let colorVal = visualizationColors.GPCat.colors[14][(num ?? 0) % visualizationColors.GPCat.colors[14].length];
if (colorVal == null) colorVal = visualizationColors.GPCat.colors[14][0];
return colorVal;
}
export const isGeoJsonType = (data: NodeType | GeoJsonType | SearchResultType): data is GeoJsonType => {
return (data as GeoJsonType).properties !== undefined;
};
......
......@@ -598,7 +598,7 @@ export const NLPixi = forwardRef((props: Props, refExternal) => {
fill: 0xffffff,
wordWrap: true,
breakWords: true,
wordWrapWidth: config.NODE_RADIUS,
wordWrapWidth: config.NODE_RADIUS + 5,
align: 'center',
},
});
......