Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/ts/src/components/axis/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -715,4 +715,8 @@ export class Axis<Datum> extends XYComponentCore<Datum, AxisConfigInterface<Datu
default: return 0
}
}

protected _onDestroy (): void {
cancelAnimationFrame(this._collideTickLabelsAnimFrameId)
}
}
5 changes: 5 additions & 0 deletions packages/ts/src/components/crosshair/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,11 @@ export class Crosshair<Datum> extends XYComponentCore<Datum, CrosshairConfigInte
this.hide(event)
}

protected _onDestroy (): void {
window.cancelAnimationFrame(this._animFrameId)
this.container?.on('.crosshair', null)
}

_showTooltip (datum: Datum | undefined, xValue: number, pos: [number, number], nearestDatumIndex: number | undefined): void {
const { config, datamodel } = this
const tooltip = config.tooltip ?? this.tooltip
Expand Down
19 changes: 15 additions & 4 deletions packages/ts/src/components/graph/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,17 +241,19 @@
this._brush.call(this._brushBehavior)

// Activate the brush when the shift key is pressed
// The event namespace includes the component uid to prevent
// multiple Graph instances from overriding each other's listeners
select(window)
.on('keydown.unovis-graph', e => e.key === 'Shift' && this._activateBrush())
.on('keyup.unovis-graph', e => e.key === 'Shift' && this._clearBrush())
.on(`keydown.unovis-graph-${this.uid}`, e => e.key === 'Shift' && this._activateBrush())
.on(`keyup.unovis-graph-${this.uid}`, e => e.key === 'Shift' && this._clearBrush())

this._zoomBehavior.filter(event => !event.shiftKey)
} else {
this._brush.on('.brush', null)

select(window)
.on('keydown.unovis-graph', null)
.on('keyup.unovis-graph', null)
.on(`keydown.unovis-graph-${this.uid}`, null)
.on(`keyup.unovis-graph-${this.uid}`, null)

// Clear brush in case it was disabled in an active state
if (this._brush.classed('active')) this._clearBrush()
Expand Down Expand Up @@ -669,21 +671,21 @@
}

// eslint-disable-next-line @typescript-eslint/no-empty-function
private _onNodeClick (d: GraphNode<N, L>): void {

Check warning on line 674 in packages/ts/src/components/graph/index.ts

View workflow job for this annotation

GitHub Actions / lint

'd' is defined but never used
}

// eslint-disable-next-line @typescript-eslint/no-empty-function
private _onNodeMouseOut (d: GraphNode<N, L>): void {

Check warning on line 678 in packages/ts/src/components/graph/index.ts

View workflow job for this annotation

GitHub Actions / lint

'd' is defined but never used
this._updateNodesLinksPartial()
}

// eslint-disable-next-line @typescript-eslint/no-empty-function
private _onNodeMouseOver (d: GraphNode<N, L>): void {

Check warning on line 683 in packages/ts/src/components/graph/index.ts

View workflow job for this annotation

GitHub Actions / lint

'd' is defined but never used
this._updateNodesLinksPartial()
}

// eslint-disable-next-line @typescript-eslint/no-empty-function
private _onLinkClick (d: GraphLink<N, L>): void {

Check warning on line 688 in packages/ts/src/components/graph/index.ts

View workflow job for this annotation

GitHub Actions / lint

'd' is defined but never used
}

private _onLinkMouseOver (d: GraphLink<N, L>): void {
Expand Down Expand Up @@ -1144,4 +1146,13 @@
const nodeElements = this._nodesGroup.selectAll<SVGGElement, GraphNode<N, L>>(`.${nodeSelectors.gNode}:not(.${nodeSelectors.gNodeExit})`)
updateNodePositions(nodeElements, animDuration)
}

protected _onDestroy (): void {
this._timer?.stop()
this._timer = undefined

select(window)
.on(`keydown.unovis-graph-${this.uid}`, null)
.on(`keyup.unovis-graph-${this.uid}`, null)
}
}
4 changes: 2 additions & 2 deletions packages/ts/src/components/leaflet-flow-map/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,12 +229,12 @@ export class LeafletFlowMap<
this.canvasElement.style.transform = `translate(${shift.x}px, ${shift.y}px)`
}

public destroy (): void {
protected _onDestroy (): void {
this.resizeObserver?.disconnect()
this.renderer?.destroy()
this.canvasElement?.removeEventListener('mousemove', this.onCanvasMouseMoveBound)
this.canvasElement?.removeEventListener('click', this.onCanvasClickBound)
super.destroy()
this.leafletMap?.destroy()
}

// Leaflet Map useful methods
Expand Down
3 changes: 1 addition & 2 deletions packages/ts/src/components/leaflet-map/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -520,7 +520,7 @@

// Render content
const points = this._pointGroup.selectAll<SVGGElement, LeafletMapPoint<Datum>>(`.${s.point}:not(.exit)`)
.data(pointData, (d: LeafletMapPoint<Datum>, i) => `${d.id || d.geometry.coordinates.join('')}`)

Check warning on line 523 in packages/ts/src/components/leaflet-map/index.ts

View workflow job for this annotation

GitHub Actions / lint

'i' is defined but never used

points.exit<LeafletMapPoint<Datum>>().classed('exit', true).call(removeNodes)
const pointsEnter = points.enter().append('g').attr('class', s.point)
Expand All @@ -533,7 +533,7 @@

this._clusterBackground.call(updateBackgroundNode, this._expandedCluster, config, this._map.leaflet, this._clusterBackgroundRadius)
if (this._expandedCluster && config.clusterBackground) {
pointData.forEach((d, i) => { d._zIndex = (d.properties as LeafletMapPointDatum<Datum>)?.expandedClusterPoint ? 2 : 0 })

Check warning on line 536 in packages/ts/src/components/leaflet-map/index.ts

View workflow job for this annotation

GitHub Actions / lint

'i' is defined but never used
this._pointGroup
.selectAll<SVGGElement, LeafletMapPoint<Datum>>(`.${s.point}, .${s.clusterBackground}, .${s.pointSelectionRing}`)
.sort((a: LeafletMapPoint<Datum>, b: LeafletMapPoint<Datum>) => a._zIndex - b._zIndex)
Expand Down Expand Up @@ -647,7 +647,7 @@

if (this._expandedCluster) {
// Remove expanded cluster from the data
geoJSONPoints = geoJSONPoints.filter(c => (c as ClusterFeature<LeafletMapClusterDatum<Datum>>).properties.cluster_id !== (this._expandedCluster.cluster.properties as LeafletMapClusterDatum<Datum>).cluster_id)

Check warning on line 650 in packages/ts/src/components/leaflet-map/index.ts

View workflow job for this annotation

GitHub Actions / lint

This line has a length of 214. Maximum allowed is 200
// Add points from the expanded cluster
geoJSONPoints = geoJSONPoints.concat(this._expandedCluster.points)
}
Expand Down Expand Up @@ -747,7 +747,7 @@
config.onMapMoveZoom?.(this._getMapZoomState())
}

private _onBackgroundClick (el: HTMLElement, event: MouseEvent): void {

Check warning on line 750 in packages/ts/src/components/leaflet-map/index.ts

View workflow job for this annotation

GitHub Actions / lint

'event' is defined but never used

Check warning on line 750 in packages/ts/src/components/leaflet-map/index.ts

View workflow job for this annotation

GitHub Actions / lint

'el' is defined but never used
const { config } = this

if (this._cancelBackgroundClick) {
Expand Down Expand Up @@ -779,7 +779,7 @@
}
}

private _onPointMouseDown (d: LeafletMapPoint<Datum>, event: MouseEvent): void {

Check warning on line 782 in packages/ts/src/components/leaflet-map/index.ts

View workflow job for this annotation

GitHub Actions / lint

'd' is defined but never used
this._cancelBackgroundClick = true
}

Expand Down Expand Up @@ -813,7 +813,7 @@
this.fitToPoints()
}

public destroy (): void {
protected _onDestroy (): void {
constraintMapViewThrottled.cancel()
cancelAnimationFrame(this._renderDataAnimationFrameId)
cancelAnimationFrame(this._flyToBoundsAnimationFrameId)
Expand All @@ -822,7 +822,6 @@

map?.stop()
map?.remove()
this.g.remove()
this.resizeObserver.disconnect()
this.themeObserver?.disconnect()
}
Expand Down
4 changes: 2 additions & 2 deletions packages/ts/src/components/tooltip/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -360,12 +360,12 @@ export class Tooltip {
const template = currentConfig.triggers[className]
if (!template) continue // Skip if the trigger is not configured

const els = selection.selectAll<HTMLElement | SVGGElement, unknown>(`.${className}`).nodes()

// Go through all of the elements in the event path (from the deepest element upwards)
for (const el of path) {
if (el === selection.node()) break // Break on the component's level (usually the `<g>` element)
if (el.classList.contains(className)) { // If there's a match, show the tooltip
// Query the trigger elements only after a match (we need the element index for the template)
const els = selection.selectAll<HTMLElement | SVGGElement, unknown>(`.${className}`).nodes()
const i = els.indexOf(el)
const d = select(el).datum()
const content = template(d, i, els)
Expand Down
2 changes: 1 addition & 1 deletion packages/ts/src/components/topojson-map/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1571,7 +1571,7 @@ export class TopoJSONMap<
this._renderPoints(this.config.duration)
}

destroy (): void {
protected _onDestroy (): void {
window.cancelAnimationFrame(this._animFrameId)
this._stopFlowAnimation()
window.cancelAnimationFrame(this._collisionDetectionAnimFrameId)
Expand Down
4 changes: 3 additions & 1 deletion packages/ts/src/containers/single-container/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,11 @@ export class SingleContainer<Data> extends ContainerCore {
public setData (data: Data, preventRender?: boolean): void {
const { config } = this

// Hide the tooltip only when the data has actually changed
const hasDataUpdated = this.component?.datamodel.data !== data
if (this.component) this.component.setData(data)
if (!preventRender) this.render()
config.tooltip?.hide()
if (hasDataUpdated) config.tooltip?.hide()
}

public updateContainer (containerConfig: SingleContainerConfigInterface<Data>, preventRender?: boolean): void {
Expand Down
18 changes: 16 additions & 2 deletions packages/ts/src/containers/xy-container/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import { ScaleDimension } from '@/types/scale'
import { Direction } from '@/types/direction'

// Utils
import { clamp, clean, flatten, isEqual } from '@/utils/data'
import { clamp, clean, flatten } from '@/utils/data'
import { guid } from '@/utils/misc'

// Config
Expand Down Expand Up @@ -117,6 +117,7 @@ export class XYContainer<Datum> extends ContainerCore {
const { components, config } = this
if (!data) return

const hasDataUpdated = this.datamodel.data !== data
this.datamodel.data = data

components.forEach((c) => {
Expand All @@ -130,7 +131,6 @@ export class XYContainer<Datum> extends ContainerCore {
// Hide tooltip and crosshair if the data has changed
// Important: We still want to do `setData` for the components above even if the data hasn't changed
// because calling `updateContainer` may add new components and we need to pass them the data
const hasDataUpdated = !isEqual(this.datamodel.data, data)
if (hasDataUpdated) {
config.tooltip?.hide()
config.crosshair?.hide()
Expand Down Expand Up @@ -197,6 +197,11 @@ export class XYContainer<Datum> extends ContainerCore {
public updateComponents (componentConfigs: XYConfigInterface<Datum>[], preventRender?: boolean): void {
const { config } = this

// Configs are matched to components by array index
if (componentConfigs && componentConfigs.length !== this.components.length) {
console.warn('Unovis | XY Container: The number of provided component configs doesn\'t match the number of components. Configs are matched to components by index, so some components won\'t be updated')
}

this.components.forEach((c, i) => {
const componentConfig = componentConfigs[i]
if (componentConfig) {
Expand Down Expand Up @@ -444,6 +449,15 @@ export class XYContainer<Datum> extends ContainerCore {
}
}

protected _onResize (): void {
const { config } = this
super._onResize()

// Hide the tooltip and crosshair on resize to prevent them from rendering at a stale position
config.tooltip?.hide()
config.crosshair?.hide()
}

private _getBleed<T extends XYComponentCore<Datum>> (components: T[]): Spacing {
return components.map(c => c.bleed).reduce((bleed, b) => {
for (const key of Object.keys(bleed)) {
Expand Down
13 changes: 12 additions & 1 deletion packages/ts/src/core/component/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,8 +160,11 @@ export class ComponentCore<
Object.keys(events[className]).forEach(eventType => {
const selection = (this.g as Selection<SVGGElement | HTMLElement, unknown, null, undefined>)
.selectAll<SVGGElement | HTMLElement, unknown>(`.${className}`)

// Snapshot the matched elements once at bind time (events are re-bound after every render)
// instead of re-querying the DOM on every dispatched event
const els = selection.nodes()
selection.on(eventType + suffix, (event: MouseEvent & WheelEvent & PointerEvent & TouchEvent, d) => {
const els = selection.nodes()
const i = els.indexOf(event.currentTarget as SVGGElement | HTMLElement)
const eventFunction = events[className][eventType as VisEventType]
const { datum, index } = this._mapEventDatum(d, i)
Expand All @@ -171,7 +174,15 @@ export class ComponentCore<
})
}

/** Override to release component-specific resources (timers, animation frames, global event
* listeners, observers). Called by `destroy()` before the component's DOM node is removed. */
// eslint-disable-next-line @typescript-eslint/no-empty-function
protected _onDestroy (): void {}

public destroy (): void {
if (this.isDestroyed()) return

this._onDestroy()
this.g?.remove()
this.element = undefined
}
Expand Down
73 changes: 56 additions & 17 deletions packages/ts/src/data-models/graph.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { isNumber, isUndefined, cloneDeep, isFunction, without, isString, isObject, isEqual } from '@/utils/data'
import { isNumber, isUndefined, cloneDeep, isFunction, isString, isObject, isEqual } from '@/utils/data'

// Types
import { GraphInputLink, GraphInputNode, GraphLinkCore, GraphNodeCore } from '@/types/graph'
Expand All @@ -23,6 +23,8 @@ export class GraphDataModel<
private _links: OutLink[] = []
private _inputNodesMap = new Map<OutNode, N>()
private _nodesMap = new Map<string | number, OutNode>()
private _nodesByUserId = new Map<string, OutNode>()
private _nodesByInputRef = new Map<N, OutNode>()

// Model configuration
public nodeId: ((n: N) => string | undefined) = n => (isString(n.id) || isFinite(n.id as number)) ? `${n.id}` : undefined
Expand All @@ -45,6 +47,8 @@ export class GraphDataModel<

this._inputNodesMap.clear()
this._nodesMap.clear()
this._nodesByUserId.clear()
this._nodesByInputRef.clear()

// Todo: Figure out why TypeScript complains about types
const nodes = cloneDeep(inputData?.nodes ?? []) as undefined as OutNode[]
Expand All @@ -61,6 +65,12 @@ export class GraphDataModel<
node._id = this.nodeId(node) || `${i}`
this._inputNodesMap.set(node, inputData.nodes[i])
this._nodesMap.set(node._id, node)
this._nodesByInputRef.set(inputData.nodes[i], node)

// Index nodes by their user-provided id for link endpoint resolution.
// The first node wins on duplicate ids, matching lookup-by-scan behavior
const userId = this.nodeId(node)
if (userId !== undefined && !this._nodesByUserId.has(userId)) this._nodesByUserId.set(userId, node)
})

// Sort nodes
Expand All @@ -73,31 +83,48 @@ export class GraphDataModel<
link.target = this.findNode(nodes, link.target)
})

// Set link index for multiple link rendering
links.forEach((link, i) => {
if (!isUndefined(link._index) && !isUndefined(link._neighbours)) return
// Group links connecting the same pair of nodes (in either direction) to set
// their index for multiple link rendering. Nodes are keyed by `_index` because
// it's unique per node object (unlike `_id`, which can collide on duplicate user ids)
const linkGroups = new Map<string, OutLink[]>()
for (const link of links) {
const sourceIndex = (link.source as OutNode)?._index ?? -1
const targetIndex = (link.target as OutNode)?._index ?? -1
const key = sourceIndex <= targetIndex ? `${sourceIndex}|${targetIndex}` : `${targetIndex}|${sourceIndex}`
const group = linkGroups.get(key)
if (group) group.push(link)
else linkGroups.set(key, [link])
}

const linksFiltered = links.filter(l =>
((link.source === l.source) && (link.target === l.target)) ||
((link.source === l.target) && (link.target === l.source))
)
linkGroups.forEach(group => {
// Links that came in with `_index` and `_neighbours` already set keep their values,
// but a single new link in the group triggers a reindex of the whole group
if (group.every(l => !isUndefined(l._index) && !isUndefined(l._neighbours))) return

linksFiltered.forEach((l, i) => {
const firstLink = group[0]
group.forEach((l, i) => {
l._index = i
l._id = this.linkId(l) || `${l.source?._id}-${l.target?._id}-${i}`
l._neighbours = linksFiltered.length
l._direction = ((link.source === l.source) && (link.target === l.target)) ? 1 : -1
l._neighbours = group.length
l._direction = ((firstLink.source === l.source) && (firstLink.target === l.target)) ? 1 : -1
})
})

// Determine if a node is connected or not and store its links as a property
const linksByNode = new Map<OutNode, OutLink[]>()
nodes.forEach(node => linksByNode.set(node, []))
for (const l of links) {
if (l.source) linksByNode.get(l.source as OutNode)?.push(l)
if (l.target && l.target !== l.source) linksByNode.get(l.target as OutNode)?.push(l)
}

nodes.forEach(d => {
// Determine if a node is connected or not and store it as a property
d.links = links.filter(l => (l.source === d) || (l.target === d))
d.links = linksByNode.get(d)
d._isConnected = d.links.length !== 0
})

this._nonConnectedNodes = nodes.filter(d => !d._isConnected)
this._connectedNodes = without(nodes, ...this._nonConnectedNodes)
this._connectedNodes = nodes.filter(d => d._isConnected)

this._nodes = nodes

Expand Down Expand Up @@ -131,8 +158,13 @@ export class GraphDataModel<
let foundNode: OutNode | undefined

if (isNumber(nodeIdentifier)) foundNode = nodes[nodeIdentifier as number]
else if (isString(nodeIdentifier)) foundNode = nodes.find(node => this.nodeId(node) === nodeIdentifier)
else if (isObject(nodeIdentifier)) foundNode = nodes.find(node => isEqual(this._inputNodesMap.get(node), nodeIdentifier))
else if (isString(nodeIdentifier)) foundNode = this._nodesByUserId.get(nodeIdentifier as string)
else if (isObject(nodeIdentifier)) {
// Fast path: the identifier is the same object as one of the input nodes.
// Fall back to a deep-equality scan for value-equal but not identical objects
foundNode = this._nodesByInputRef.get(nodeIdentifier as N) ??
nodes.find(node => isEqual(this._inputNodesMap.get(node), nodeIdentifier))
}

if (!foundNode) {
console.warn(`Unovis | Graph Data Model: Node ${nodeIdentifier} is missing from the nodes list`)
Expand All @@ -146,8 +178,15 @@ export class GraphDataModel<
itemsPrev: T[],
getId: (d: T) => string
): void {
// The first item wins on duplicate ids, matching lookup-by-scan behavior
const prevById = new Map<string, T>()
for (const dPrev of itemsPrev) {
const id = getId(dPrev)
if (!prevById.has(id)) prevById.set(id, dPrev)
}

for (const item of items) {
const dPrev = itemsPrev.find((dp) => getId(dp) === getId(item))
const dPrev = prevById.get(getId(item))
if (dPrev) item._state = { ...dPrev._state }
else item._state = {}
}
Expand Down
2 changes: 2 additions & 0 deletions packages/ts/src/utils/data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ export const isEqual = (
skipKeys: string[] = [],
visited: Set<any> = new Set()
): boolean => {
if (a === b) return true

if (Array.isArray(a)) {
if (!Array.isArray(b) || a.length !== b.length) return false

Expand Down
Loading