Zoomable Circle Packing Example
This is an extension of the Circle Packing Example. This version was implemented by @giammaria. It incorporates a timer to facilitate zoom and fade animations, offering a technique beneficial for drill-down behavior and exploration. For simplicity, the example reveals only a few text marks upon zoom, corresponding to the user-selected node. Alternatively, you can render various mark types, axes, legends, etc., to provide additional insights into the selected node. Here is a more involved example in the editor.
Vega JSON Specification <>
{
"$schema": "https://vega.github.io/schema/vega/v5.json",
"description": "An example of a zoomable circle packing layout for hierarchical data.",
"width": 600,
"height": 600,
"padding": 5,
"signals": [
{
"name": "duration",
"init": "750",
"description": "The duration for the zoom transitions. Fade-in transitions will be the same duration, but will be delayed per the amount set here.",
"on": [
{
"events": {"type": "click", "marknames": ["circles", "background"]},
"update": "(event.metaKey || event.ctrlKey ? 4 : 1) *750"
}
]
},
{
"name": "k",
"value": 1,
"description": "The scale used for zooming based on the focused node",
"on": [
{
"events": [{"signal": "focus"}],
"update": "focus ? width/(focus.r*2) : 1"
}
]
},
{
"name": "root",
"update": "{'id': data('tree')[0]['id'], 'x': data('tree')[0]['x'], 'y': data('tree')[0]['y'], 'r': data('tree')[0]['r'], 'k': 1, 'children': data('tree')[0]['children']}",
"description": "The root node in the hierarchy"
},
{
"name": "focus",
"init": "root",
"description": "The zoomed-in node in the hierarchy",
"on": [
{
"events": {"type": "click", "markname": "background"},
"update": "{id: root['id'], 'x': root['x'], 'y': root['y'], 'r': root['r'], 'k': 1,'children': root['children']}"
},
{
"events": {"type": "click", "markname": "circles"},
"update": "(focus['x'] === datum['x'] && focus['y'] === datum['y'] && focus['r'] === datum['r'] && focus['r'] !== root['r']) ? {'id': root['id'], 'x': root['x'], 'y': root['y'], 'r': root['r'], 'k': 1, 'children': root['children']} : {'id': datum['id'], 'x': datum['x'], 'y': datum['y'], 'r': datum['r'], 'k': k, 'children': datum['children']}"
}
]
},
{
"name": "focus0",
"update": "data('focus0') && length(data('focus0'))>0 ? data('focus0')[0] : focus",
"description": "The prior zoomed-in node in the hierarchy"
},
{
"name": "timer",
"description": "The timer to be used for transitions such as zoom, fade, etc.",
"on": [{"events": "timer", "update": "now()"}]
},
{
"name": "interpolateTime",
"description": "the start and end times in miliseconds for animation interpolations",
"on": [
{
"events": {
"type": "click",
"marknames": ["circles", "background"]
},
"update": "{'start': timer, 'end': timer+duration}"
}
]
},
{
"name": "t",
"description": "The normalized time for easing",
"update": "interpolateTime ? clamp((timer-interpolateTime.start)/(interpolateTime.end-interpolateTime.start), 0, 1): null"
},
{
"name": "tEase",
"description": "The easing calculation. Currently set as easeInOutCubic",
"update": "t < 0.5 ? 4 * t * t * t : (t - 1) * (2 * t - 2) * (2 * t - 2) + 1"
},
{
"name": "interpolateTimeDelayed",
"description": "The delayed time for easing",
"on": [
{
"events": {"signal": "interpolateTime"},
"update": "{'start': interpolateTime['end'], 'end': interpolateTime['end']+duration}"
}
]
},
{
"name": "tDelayed",
"description": "The delayed normalized time for easing",
"update": "interpolateTimeDelayed ? clamp((timer-interpolateTimeDelayed.start)/(interpolateTimeDelayed.end-interpolateTimeDelayed.start), 0, 1): null"
},
{
"name": "tEaseDelayed",
"description": "The delayed easing calculation. Currently set as easeInOutCubic",
"update": "tDelayed < 0.5 ? 4 * tDelayed * tDelayed * tDelayed : (tDelayed - 1) * (2 * tDelayed - 2) * (2 * tDelayed - 2) + 1"
},
{
"name": "showDetails",
"description": "A boolean to indicate whether to show a node's details",
"value": false,
"on": [
{
"events": {
"type": "click",
"marknames": ["circles", "background"],
"filter": [
"!event.altKey && !event.shiftKey",
"event.button === 0"
],
"markname": "circles"
},
"update": "focus['children'] > 0 ? false : datum['id'] === root['id'] || focus0['id'] !== root['id'] && focus['id'] === root['id'] ? false : true"
},
{
"events": {
"type": "click",
"marknames": ["circles", "background"],
"filter": ["event.altKey || event.shiftKey", "event.button === 0"]
},
"update": "focus0['id'] === focus['id'] ? !showDetails : true"
}
]
}
],
"data": [
{
"name": "source",
"url": "data/flare.json",
"transform": [
{
"type": "formula",
"expr": "isValid(datum['parent']) ? datum['parent'] : null",
"as": "parent"
},
{
"type": "formula",
"expr": "isValid(datum['size']) ? datum['size'] : null",
"as": "size"
}
]
},
{
"name": "tree",
"source": "source",
"transform": [
{"type": "stratify", "key": "id", "parentKey": "parent"},
{
"type": "pack",
"field": "size",
"sort": {"field": "value"},
"size": [{"signal": "width"}, {"signal": "height"}]
}
]
},
{
"name": "focus0",
"on": [{"trigger": "focus", "insert": "focus"}],
"transform": [
{"type": "formula", "expr": "now()", "as": "now"},
{
"type": "window",
"ops": ["row_number"],
"as": ["row"],
"sort": {"field": "now", "order": "descending"}
},
{"type": "filter", "expr": "datum['row'] ? datum['row'] == 2 : true "},
{"type": "project", "fields": ["id", "x", "y", "r", "children"]},
{"type": "formula", "expr": "width/(datum['r']*2)", "as": "k"}
]
},
{
"name": "details_data",
"source": "tree",
"transform": [
{
"type": "filter",
"expr": "datum['id'] === focus['id'] && showDetails"
},
{
"type": "formula",
"expr": "['hierarchy depth: ' + datum['depth'], 'children count: ' + datum['children'],isValid( datum['size']) ? 'size: ' + datum['size'] + ' bytes' : '']",
"as": "details"
}
]
}
],
"scales": [
{
"name": "color",
"type": "ordinal",
"domain": {"data": "tree", "field": "depth"},
"range": {"scheme": "magma"}
}
],
"marks": [
{
"name": "background",
"description": "An ivisible rect that covers the entire canvas and sits behind everything",
"type": "rect",
"encode": {
"enter": {
"x": {"signal": "-padding['left']"},
"y": {"signal": "-padding['top']"},
"width": {"signal": "width+padding['left']+padding['right']"},
"height": {"signal": "height+padding['top']+padding['bottom']"},
"fillOpacity": {"value": 0}
}
}
},
{
"name": "circles",
"description": "the zoomable packed circles",
"type": "symbol",
"from": {"data": "tree"},
"encode": {
"enter": {
"shape": {"value": "circle"},
"fill": {"scale": "color", "field": "depth"},
"cursor": {"value": "pointer"},
"tooltip": {"field": "name"}
},
"update": {
"x": {
"signal": "lerp([root['x']+ (datum['x'] - focus0['x']) * focus0['k'], root['x'] + (datum['x'] - focus['x']) * k], tEase)"
},
"y": {
"signal": "lerp([ root['y'] + (datum['y'] - focus0['y']) * focus0['k'], root['y'] + (datum['y'] - focus['y']) * k], tEase)"
},
"size": {
"signal": "pow(2*(datum['r'] * lerp([focus0['k'], k],tEase)),2)"
},
"fill": {
"signal": "showDetails && focus['id'] === datum['id'] ? '#fff' : scale('color',datum['depth'])"
},
"zindex": {
"signal": "!showDetails ? 1 : (focus['id'] === root['id'] && isValid(datum['parent'])) ? -99 : indexof(pluck(treeAncestors('tree', datum['id']), 'id'), focus['id']) > 0 ? -99 : 1"
},
"stroke": {
"signal": "showDetails ? scale('color', datum['depth']) : luminance(scale('color', datum['depth'])) > 0.5 ? 'black' : 'white'"
},
"strokeWidth": {
"signal": "focus['id'] === datum['id'] && showDetails ? 20 : 0.5"
},
"strokeOpacity": {
"signal": "!showDetails ? 0.5 : focus['id'] === root['id'] ? min(tEase, 0.35) : min(tEaseDelayed, 0.35)"
}
},
"hover": {
"color": {
"signal": "showDetails ? scale('color', datum['depth']) : luminance(scale('color', datum['depth'])) > 0.5 ? 'black' : 'white'"
},
"strokeWidth": {"value": 2}
}
}
},
{
"name": "details_title",
"details": "the name of the node (appears on zoom)",
"type": "text",
"from": {"data": "details_data"},
"interactive": false,
"encode": {
"enter": {
"text": {"signal": "datum['name']"},
"fill": {"scale": "color", "field": "depth"},
"fontSize": {"signal": "0.055*width"},
"align": {"value": "center"},
"x": {"signal": "width/2"},
"y": {"signal": "height/4"},
"opacity": {"value": 0}
},
"update": {
"opacity": {
"signal": "!showDetails ? 0 : focus['id'] === root['id'] ? tEase : tEaseDelayed"
}
}
}
},
{
"name": "details",
"description": "additional information about the node (appears on zoom)",
"type": "text",
"from": {"data": "details_data"},
"interactive": false,
"encode": {
"enter": {
"text": {"signal": "datum['details']"},
"fontSize": {"signal": "0.045*width"},
"align": {"value": "center"},
"x": {"signal": "width/2"},
"y": {"signal": "height/3"},
"fill": {"value": "gray"},
"opacity": {"value": 0}
},
"update": {
"opacity": {
"signal": "!showDetails ? 0 : focus['id'] === root['id'] ? tEase : tEaseDelayed"
}
}
}
},
{
"name": "helper_text",
"interactive": false,
"description": "interactivity instructions located at the bottom of left",
"type": "text",
"encode": {
"enter": {
"fontSize": {"value": 14},
"text": {
"signal": "['interactivity instructions:', '• click on a node to zoom-in','• for nodes with children, shift + click to see details for that node', '• to slow down animations, ⌘ + click (Mac) / ⊞ + click (Windows)']"
},
"y": {"signal": "height+5"}
},
"update": {
"opacity": {
"signal": "ceil(k) === 1 ? isValid(t) ? tEaseDelayed : 1 : 0"
}
}
}
}
]
}