From 0c1eb488cda3de60f41200b39ed87bfbcc967e65 Mon Sep 17 00:00:00 2001 From: Rebecca Bol Date: Tue, 12 Dec 2023 16:24:46 -0800 Subject: [PATCH] Component | Chord Diagram | Fix: padAngle and emptyNode behavior (+ refactoring layout modules) unovis/issues-only#20 --- .../chord-diagram/chord-diagram.component.ts | 4 +- .../ts/src/components/chord-diagram/config.ts | 12 +- .../ts/src/components/chord-diagram/index.ts | 196 ++++-------------- .../chord-diagram/modules/layout.ts | 118 +++++++++++ .../components/chord-diagram/modules/link.ts | 20 +- .../ts/src/components/chord-diagram/style.ts | 4 +- 6 files changed, 172 insertions(+), 182 deletions(-) create mode 100644 packages/ts/src/components/chord-diagram/modules/layout.ts diff --git a/packages/angular/src/components/chord-diagram/chord-diagram.component.ts b/packages/angular/src/components/chord-diagram/chord-diagram.component.ts index 62ed1536e..7964415b8 100644 --- a/packages/angular/src/components/chord-diagram/chord-diagram.component.ts +++ b/packages/angular/src/components/chord-diagram/chord-diagram.component.ts @@ -105,8 +105,8 @@ export class VisChordDiagramComponent> - /** Pad angle in radians. Constant value or accessor function. Default: `0.02` */ - @Input() padAngle?: NumericAccessor> + /** Pad angle in radians. Default: `0.02` */ + @Input() padAngle?: number /** Corner radius constant value or accessor function. Default: `2` */ @Input() cornerRadius?: NumericAccessor> diff --git a/packages/ts/src/components/chord-diagram/config.ts b/packages/ts/src/components/chord-diagram/config.ts index 0d86622a7..e7cd93c37 100644 --- a/packages/ts/src/components/chord-diagram/config.ts +++ b/packages/ts/src/components/chord-diagram/config.ts @@ -8,6 +8,10 @@ import { ColorAccessor, GenericAccessor, NumericAccessor, StringAccessor } from import { ChordInputLink, ChordInputNode, ChordLabelAlignment, ChordLinkDatum, ChordNodeDatum } from './types' export interface ChordDiagramConfigInterface extends ComponentConfigInterface { + /** Angular range of the diagram. Default: `[0, 2 * Math.PI]` */ + angleRange?: [number, number]; + /** Corner radius constant value or accessor function. Default: `2` */ + cornerRadius?: NumericAccessor>; /** Node id or index to highlight. Overrides default hover behavior if supplied. Default: `undefined` */ highlightedNodeId?: number | string; /** Link ids or index values to highlight. Overrides default hover behavior if supplied. Default: [] */ @@ -28,12 +32,8 @@ export interface ChordDiagramConfigInterface>; /** Node label alignment. Default: `ChordLabelAlignment.Along` */ nodeLabelAlignment?: GenericAccessor>; - /** Pad angle in radians. Constant value or accessor function. Default: `0.02` */ - padAngle?: NumericAccessor>; - /** Corner radius constant value or accessor function. Default: `2` */ - cornerRadius?: NumericAccessor>; - /** Angular range of the diagram. Default: `[0, 2 * Math.PI]` */ - angleRange?: [number, number]; + /** Pad angle in radians. Default: `0.02` */ + padAngle?: number; /** The exponent property of the radius scale. Default: `2` */ radiusScaleExponent?: number; } diff --git a/packages/ts/src/components/chord-diagram/index.ts b/packages/ts/src/components/chord-diagram/index.ts index b688b02c8..969984aad 100644 --- a/packages/ts/src/components/chord-diagram/index.ts +++ b/packages/ts/src/components/chord-diagram/index.ts @@ -1,6 +1,5 @@ import { max } from 'd3-array' -import { nest } from 'd3-collection' -import { HierarchyNode, hierarchy, partition } from 'd3-hierarchy' +import { partition } from 'd3-hierarchy' import { Selection } from 'd3-selection' import { scalePow, ScalePower } from 'd3-scale' import { arc } from 'd3-shape' @@ -10,24 +9,14 @@ import { ComponentCore } from 'core/component' import { GraphData, GraphDataModel } from 'data-models/graph' // Utils -import { getNumber, isNumber, groupBy, getString, getValue } from 'utils/data' +import { getNumber, isNumber, getString, getValue } from 'utils/data' import { estimateStringPixelLength } from 'utils/text' // Types -import { GraphNodeCore } from 'types/graph' import { Spacing } from 'types/spacing' // Local Types -import { - ChordInputNode, - ChordInputLink, - ChordDiagramData, - ChordHierarchyNode, - ChordNode, - ChordRibbon, - ChordLabelAlignment, - ChordLeafNode, -} from './types' +import { ChordInputNode, ChordInputLink, ChordDiagramData, ChordNode, ChordRibbon, ChordLabelAlignment, ChordLeafNode } from './types' // Config import { ChordDiagramDefaultConfig, ChordDiagramConfigInterface } from './config' @@ -35,6 +24,7 @@ import { ChordDiagramDefaultConfig, ChordDiagramConfigInterface } from './config // Modules import { createNode, updateNode, removeNode } from './modules/node' import { createLabel, updateLabel, removeLabel, LABEL_PADDING } from './modules/label' +import { getHierarchyNodes, getRibbons, positionChildren } from './modules/layout' import { createLink, updateLink, removeLink } from './modules/link' // Styles @@ -127,48 +117,51 @@ export class ChordDiagram< setData (data: GraphData): void { super.setData(data) - const hierarchyData = this._getHierarchyNodes() - - const partitionData = partition>() - .size([this.config.angleRange[1], 1])(hierarchyData) as ChordNode - - partitionData.each((node, i) => { - this._calculateRadialPosition(node, getNumber(node.data, this.config.padAngle)) - - // Add hierarchy data for non leaf nodes - if (node.children) { - node.data = Object.assign(node.data, { - depth: node.depth, - height: node.height, - value: node.value, - ancestors: node.ancestors().map(d => (d.data as ChordHierarchyNode).key), - }) - } - node.x0 = Number.isNaN(node.x0) ? 0 : node.x0 - node.x1 = Number.isNaN(node.x1) ? 0 : node.x1 - node.uid = `${this.uid}-n${i}` - node._state = {} + this._layoutData() + } + + _layoutData (): void { + const { nodes, links } = this.datamodel + const { padAngle, linkValue, nodeLevels } = this.config + nodes.forEach(n => { delete n._state.value }) + links.forEach(l => { + delete l._state.points + l._state.value = getNumber(l, linkValue) + l.source._state.value = (l.source._state.value || 0) + getNumber(l, linkValue) + l.target._state.value = (l.target._state.value || 0) + getNumber(l, linkValue) + }) + + const root = getHierarchyNodes(nodes, d => d._state?.value, nodeLevels) + + const partitionData = partition().size([this.config.angleRange[1], 1])(root) as ChordNode + partitionData.each((n, i) => { + positionChildren(n, padAngle) + n.uid = `${this.uid.substr(0, 4)}-${i}` + n.x0 = Number.isNaN(n.x0) ? 0 : n.x0 + n.x1 = Number.isNaN(n.x1) ? 0 : n.x1 + n._state = {} }) const partitionDataWithRoot = partitionData.descendants() this._rootNode = partitionDataWithRoot.find(d => d.depth === 0) this._nodes = partitionDataWithRoot.filter(d => d.depth !== 0) // Filter out the root node - this._links = this._getRibbons(partitionData) + this._links = getRibbons(partitionData, links, padAngle) } _render (customDuration?: number): void { super._render(customDuration) const { config, bleed } = this + this._layoutData() const duration = isNumber(customDuration) ? customDuration : config.duration const size = Math.min(this._width, this._height) const radius = size / 2 - max([bleed.top, bleed.bottom, bleed.left, bleed.right]) - this.radiusScale.range([0, radius]) + this.radiusScale.range([0, radius - config.nodeWidth]) this.arcGen - .startAngle(d => d.x0) - .endAngle(d => d.x1) + .startAngle(d => d.x0 + config.padAngle / 2 - (d.value ? 0 : Math.PI / 360)) + .endAngle(d => d.x1 - config.padAngle / 2 + (d.value ? 0 : Math.PI / 360)) .cornerRadius(d => getNumber(d.data, config.cornerRadius)) .innerRadius(d => this.radiusScale(d.y1) - getNumber(d, config.nodeWidth)) .outerRadius(d => this.radiusScale(d.y1)) @@ -237,128 +230,6 @@ export class ChordDiagram< .call(removeLabel, duration) } - private _getHierarchyNodes (): HierarchyNode> { - const { config, datamodel: { nodes, links } } = this - nodes.forEach(n => { delete n._state.value }) - links.forEach(l => { - delete l._state.points - l.source._state.value = (l.source._state.value || 0) + getNumber(l, config.linkValue) - l.target._state.value = (l.target._state.value || 0) + getNumber(l, config.linkValue) - }) - - // TODO: Replace with d3-group - const nestGen = nest() - config.nodeLevels.forEach(levelAccessor => { - nestGen.key((d) => (d as unknown as Record)[levelAccessor]) - }) - const root = { key: 'root', values: nestGen.entries(nodes) } - const hierarchyNodes = hierarchy(root, d => d.values) - .sum((d) => (d as unknown as GraphNodeCore)._state?.value) - - return hierarchyNodes - } - - private _getRibbons (partitionData: ChordNode): ChordRibbon[] { - const { config, datamodel: { links } } = this - const findNode = ( - nodes: ChordLeafNode[], - id: string - ): ChordLeafNode => nodes.find(n => n.data._id === id) - const leafNodes = partitionData.leaves() as ChordLeafNode[] - - type LinksArrayType = typeof links - const groupedBySource: Record = groupBy(links, d => d.source._id) - const groupedByTarget: Record = groupBy(links, d => d.target._id) - - const getNodesInRibbon = ( - source: ChordLeafNode, - target: ChordLeafNode, - partitionHeight: number, - nodes: ChordLeafNode[] = [] - ): ChordNode[] => { - nodes[source.height] = source - nodes[partitionHeight * 2 - target.height] = target - if (source.parent && target.parent) getNodesInRibbon(source.parent, target.parent, partitionHeight, nodes) - return nodes - } - - const calculatePoints = ( - links: LinksArrayType, - type: 'in' | 'out', - depth: number - ): void => { - links.forEach(link => { - if (!link._state.points) link._state.points = [] - const sourceLeaf = findNode(leafNodes, link.source._id) - const targetLeaf = findNode(leafNodes, link.target._id) - const nodesInRibbon = getNodesInRibbon( - type === 'out' ? sourceLeaf : targetLeaf, - type === 'out' ? targetLeaf : sourceLeaf, - partitionData.height) - const currNode = nodesInRibbon[depth] - const len = currNode.x1 - currNode.x0 - const x0 = currNode._prevX1 ?? currNode.x0 - const x1 = x0 + len * getNumber(link, config.linkValue) / currNode.value - currNode._prevX1 = x1 - - const pointIdx = type === 'out' ? depth : partitionData.height * 2 - 1 - depth - link._state.points[pointIdx] = { - a0: Math.min(x0, x1), // - Math.PI / 2, - a1: Math.max(x0, x1), // - Math.PI / 2, - r: currNode.y1, - } - }) - } - - leafNodes.forEach(leafNode => { - const outLinks = groupedBySource[leafNode.data._id] || [] - const inLinks = groupedByTarget[leafNode.data._id] || [] - for (let depth = 0; depth < partitionData.height; depth += 1) { - calculatePoints(outLinks, 'out', depth) - calculatePoints(inLinks, 'in', depth) - } - }) - - const ribbons = links.map(l => { - const sourceNode = findNode(leafNodes, l.source._id) - const targetNode = findNode(leafNodes, l.target._id) - - return { - source: sourceNode, - target: targetNode, - data: l, - points: l._state.points, - _state: {}, - } - }) - - return ribbons - } - - private _calculateRadialPosition ( - hierarchyNode: ChordNode, - nodePadding = 0.02, - scalingCoeff = 0.95 - ): void { - if (!hierarchyNode.children) return - - // Calculate x0 and x1 - const nodeLength = (hierarchyNode.x1 - hierarchyNode.x0) - const scaledNodeLength = nodeLength * scalingCoeff - const delta = nodeLength - scaledNodeLength - let x0 = hierarchyNode.x0 + delta / 2 - for (const node of hierarchyNode.children) { - const childX0 = x0 - const childX1 = x0 + (node.value / hierarchyNode.value) * scaledNodeLength - nodePadding / 2 - const childNodeLength = childX1 - childX0 - const scaledChildNodeLength = childNodeLength * scalingCoeff - const childDelta = childNodeLength - scaledChildNodeLength - node.x0 = childX0 + childDelta / 2 - node.x1 = node.x0 + scaledChildNodeLength - x0 = childX1 + nodePadding / 2 + childDelta / 2 - } - } - private _onNodeMouseOver (d: ChordNode): void { let ribbons: ChordRibbon[] if (d.children) { @@ -370,6 +241,9 @@ export class ChordDiagram< const leaf = d as ChordLeafNode ribbons = this._links.filter(l => l.source.data.id === leaf.data.id || l.target.data.id === leaf.data.id) } + + // Nodes without links should still be highlighted + if (!ribbons.length) d._state.hovered = true this._highlightOnHover(ribbons) } diff --git a/packages/ts/src/components/chord-diagram/modules/layout.ts b/packages/ts/src/components/chord-diagram/modules/layout.ts new file mode 100644 index 000000000..e726c9c31 --- /dev/null +++ b/packages/ts/src/components/chord-diagram/modules/layout.ts @@ -0,0 +1,118 @@ +import { group, index } from 'd3-array' +import { HierarchyNode, hierarchy } from 'd3-hierarchy' +import { pie } from 'd3-shape' + +// Utils +import { getNumber, groupBy } from 'utils/data' + +// Types +import { NumericAccessor } from 'types/accessor' + +// Local Types +import { ChordNode, ChordRibbon, ChordLinkDatum, ChordHierarchyNode, ChordLeafNode } from '../types' + +function transformData (node: HierarchyNode): void { + const { height, depth } = node + if (height > 0) { + const d = node.data as unknown as [string, T[]] + const n = node as unknown as HierarchyNode> + n.data = { key: d[0], values: d[1], depth, height, ancestors: n.ancestors().map(d => d.data.key) } + } +} + +export function getHierarchyNodes ( + data: N[], + value: NumericAccessor, + levels: string[] = [] +): HierarchyNode> { + const nodeLevels = levels.map(level => (d: N) => d[level as keyof N]) as unknown as [(d: N) => string] + const nestedData = levels.length ? group(data, ...nodeLevels) : { key: 'root', children: data } + + const root = hierarchy(nestedData) + .sum(d => getNumber(d as unknown as N, value)) + .each(transformData) + + return root as unknown as HierarchyNode> +} + +export function positionChildren (node: ChordNode, padding: number, scalingCoeff = 0.95): void { + if (!node.children) return + + const length = node.x1 - node.x0 + const scaledLength = length * scalingCoeff + const delta = length - scaledLength + + const positions = pie>() + .startAngle(node.x0 + delta / 2) + .endAngle(node.x1 - delta / 2) + .padAngle(padding) + .value(d => d.value) + .sort((a, b) => node.children.indexOf(a) - node.children.indexOf(b))(node.children) + + node.children.forEach((child, i) => { + const x0 = positions[i].startAngle + const x1 = positions[i].endAngle + const childDelta = (x1 - x0) * (1 - scalingCoeff) + child.x0 = x0 + childDelta / 2 + child.x1 = x1 - childDelta / 2 + }) +} + +export function getRibbons (data: ChordNode, links: ChordLinkDatum[], padding: number): ChordRibbon[] { + type LinksArrayType = typeof links + const groupedBySource: Record = groupBy(links, d => d.source._id) + const groupedByTarget: Record = groupBy(links, d => d.target._id) + + const leafNodes = data.leaves() as ChordLeafNode[] + const leafNodesById: Map> = index(leafNodes, d => d.data._id) + + const getNodesInRibbon = ( + source: ChordLeafNode, + target: ChordLeafNode, + partitionHeight: number, + nodes: ChordNode[] = [] + ): ChordNode[] => { + nodes[source.height] = source + nodes[partitionHeight * 2 - target.height] = target + if (source.parent && target.parent) getNodesInRibbon(source.parent, target.parent, partitionHeight, nodes) + return nodes + } + const calculatePoints = (links: LinksArrayType, type: 'in' | 'out', depth: number, maxDepth: number): void => { + links.forEach(link => { + if (!link._state.points) link._state.points = [] + + const sourceLeaf = leafNodesById.get(link.source._id) + const targetLeaf = leafNodesById.get(link.target._id) + const nodesInRibbon = getNodesInRibbon( + type === 'out' ? sourceLeaf : targetLeaf, + type === 'out' ? targetLeaf : sourceLeaf, + maxDepth + ) + const currNode = nodesInRibbon[depth] + const len = currNode.x1 - currNode.x0 - padding + const x0 = currNode._prevX1 ?? (currNode.x0 + padding / 2) + const x1 = x0 + len * link._state.value / currNode.value + currNode._prevX1 = x1 + + const pointIdx = type === 'out' ? depth : maxDepth * 2 - 1 - depth + link._state.points[pointIdx] = { a0: x0, a1: x1, r: currNode.y1 } + }) + } + + leafNodes.forEach(leafNode => { + const outLinks = groupedBySource[leafNode.data._id] || [] + const inLinks = groupedByTarget[leafNode.data._id] || [] + for (let depth = 0; depth < leafNode.depth; depth += 1) { + calculatePoints(outLinks, 'out', depth, leafNode.depth) + calculatePoints(inLinks, 'in', depth, leafNode.depth) + } + }) + + return links.map(l => ({ + source: leafNodesById.get(l.source._id), + target: leafNodesById.get(l.target._id), + data: l, + points: l._state.points, + _state: {}, + })) +} diff --git a/packages/ts/src/components/chord-diagram/modules/link.ts b/packages/ts/src/components/chord-diagram/modules/link.ts index 9b046fb45..c5deaed48 100644 --- a/packages/ts/src/components/chord-diagram/modules/link.ts +++ b/packages/ts/src/components/chord-diagram/modules/link.ts @@ -1,6 +1,5 @@ import { Selection, select } from 'd3-selection' import { ribbon } from 'd3-chord' -import { path } from 'd3-path' import { ScalePower } from 'd3-scale' import { areaRadial } from 'd3-shape' import { Transition } from 'd3-transition' @@ -41,18 +40,17 @@ function linkGen (points: ChordRibbonPoint[], radiusScale: ScalePower radiusScale(d.r)) - if (points.length === 2) { - return link(points) as string - } - const p = path() - const src = points[0] - const radius = Math.max(radiusScale(src.r), 0) + const linkPath = link(points) as string + + if (points.length === 2) return linkPath - link.context(p as CanvasRenderingContext2D) - link(points) - p.arc(0, 0, radius, src.a0 - Math.PI / 2, src.a1 - Math.PI / 2, src.a1 - src.a0 <= Number.EPSILON) + // Replace closePath with line to starting point + const area = linkPath.slice(0, -1) + const path = area.concat(`L${area.match(/M-?\d*\.?\d*[,\s*]-?\d*\.?\d*/)?.[0].slice(1)}`) - return convertLineToArc(p, radius) + // Convert line edges to arcs + const radius = Math.max(radiusScale(points[0].r), 0) + return convertLineToArc(path, radius) } export function createLink ( diff --git a/packages/ts/src/components/chord-diagram/style.ts b/packages/ts/src/components/chord-diagram/style.ts index 70b7e397c..0e3d5ff68 100644 --- a/packages/ts/src/components/chord-diagram/style.ts +++ b/packages/ts/src/components/chord-diagram/style.ts @@ -63,12 +63,12 @@ export const label = css` ` export const labelText = css` - label: label-text: + label: label-text; dominant-baseline: middle; user-select: none; font-size: var(--vis-chord-diagram-label-text-font-size); - + > textPath { dominant-baseline: central; }