Deneb Example - Timeline

Deneb/Vega-Lite can be used to create a variety of timeline visuals. All include a temporal X-axis (hence the name) but can have different Y-axis values. Included here are two such examples: a more traditional project phase timeline with no Y-axis, and a stock price timeline with layered events. Additionally, clarity can be increased by rendering long descriptions as multi-line data labels.

Both examples are quite similar, and the stock price timeline visual is described below to illustrate a number of Deneb/Vega-Lite features, including:

0 - General:

  • a “title” block with a 2-line subtitle (array)
  • a “params” block with a notes line length parameter bound to a slider input widget
  • a shared “encoding” block to ensure all layers use the same temporal X-axis
  • a “layer” block with a stock price line and a nested layer for leader lines and notes

1 - Stock Price Line:

  • a “line” mark with 20% opacity, no Y-axis title, and a fixed Y-axis scale

2 - Event Leaders:

  • a “transform” block with:
    • a “filter” transform to reduce the dataset to only those rows having events
    • a “window/rank” transform to rank the events ascending by date
    • a “calculate” transform to set a minimum leader line height
    • 25 “calculate” transforms (5 sets of 5) to split the event notes into lines of less than or equal to the notes line length parameter (a 6th line is used to hold any remaining characters; this method could be extended as much as desired, but only 5 lines were calculated in this example)
      • the “slice”, “length”, “lastindexof”, “trim”, and “replace” functions are used to split the event notes into “lines” of full words using the location of the last space in each line
    • a “calculate” transform to compose a 6-member array with each line (and the remainder)
    • a “calculate” transform to determine the number of non-null lines in each event note
    • a nested “layer” block with:
      • a “bar” mark with narrow width for the “lollipop” stem
      • a “circle” mark of fixed size and 100% opacity for the “lollipop” head
      • a “text” mark with conditional Y-offset (alternating by rank) for the event product (large; black; bold)
      • a “text” mark with conditional Y-offset (alternating by rank) and temporal formatting for the event date (small; italics)
      • a “text” mark with conditional Y-offset (alternating by rank) for the multi-line event notes (small)

NOTE: the overlap of notes is recognized as an issue, but as the purpose of these examples was to investigate multi-line data labels, the resolution is left to future development.

Deneb/Vega-Lite JSON Code - Stock:
{
  "title": {
    "anchor": "start",
    "align": "left",
    "offset": 20,
    "text": "Power BI Timeline using Deneb - Stock",
    "font": "Verdana",
    "fontSize": 24,
    "fontWeight": "bold",
    "fontStyle": "normal",
    "subtitle": [
      "Data Source - Prices: Yahoo Finance (https://ca.finance.yahoo.com/quote/MSFT/history/)",
      "Data Source - Events: synthetic (original source from Wikipedia [https://en.wikipedia.org/wiki/Timeline_of_Microsoft] then modified for visualization purposes)"
    ],
    "subtitleFont": "Verdana",
    "subtitleFontSize": 14,
    "subtitleFontWeight": "normal",
    "subtitleFontStyle": "italic"
  },
  "data": {
    "name": "dataset"
  },
  "params": [
    {
      "name": "_length",
      "value": 40,
      "bind": {
        "input": "range",
        "min": 20,
        "max": 50,
        "step": 1,
        "name": "Length: "
      }
    },
    {
      "name": "_max_length",
      "value": 99999
    }
  ],
  "encoding": {
    "x": {
      "field": "Date",
      "type": "temporal"
    }
  },
  "layer": [
    {
      "name": "STOCK_PRICE_LINE",
      "height": 650,
      "mark": {
        "type": "line",
        "color": "#0F4C81",
        "opacity": 0.2
      },
      "encoding": {
        "y": {
          "field": "Stock Price",
          "type": "quantitative",
          "axis": {
            "title": null
          },
          "scale": {
            "domain": [
              0,
              500
            ]
          }
        }
      }
    },
    {
      "name": "EVENT_LEADERS",
      "transform": [
        {
          "filter": "datum['Event Type'] != null"
        },
        {
          "window": [
            {
              "op": "rank",
              "as": "_rank"
            }
          ],
          "sort": [
            {
              "field": "Date",
              "order": "ascending"
            }
          ]
        },
        {
          "calculate": "datum['Stock Price']<= 100 ? datum['Stock Price'] + 50 : datum['Stock Price'] + 100",
          "as": "_event_leader_line_height"
        },
        {
          "calculate": "datum['Event Notes']",
          "as": "_notes_0"
        },
        // line 1
        {
          "calculate": "datum['_notes_0']",
          "as": "_notes_1"
        },
        {
          "calculate": "slice( datum['_notes_1'], 0, _length )",
          "as": "_temp_line_1"
        },
        {
          "calculate": "if( length( datum['_notes_1'] ) > _length, lastindexof( datum['_temp_line_1'], ' ' ), -1 )",
          "as": "_position_1"
        },
        {
          "calculate": "if( datum['_position_1'] == -1, datum['_temp_line_1'], slice( datum['_notes_1'], 0, datum['_position_1'] ) )",
          "as": "_line_1"
        },
        {
          "calculate": "trim( replace( datum['_notes_1'], datum['_line_1'], '' ) )",
          "as": "_remainder_1"
        },
        // line 2
        {
          "calculate": "datum['_remainder_1']",
          "as": "_notes_2"
        },
        {
          "calculate": "slice( datum['_notes_2'], 0, _length )",
          "as": "_temp_line_2"
        },
        {
          "calculate": "if( length( datum['_notes_2'] ) > _length, lastindexof( datum['_temp_line_2'], ' ' ), -1 )",
          "as": "_position_2"
        },
        {
          "calculate": "if( datum['_position_2'] == -1, datum['_temp_line_2'], slice( datum['_notes_2'], 0, datum['_position_2'] ) )",
          "as": "_line_2"
        },
        {
          "calculate": "trim( replace( datum['_notes_2'], datum['_line_2'], '' ) )",
          "as": "_remainder_2"
        },
        // line 3
        {
          "calculate": "datum['_remainder_2']",
          "as": "_notes_3"
        },
        {
          "calculate": "slice( datum['_notes_3'], 0, _length )",
          "as": "_temp_line_3"
        },
        {
          "calculate": "if( length( datum['_notes_3'] ) > _length, lastindexof( datum['_temp_line_3'], ' ' ), -1 )",
          "as": "_position_3"
        },
        {
          "calculate": "if( datum['_position_3'] == -1, datum['_temp_line_3'], slice( datum['_notes_3'], 0, datum['_position_3'] ) )",
          "as": "_line_3"
        },
        {
          "calculate": "trim( replace( datum['_notes_3'], datum['_line_3'], '' ) )",
          "as": "_remainder_3"
        },
        // line 4
        {
          "calculate": "datum['_remainder_3']",
          "as": "_notes_4"
        },
        {
          "calculate": "slice( datum['_notes_4'], 0, _length )",
          "as": "_temp_line_4"
        },
        {
          "calculate": "if( length( datum['_notes_4'] ) > _length, lastindexof( datum['_temp_line_4'], ' ' ), -1 )",
          "as": "_position_4"
        },
        {
          "calculate": "if( datum['_position_4'] == -1, datum['_temp_line_4'], slice( datum['_notes_4'], 0, datum['_position_4'] ) )",
          "as": "_line_4"
        },
        {
          "calculate": "trim( replace( datum['_notes_4'], datum['_line_4'], '' ) )",
          "as": "_remainder_4"
        },
        // line 5
        {
          "calculate": "datum['_remainder_4']",
          "as": "_notes_5"
        },
        {
          "calculate": "slice( datum['_notes_5'], 0, _length )",
          "as": "_temp_line_5"
        },
        {
          "calculate": "if( length( datum['_notes_5'] ) > _length, lastindexof( datum['_temp_line_5'], ' ' ), -1 )",
          "as": "_position_5"
        },
        {
          "calculate": "if( datum['_position_5'] == -1, datum['_temp_line_5'], slice( datum['_notes_5'], 0, datum['_position_5'] ) )",
          "as": "_line_5"
        },
        {
          "calculate": "trim( replace( datum['_notes_5'], datum['_line_5'], '' ) )",
          "as": "_remainder_5"
        },
        {
          "calculate": "[datum['_line_1'], datum['_line_2'], datum['_line_3'], datum['_line_4'], datum['_line_5'], datum['_remainder_5']]",
          "as": "_notes"
        },
        {
          "calculate": "if( length( datum['_notes_1'] ) > 0, 1, 0 ) + if( length( datum['_notes_2'] ) > 0, 1, 0 ) + if( length( datum['_notes_3'] ) > 0, 1, 0 ) + if( length( datum['_notes_4'] ) > 0, 1, 0 ) + if( length( datum['_notes_5'] ) > 0, 1, 0 ) + if( length( datum['_remainder_5'] ) > 0, 1, 0 )",
          "as": "_lines"
        }
      ],
      "layer": [
        {
          "name": "EVENT_LEADER_LINE",
          "mark": {
            "type": "bar",
            "color": "darkgray",
            "width": 2
          },
          "encoding": {
            "y": {
              "field": "_event_leader_line_height",
              "type": "quantitative"
            }
          }
        },
        {
          "name": "EVENT_LEADER_CIRCLE",
          "mark": {
            "type": "circle",
            "color": "darkgray",
            "opacity": 1,
            "size": 200,
            "tooltip": true
          },
          "encoding": {
            "y": {
              "field": "_event_leader_line_height",
              "type": "quantitative"
            },
            "tooltip": [
              {
                "field": "Event Product",
                "type": "nominal",
                "title": "Product"
              },
              {
                "field": "Date",
                "type": "temporal",
                "format": "%B %e, %Y"
              },
              {
                "field": "Event Notes",
                "type": "nominal",
                "title": "Notes"
              }
            ]
          }
        },
        {
          "name": "EVENT_LEADER_TITLE",
          "mark": {
            "type": "text",
            "align": "left",
            "fontSize": 16,
            "fontWeight": "bold",
            "xOffset": 8,
            "yOffset": {
              "expr": "datum['_rank'] % 2 != 0 ? ( datum['_lines'] * -13 ) - 24 : 10"
            }
          },
          "encoding": {
            "text": {
              "field": "Event Product",
              "type": "nominal"
            },
            "y": {
              "field": "_event_leader_line_height",
              "type": "quantitative"
            }
          }
        },
        {
          "name": "EVENT_LEADER_DATE",
          "mark": {
            "type": "text",
            "align": "left",
            "fontSize": 10,
            "fontStyle": "italic",
            "color": "#969696",
            "xOffset": 8,
            "yOffset": {
              "expr": "datum['_rank'] % 2 != 0 ? ( datum['_lines'] * -12 ) - 12 : 22"
            }
          },
          "encoding": {
            "text": {
              "field": "Date",
              "type": "temporal",
              "format": "%B %e, %Y"
            },
            "y": {
              "field": "_event_leader_line_height",
              "type": "quantitative"
            }
          }
        },
        {
          "name": "EVENT_LEADER_NOTES",
          "mark": {
            "type": "text",
            "align": "left",
            "color": "black",
            "opacity": 1.0,
            "xOffset": 8,
            "yOffset": {
              "expr": "datum['_rank'] % 2 != 0 ? ( datum['_lines'] * -12 ) : 34"
            }
          },
          "encoding": {
            "text": {
              "field": "_notes",
              "type": "nominal"
            },
            "y": {
              "field": "_event_leader_line_height",
              "type": "quantitative"
            }
          }
        }
      ]
    }
  ]
}

The intent of these examples is not to provide finished visuals, but rather to serve as starting points for further custom visual development.

Also included is the sample PBIX using 2 separate datasets:

  • a small synthetic dataset (6 rows) of project phases and made-up long descriptions
  • a large factual dataset of Microsoft Stock Prices from the day after IPO (1986-03-14) to August 2024 (2024-08-19) (Yahoo Finance; 9685 rows) and Microsoft Events (Wikipedia; 69 rows)
    • (the “events” dataset has been modified for visualization purposes with product types added and citation references removed)

These examples are provided as-is for information purposes only, and their use is solely at the discretion of the end user; no responsibility is assumed by the author.

Greg
Deneb Example - Timeline - V4.pbix (2.6 MB)

4 Likes

marking as solved

1 Like