Skip to main content

Reorderable Adjacency Matrix

This is the Vega Reorderable Matrix Example

Vega example


NetPanorama example

Vega specification

Rendering the background for empty table cells requires creating a new dataset (cross).

Rendering the upper- and lower- halves of the matrix requires separate marks.

The table uses a scale to set the x- and y- coordinates; there's no way to switch to using matrix seriation methods.

The signal flow needed to re-order in response to dragging is complex, and difficult to either read or write.


{
"$schema": "https://vega.github.io/schema/vega/v5.json",
"description": "A re-orderable adjacency matrix depicting character co-occurrence in the novel Les Misérables.",
"width": 770,
"height": 770,
"padding": 2,

Define various signals

After a drag to re-order, src will be set to the corresponding item, and dest to its new position.


"signals": [
{ "name": "cellSize", "value": 10 },
{ "name": "count", "update": "length(data('nodes'))" },
{ "name": "width", "update": "span(range('position'))" },
{ "name": "height", "update": "width" },
{
"name": "src", "value": {},
"on": [
{"events": "text:mousedown", "update": "datum"},
{"events": "window:mouseup", "update": "{}"}
]
},
{
"name": "dest", "value": -1,
"on": [
{
"events": "[@columns:mousedown, window:mouseup] > window:mousemove",
"update": "src.name && datum !== src ? (0.5 + count * clamp(x(), 0, width) / width) : dest"
},
{
"events": "[@rows:mousedown, window:mouseup] > window:mousemove",
"update": "src.name && datum !== src ? (0.5 + count * clamp(y(), 0, height) / height) : dest"
},
{"events": "window:mouseup", "update": "-1"}
]
}
],



"data": [

Load node data and do some transforms??


{
"name": "nodes",
"url": "/data/miserables.json",
"format": {"type": "json", "property": "nodes"},
"transform": [
{
"type": "formula", "as": "order",
"expr": "datum.group"
},
{
"type": "formula", "as": "score",
"expr": "dest >= 0 && datum === src ? dest : datum.order"
},
{
"type": "window", "sort": {"field": "score"},
"ops": ["row_number"], "as": ["order"]
}
]
},

Load edge data


{
"name": "edges",
"url": "/data/miserables.json",
"format": {"type": "json", "property": "links"},
"transform": [
{
"type": "lookup", "from": "nodes", "key": "index",
"fields": ["source", "target"], "as": ["sourceNode", "targetNode"]
},
{
"type": "formula", "as": "group",
"expr": "datum.sourceNode.group === datum.targetNode.group ? datum.sourceNode.group : count"
}
]
},

Creat new dataset used to render background cells.


{
"name": "cross",
"source": "nodes",
"transform": [
{ "type": "cross" }
]
}
],



"scales": [
{
"name": "position",
"type": "band",
"domain": {"data": "nodes", "field": "order", "sort": true},
"range": {"step": {"signal": "cellSize"}}
},
{
"name": "color",
"type": "ordinal",
"range": "category",
"domain": {
"fields": [
{"data": "nodes", "field": "group"},
{"signal": "count"}
],
"sort": true
}
}
],



"marks": [

Render background for empty table cells (light grey, or darker gray for row/column that are being re-ordered)


{
"type": "rect",
"from": {"data": "cross"},
"encode": {
"update": {
"x": {"scale": "position", "field": "a.order"},
"y": {"scale": "position", "field": "b.order"},
"width": {"scale": "position", "band": 1, "offset": -1},
"height": {"scale": "position", "band": 1, "offset": -1},
"fill": [
{"test": "datum.a === src || datum.b === src", "value": "#ddd"},
{"value": "#f5f5f5"}
]
}
}
},

Render half of the matrix


{
"type": "rect",
"from": {"data": "edges"},
"encode": {
"update": {
"x": {"scale": "position", "field": "sourceNode.order"},
"y": {"scale": "position", "field": "targetNode.order"},
"width": {"scale": "position", "band": 1, "offset": -1},
"height": {"scale": "position", "band": 1, "offset": -1},
"fill": {"scale": "color", "field": "group"}
}
}
},

Render other half of the matrix


{
"type": "rect",
"from": {"data": "edges"},
"encode": {
"update": {
"x": {"scale": "position", "field": "targetNode.order"},
"y": {"scale": "position", "field": "sourceNode.order"},
"width": {"scale": "position", "band": 1, "offset": -1},
"height": {"scale": "position", "band": 1, "offset": -1},
"fill": {"scale": "color", "field": "group"}
}
}
},

Label the columns (with blue text for the column being reordered)


{
"type": "text",
"name": "columns",
"from": {"data": "nodes"},
"encode": {
"update": {
"x": {"scale": "position", "field": "order", "band": 0.5},
"y": {"offset": -2},
"text": {"field": "name"},
"fontSize": {"value": 10},
"angle": {"value": -90},
"align": {"value": "left"},
"baseline": {"value": "middle"},
"fill": [
{"test": "datum === src", "value": "steelblue"},
{"value": "black"}
]
}
}
},

Label the rows (with blue text for the row being reordered)


{
"type": "text",
"name": "rows",
"from": {"data": "nodes"},
"encode": {
"update": {
"x": {"offset": -2},
"y": {"scale": "position", "field": "order", "band": 0.5},
"text": {"field": "name"},
"fontSize": {"value": 10},
"align": {"value": "right"},
"baseline": {"value": "middle"},
"fill": [
{"test": "datum === src", "value": "steelblue"},
{"value": "black"}
]
}
}



]
}

NetPanorama specification

NetPanorama has more limited support for interactions: it doesn't allow matching an arbitrary expression against an event stream. On the other hand, implementation of those interactions that it does support is simpler than it would be using Vega.


{
"x": 120,
"y": 120,

"width": 1000,
"height": 1000,

Import data and construct network, and cluster nodes.


"data": [
{
"name": "nodes",
"url": "/data/miserables.json",
"format": {"type": "json", "property": "nodes"},
"transform": [
{ "type": "calculate", "calculate": "index", "as": "id" }
]
},
{
"name": "links",
"url": "/data/miserables.json",
"format": {"type": "json", "property": "links"}
}
],

...and assemble into a network.


"networks": [
{
"name": "network",
"nodes": "nodes",
"links": "links",
"directed": true,
"source_node": [ "index", "source" ],
"target_node": [ "index", "target" ],

"addReverseLinks": true // avoids duplication in mark section
}
],

Create two color scales.


"scales": [
{
"name": "color",
"type": "linear",
"scheme": "blues",
"domain": [0, 10]
},
{
"name": "clusterColor",
"type": "linear",
"scheme": "tableau10",
"domain": { "data": "network.nodes", "field": "group" }
}
],

Order nodes based on the cluster id.


"orderings": [
{
"name": "o",
"data": "network.nodes",
"allowTies": false,
"orderBy": [{"field": "group"}]
}
],



Construct a table.


"tables": [
{
"name": "adjacencyMatrix",
"dragToReorder": true,
"data": "network.links",
"rowOrder": {
"order": "o", "field": "source"
},
"colOrder": {
"order": "o", "field": "target"
}
}

],

Draw contents of adjacency matrix,a nd label the rows and columns.


"vis": [
{
"table": "adjacencyMatrix",

"rowLabels": { "field": "source.name", "align": "right", "fill": {"field": "source.group", "scale": "clusterColor"}, "dx": -10 },
"colLabels": { "field": "target.name", "dx": 20, "fill": {"field": "target.group", "scale": "clusterColor"} },

"dragToReorder": true,

"rowLines": { "stroke": "white" },

"colLines": { "stroke": "white" }



"mark": {
"type": "square",
"area": {"expression": "(bounds.width * bounds.height)"},
"x": {"expression": "bounds.x"},
"y": {"expression": "bounds.y + bounds.height/4"},

"stroke": "white",

"fill": {

"condition": {

"test": "datum.size < 1",

"value": "#f5f5f5"

},



"conditions": [
{
"test": "datum.size < 1",
"value": "#f5f5f5"
},

{
"test": "datum.key[0].group !== datum.key[1].group",
"value": "#6a3d9a"
}
],

"scale": "clusterColor", "expression": "datum.key[0].group"
},

"tooltip": { "expression": "datum.key[0].name + ' <-> ' + datum.key[1].name"}
}
}
]
}