2

I'm using Vega JS for building a tree chart. In general, my question is the following:

Vega documentation has a great example of tree layout. How can I extend it with an ability to collapse & expand its nodes?

To be more specific, let's consider an example of tree chart that I'm building here in Vega Editor.

If you click on the nodes, they will toggle (expand or collapse), allowing you to see particular branches of the tree. This feature works fine unless you try to collapse the top-level node (region) while keeping the second-level nodes (districts) expanded. In that case the tree will look like this:

broken tree screenshot

It happens because of the way I handle this interaction:

  1. When you click on a node, toggledNode signal is triggered, which in turn triggers toggle action in the expandedNodes data array. I.e. by clicking on a node, I add or remove that node to the expandedNodes array (more precisely, we add/remove a reduced object with only name property)
  2. Thus expandedNodes data contains information about which nodes are explicitly expanded. But it doesn't know if those expanded nodes are inside of a collapsed parent node.
  3. Then, to find out which nodes are actually visible, I use visibleNodes data. There I apply filter transform with the following expression: !datum.parent || indata('expandedNodes', 'name', datum.parent). I.e. I check only one level up: if the node's parent is present in the expandedNodes array , I consider the node as visible.

The problem is the following: I can't find any way of extending this functionality across multiple levels.

Probably I could write some hooks to check the same condition across 2 or 3 levels, e.g:

!datum.parent ||
  indata('expandedNodes', 'name', datum.parent) && 
  indata('expandedNodes', 'name', datum.myCustomFieldWithParentNode.parent) && 
  indata('expandedNodes', 'name', datum.myCustomFieldWithParentNode.myCustomFieldWithParentNode.parent)

But it seems too complex for such a simple problem, and also it's not a final solution. In theory, a tree may contain dozens of nesting levels: what to do then?

I found one useful expression in Vega: treeAncestors. I could easily write a solution in JavaScript, where I have loops and array methods such as .some() and .every(). But apparently Vega doesn't support any expressions to iterate over an array. So even though I can get an array of tree node ancestors with treeAncestors function, I can't do anything with it to verify that all ancestors are expanded.

Either my approach is wrong, and somebody can find a better algorithm for doing the same, which doesn't require iterating over arrays (except for data and indata expressions) - or it's a current limitation of Vega.

Alexey Grinko
  • 2,773
  • 22
  • 21

3 Answers3

2

You can use treeAncestors and then use a flatten transform to get a dataset that you can query. In your case it would look something like:

{
  "transform": [
    {
      "as": "treeAncestors",
      "type": "formula",
      "expr": "treeAncestors('tree', datum.id, 'root')"
    }
  ],
  "name": "tree-ancestors",
  "source": "tree"
},
{
  "transform": [{"fields": ["treeAncestors"], "type": "flatten"}],
  "name": "tree-ancestors-flatt",
  "source": "tree-ancestors"
},
{
  "transform": [
    {
      "type": "filter",
      "expr": "indata('selected', 'value', datum.treeAncestors.id)"
    }
  ],
  "name": "filtered",
  "source": "tree-ancestors-flatt"
},
{
  "transform": [{"type": "aggregate", "groupby": ["id"]}],
  "name": "filtered-aggregate",
  "source": "filtered"
},
{
  "transform": [
    {
      "type": "filter",
      "expr": "indata('filtered-aggregate', 'id', datum.id) "
    }
  ],
  "name": "filtered-tree",
  "source": "tree"
}
LDomagala
  • 2,193
  • 4
  • 20
  • 34
  • It is at first, but that's where the flatten transform comes into play. It takes the initial row and gives you a new row for each field in the array, so that you can just access it easily. – LDomagala Nov 28 '21 at 10:42
  • sorry, I've deleted my comment right before you answered it. I got it now, `treeAncestors` is turned into an object after the `flatten` transform. – Alexey Grinko Nov 28 '21 at 10:50
  • So I tried your implementation on my chart, but it doesn't work as expected. Of course, I replaced `id` fields with `name`, and made some other renamings to adapt it for my sample. Here is the link: https://vega.github.io/editor/#/gist/329742ab6fbc3104cb3bfb911fbc4fcb/spec.json. It does hide the lowest nodes when I collapse the root node, but as you can see, when root node is expanded, the lowest nodes are always visible. I.e. it doesn't respect the middle nodes' state. I don't fully understand what's going on with all these `filter` and `aggregate` transforms, could you suggest please? – Alexey Grinko Nov 28 '21 at 10:54
1

Vega doesn't seem to have a recursive way of solving the problem for your question" hey, if all my parents are expanded, then I am visible as a node ".

You can check indeed conditions for all levels you wish to define.

{
      "type": "filter",
      "expr": "!datum.parent || indata('expandedNodes','name',datum.parent)&&datum.depth==1||(indata('expandedNodes','name',datum.firstParent)&&indata('expandedNodes','name',datum.secondParent)&&datum.depth==2)||(indata('expandedNodes','name',datum.firstParent)&&indata('expandedNodes','name',datum.secondParent)&&indata('expandedNodes','name',datum.thirdParent)&&datum.depth==3)"
}

The code above says to VEGA : " hey check if all of my defined parents are expanded and filter me if any of my parents exist but are not expanded

To see the full solution with your case, please check : spec

0

Here is an example I created for expanding and collapsing tree nodes (also supports pan and zoom).

Editor

enter image description here

{
    "$schema": "https://vega.github.io/schema/vega/v5.json",
    "description": "Zoomable, collapsable tree by David Bacci: https://www.linkedin.com/in/davbacci/",
    "width": {"signal": "1400"},
    "height": {"signal": "1000"},
    "background": "#f5f5f5",
    "autosize": "pad",
    "padding": 5,
    "signals": [
      {"name": "nodeWidth", "value": 190},
      {"name": "nodeHeight", "value": 45},
      {
        "name": "startingDepth",
        "value": 1,
        "on": [
          {
            "events": {
              "type": "timer",
              "throttle": 0
            },
            "update": "-1"
          }
        ]
      },
      {
        "name": "node",
        "value": 0,
        "on": [
          {
            "events": {
              "type": "click",
              "markname": "node"
            },
            "update": "datum.id"
          },
          {
            "events": {
              "type": "timer",
              "throttle": 10
            },
            "update": "0"
          }
        ]
      },
      {
        "name": "nodeHighlight",
        "value": "[0]",
        "on": [
          {
            "events": {
              "type": "mouseover",
              "markname": "node"
            },
            "update": "pluck(treeAncestors('treeCalcs', datum.id), 'id')"
          },
          {
            "events": {
              "type": "mouseout"
            },
            "update": "[0]"
          }
        ]
      },
      {
        "name": "isExpanded",
        "value": 0,
        "on": [
          {
            "events": {
              "type": "click",
              "markname": "node"
            },
            "update": "datum.children > 0 && indata('treeClickStorePerm', 'id', datum.childrenIds[0])?true:false"
          }
        ]
      },
      {
        "name": "xrange",
        "update": "[0, width]"
      },
      {
        "name": "yrange",
        "update": "[0, height]"
      },
      {
        "name": "down",
        "value": null,
        "on": [
          {
            "events": "mousedown",
            "update": "xy()"
          }
        ]
      },
      {
        "name": "xcur",
        "value": null,
        "on": [
          {
            "events": "mousedown",
            "update": "slice(xdom)"
          }
        ]
      },
      {
        "name": "ycur",
        "value": null,
        "on": [
          {
            "events": "mousedown",
            "update": "slice(ydom)"
          }
        ]
      },
      {
        "name": "delta",
        "value": [0, 0],
        "on": [
          {
            "events": [
              {
                "source": "window",
                "type": "mousemove",
                "consume": true,
                "between": [
                  {"type": "mousedown"},
                  {
                    "source": "window",
                    "type": "mouseup"
                  }
                ]
              }
            ],
            "update": "down ? [down[0]-x(), down[1]-y()] : [0,0]"
          }
        ]
      },
      {
        "name": "anchor",
        "value": [0, 0],
        "on": [
          {
            "events": "wheel",
            "update": "[invert('xscale', x()), invert('yscale', y())]"
          }
        ]
      },
      {
        "name": "xext",
        "update": "[0,width]"
      },
      {
        "name": "yext",
        "update": "[0,height]"
      },
      {
        "name": "zoom",
        "value": 1,
        "on": [
          {
            "events": "wheel!",
            "force": true,
            "update": "pow(1.001, event.deltaY * pow(16, event.deltaMode))"
          }
        ]
      },
      {
        "name": "xdom",
        "update": "slice(xext)",
        "on": [
          {
            "events": {"signal": "delta"},
            "update": "[xcur[0] + span(xcur) * delta[0] / width, xcur[1] + span(xcur) * delta[0] / width]"
          },
          {
            "events": {"signal": "zoom"},
            "update": "[anchor[0] + (xdom[0] - anchor[0]) * zoom, anchor[0] + (xdom[1] - anchor[0]) * zoom]"
          },
          {
            "events": "dblclick",
            "update": "[0,width]"
          }
        ]
      },
      {
        "name": "ydom",
        "update": "slice(yext)",
        "on": [
          {
            "events": {"signal": "delta"},
            "update": "[ycur[0] + span(ycur) * delta[1] / height, ycur[1] + span(ycur) * delta[1] / height]"
          },
          {
            "events": {"signal": "zoom"},
            "update": "[anchor[1] + (ydom[0] - anchor[1]) * zoom, anchor[1] + (ydom[1] - anchor[1]) * zoom]"
          },
          {
            "events": "dblclick",
            "update": "[0,height]"
          }
        ]
      },
      {
        "name": "scaledNodeWidth",
        "update": "(nodeWidth/ span(xdom))*width"
      },
      {
        "name": "scaledNodeHeight",
        "update": "abs(nodeHeight/ span(ydom))*height"
      },
      {
        "name": "scaledFont13",
        "update": "(13/ span(xdom))*width"
      },
      {
        "name": "scaledFont12",
        "update": "(12/ span(xdom))*width"
      },
      {
        "name": "scaledFont11",
        "update": "(11/ span(xdom))*width"
      },
      {
        "name": "scaledKPIHeight",
        "update": "(5/ span(xdom))*width"
      },
      {
        "name": "scaledLimit",
        "update": "(20/ span(xdom))*width"
      }
    ],
    "data": [
      {
        "name": "source",
        "url": "https://raw.githubusercontent.com/PBI-David/Deneb-Showcase/main/Organisation%20Tree%20Chart/data.json"
      },
      {
        "name": "wideToTall",
        "source": "source",
        "transform": [
          {
            "type": "formula",
            "expr": "{key: datum.level1,parent: null, person:datum.person, kpi:datum.kpi}",
            "as": "l1"
          },
          {
            "type": "formula",
            "expr": "{key: datum.level1+ '|'+datum.level2,parent: datum.level1, person:datum.person, kpi:datum.kpi}",
            "as": "l2"
          },
          {
            "type": "formula",
            "expr": "{key:datum.level1 + '|'+datum.level2+ '|'+datum.level3,parent: datum.level1+ '|'+datum.level2, person:datum.person, kpi:datum.kpi}",
            "as": "l3"
          },
          {
            "type": "formula",
            "expr": "{key:datum.level1 + '|'+datum.level2+ '|'+datum.level3+ '|'+ datum.level4,parent: datum.level1 + '|'+datum.level2+ '|'+datum.level3, person:datum.person, kpi:datum.kpi}",
            "as": "l4"
          },
          {
            "type": "formula",
            "expr": "{key:datum.level1 + '|'+datum.level2+ '|'+datum.level3+ '|'+ datum.level4+ '|'+ datum.level5,parent: datum.level1 + '|'+datum.level2+ '|'+datum.level3+ '|'+ datum.level4, person:datum.person, kpi:datum.kpi}",
            "as": "l5"
          },
          {
            "type": "fold",
            "fields": [
              "l1",
              "l2",
              "l3",
              "l4",
              "l5"
            ]
          },
          {
            "type": "project",
            "fields": ["key", "value"]
          },
          {
            "type": "formula",
            "expr": "datum.value.key",
            "as": "id"
          },
          {
            "type": "formula",
            "expr": "reverse(split(datum.value.key,'|'))[0]",
            "as": "title"
          },
          {
            "type": "formula",
            "expr": "datum.value.parent",
            "as": "parent"
          },
          {
            "type": "filter",
            "expr": "datum.title != 'null' && datum.title != 'undefined'"
          },
          {
            "type": "aggregate",
            "groupby": [
              "id",
              "parent",
              "title",
              "value"
            ]
          },
          {
            "type": "formula",
            "expr": "datum.value.person",
            "as": "person"
          },
          {
            "type": "formula",
            "expr": "datum.value.kpi",
            "as": "kpi"
          }
        ]
      },
      {
        "name": "treeCalcs",
        "source": "wideToTall",
        "transform": [
          {
            "type": "stratify",
            "key": "id",
            "parentKey": "parent"
          },
          {
            "type": "tree",
            "method": {
              "signal": "'tidy'"
            },
            "separation": {
              "signal": "false"
            },
            "as": [
              "y",
              "x",
              "depth",
              "children"
            ]
          },
          {
            "as": "parent",
            "type": "formula",
            "expr": "datum.parent"
          }
        ]
      },
      {
        "name": "treeChildren",
        "source": "treeCalcs",
        "transform": [
          {
            "type": "aggregate",
            "groupby": ["parent"],
            "fields": ["parent"],
            "ops": ["values"],
            "as": ["childrenObjects"]
          },
          {
            "type": "formula",
            "expr": "pluck(datum.childrenObjects,'id')",
            "as": "childrenIds"
          }
        ]
      },
      {
        "name": "treeAncestors",
        "source": "treeCalcs",
        "transform": [
          {
            "type": "formula",
            "as": "treeAncestors",
            "expr": "treeAncestors('treeCalcs', datum.id, 'root')"
          },
          {
            "type": "flatten",
            "fields": ["treeAncestors"]
          },
          {
            "type": "formula",
            "expr": "datum.treeAncestors.parent",
            "as": "allParents"
          }
        ]
      },
      {
        "name": "treeChildrenAll",
        "source": "treeAncestors",
        "transform": [
          {
            "type": "project",
            "fields": [
              "allParents",
              "id",
              "name",
              "parent",
              "x",
              "y",
              "depth",
              "children"
            ]
          },
          {
            "type": "aggregate",
            "fields": [
              "parent",
              "parent",
              "id"
            ],
            "ops": [
              "values",
              "count",
              "min"
            ],
            "groupby": ["allParents"],
            "as": [
              "allChildrenObjects",
              "allChildrenCount",
              "id"
            ]
          },
          {
            "type": "formula",
            "expr": "pluck(datum.allChildrenObjects,'id')",
            "as": "allChildrenIds"
          }
        ]
      },
      {
        "name": "treeClickStoreTemp",
        "source": "treeAncestors",
        "transform": [
          {
            "type": "filter",
            "expr": "startingDepth!=-1?datum.depth <= startingDepth:node !=0 && !isExpanded? datum.parent == node: node !=0 && isExpanded? datum.allParents == node:false"
          },
          {
            "type": "project",
            "fields": [
              "id",
              "name",
              "parent",
              "x",
              "y",
              "depth",
              "children"
            ]
          },
          {
            "type": "aggregate",
            "fields": ["id"],
            "ops": ["min"],
            "groupby": [
              "id",
              "name",
              "parent",
              "x",
              "y",
              "depth",
              "children"
            ]
          }
        ]
      },
      {
        "name": "treeClickStorePerm",
        "values": [],
        "on": [
          {
            "trigger": "startingDepth>=0",
            "insert": "data('treeClickStoreTemp')"
          },
          {
            "trigger": "node",
            "insert": "!isExpanded? data('treeClickStoreTemp'):false"
          },
          {
            "trigger": "node",
            "remove": "isExpanded?data('treeClickStoreTemp'):false"
          }
        ]
      },
      {
        "name": "treeLayout",
        "source": "wideToTall",
        "transform": [
          {
            "type": "filter",
            "expr": "indata('treeClickStorePerm', 'id', datum.id)"
          },
          {
            "type": "stratify",
            "key": "id",
            "parentKey": "parent"
          },
          {
            "type": "tree",
            "method": {
              "signal": "'tidy'"
            },
            "nodeSize": [
              {"signal": "nodeHeight+10"},
              {"signal": "nodeWidth+140"}
            ],
            "separation": {
              "signal": "false"
            },
            "as": [
              "y",
              "x",
              "depth",
              "children"
            ]
          },
          {
            "type": "formula",
            "expr": "datum.y+(height/2)",
            "as": "y"
          },
          {
            "type": "formula",
            "expr": "scale('xscale',datum.x)",
            "as": "xscaled"
          },
          {
            "as": "parent",
            "type": "formula",
            "expr": "datum.parent"
          }
        ]
      },
      {
        "name": "fullTreeLayout",
        "source": "treeLayout",
        "transform": [
          {
            "type": "lookup",
            "from": "treeChildren",
            "key": "parent",
            "fields": ["id"],
            "values": [
              "childrenObjects",
              "childrenIds"
            ]
          },
          {
            "type": "lookup",
            "from": "treeChildrenAll",
            "key": "allParents",
            "fields": ["id"],
            "values": [
              "allChildrenIds",
              "allChildrenObjects"
            ]
          },
          {
            "type": "lookup",
            "from": "treeCalcs",
            "key": "id",
            "fields": ["id"],
            "values": ["children"]
          },
          {
            "type": "formula",
            "expr": "reverse(pluck(treeAncestors('treeCalcs', datum.id), 'id'))[1]",
            "as": "treeParent"
          }
        ]
      },
      {
        "name": "visibleNodes",
        "source": "fullTreeLayout",
        "transform": [
          {
            "type": "filter",
            "expr": "indata('treeClickStorePerm', 'id', datum.id)"
          }
        ]
      },
      {
        "name": "maxWidthAndHeight",
        "source": "visibleNodes",
        "transform": [
          {
            "type": "aggregate",
            "groupby": ["depth"],
            "fields": ["depth", "x", "y"],
            "ops": [
              "count",
              "max",
              "max"
            ],
            "as": ["count", "x", "y"]
          },
          {
            "type": "aggregate",
            "fields": [
              "depth",
              "count",
              "x",
              "y"
            ],
            "ops": [
              "max",
              "max",
              "max",
              "max"
            ],
            "as": [
              "maxDepth",
              "maxNodes",
              "maxX",
              "maxY"
            ]
          }
        ]
      },
      {
        "name": "links",
        "source": "treeLayout",
        "transform": [
          {"type": "treelinks"},
          {
            "type": "linkpath",
            "orient": "horizontal",
            "shape": "diagonal",
            "sourceY": {
              "expr": "scale('yscale', datum.source.y)"
            },
            "sourceX": {
              "expr": "scale('xscale', datum.source.x+nodeWidth)"
            },
            "targetY": {
              "expr": "scale('yscale', datum.target.y)"
            },
            "targetX": {
              "expr": "scale('xscale', datum.target.x)"
            }
          },
          {
            "type": "filter",
            "expr": " indata('treeClickStorePerm', 'id', datum.target.id)"
          }
        ]
      }
    ],
    "scales": [
      {
        "name": "xscale",
        "zero": false,
        "domain": {"signal": "xdom"},
        "range": {"signal": "xrange"}
      },
      {
        "name": "yscale",
        "zero": false,
        "domain": {"signal": "ydom"},
        "range": {"signal": "yrange"}
      },
      {
        "name": "kpiscale",
        "zero": false,
        "domain": [0, 100],
        "range": {
          "signal": "[0,scaledNodeWidth]"
        }
      },
      {
        "name": "colour",
        "type": "ordinal",
        "range": [
          "#6f6f6f",
          "#4472C4",
          "#3A8E50",
          "#ED7D31",
          "#a63939",
          "#6338a6",
          "#3843a6",
          "#38a695"
        ],
        "domain": {
          "data": "visibleNodes",
          "field": "treeParent"
        }
      }
    ],
    "marks": [
      {
        "type": "path",
        "interactive": false,
        "from": {"data": "links"},
        "encode": {
          "update": {
            "path": {"field": "path"},
            "strokeWidth": {
              "signal": "indexof(nodeHighlight, datum.target.id)> -1? 2.5:0.4"
            },
            "stroke": {
              "scale": "colour",
              "signal": "reverse(pluck(treeAncestors('treeCalcs', datum.target.id), 'id'))[1]"
            }
          }
        }
      },
      {
        "name": "node",
        "description": "The parent node",
        "type": "group",
        "clip": false,
        "from": {"data": "visibleNodes"},
        "encode": {
          "update": {
            "x": {
              "field": "x",
              "scale": "xscale"
            },
            "width": {
              "signal": "scaledNodeWidth"
            },
            "yc": {
              "field": "y",
              "scale": "yscale"
            },
            "height": {
              "signal": "scaledNodeHeight"
            },
            "fill": {
              "signal": "merge(hsl(scale('colour', datum.treeParent)), {l:0.94})"
            },
            "stroke": {
              "signal": "merge(hsl(scale('colour', datum.treeParent)), {l:0.79})"
            },
            "cornerRadius": {"value": 2},
            "cursor": {
              "signal": "datum.children>0?'pointer':''"
            },
            "tooltip": {"signal": ""}
          }
        },
        "marks": [
          {
            "name": "highlight",
            "description": "highlight (seems like a Vega bug as this doens't work on the group element)",
            "type": "rect",
            "interactive": false,
            "encode": {
              "update": {
                "x": {
                  "signal": "item.mark.group.x1"
                },
                "y": {"signal": "0"},
                "fill": {
                  "signal": "indexof(nodeHighlight, parent.id)> -1? merge(hsl(scale('colour', parent.treeParent)), {l:0.82}):0"
                },
                "height": {
                  "signal": "item.mark.group.height"
                },
                "width": {
                  "signal": "item.mark.group.width"
                }
              }
            }
          },
          {
            "name": "KPI background",
            "description": "KPI background",
            "type": "rect",
            "interactive": false,
            "clip": true,
            "encode": {
              "update": {
                "x": {
                  "signal": "item.mark.group.x1"
                },
                "y": {
                  "signal": "item.mark.group.height-scaledKPIHeight"
                },
                "height": {
                  "signal": "scaledKPIHeight"
                },
                "width": {
                  "signal": "(item.mark.group.width)"
                },
                "fill": {
                  "scale": "colour",
                  "signal": "parent.treeParent"
                },
                "opacity": {"value": 0.2}
              }
            }
          },
          {
            "name": "KPI",
            "description": "KPI",
            "type": "rect",
            "interactive": false,
            "clip": true,
            "encode": {
              "update": {
                "x": {
                  "signal": "item.mark.group.x1"
                },
                "y": {
                  "signal": "item.mark.group.height-scaledKPIHeight"
                },
                "height": {
                  "signal": "scaledKPIHeight"
                },
                "width": {
                  "signal": "scale('kpiscale',parent.kpi)"
                },
                "fill": {
                  "scale": "colour",
                  "signal": "parent.treeParent"
                }
              }
            }
          },
          {
            "type": "text",
            "interactive": false,
            "name": "name",
            "encode": {
              "update": {
                "x": {
                  "signal": "(10/ span(xdom))*width"
                },
                "y": {
                  "signal": "(6/ span(xdom))*width"
                },
                "fontWeight": {
                  "value": "600"
                },
                "baseline": {
                  "value": "top"
                },
                "fill": {
                  "scale": "colour",
                  "signal": "parent.treeParent"
                },
                "text": {
                  "signal": "parent.person"
                },
                "fontSize": {
                  "signal": "scaledFont13"
                },
                "limit": {
                  "signal": "scaledNodeWidth-scaledLimit"
                },
                "font": {
                  "value": "Calibri"
                }
              }
            }
          },
          {
            "type": "text",
            "interactive": false,
            "name": "title",
            "encode": {
              "update": {
                "x": {
                  "signal": "(10/ span(xdom))*width"
                },
                "y": {
                  "signal": "(22/ span(xdom))*width"
                },
                "align": {
                  "value": "left"
                },
                "baseline": {
                  "value": "top"
                },
                "fill": {
                  "signal": "'#4D4B44'"
                },
                "text": {
                  "signal": "parent.title"
                },
                "fontSize": {
                  "signal": "scaledFont11"
                },
                "limit": {
                  "signal": "scaledNodeWidth-scaledLimit"
                },
                "font": {
                  "value": "Calibri"
                }
              }
            }
          },
          {
            "type": "text",
            "interactive": false,
            "name": "node children",
            "encode": {
              "update": {
                "x": {
                  "signal": "item.mark.group.width - (9/ span(xdom))*width"
                },
                "y": {
                  "signal": "item.mark.group.height/2"
                },
                "align": {
                  "value": "right"
                },
                "baseline": {
                  "value": "middle"
                },
                "fill": {
                  "scale": "colour",
                  "signal": "parent.treeParent"
                },
                "text": {
                  "signal": "parent.children>0?parent.children:''"
                },
                "fontSize": {
                  "signal": "scaledFont12"
                },
                "font": {
                  "value": "Calibri"
                }
              }
            }
          }
        ]
      }
    ]
  }
Davide Bacci
  • 16,647
  • 3
  • 10
  • 36