Извилистая временная шкала

Извилистая временная шкала подходит для задач, где неудобно использовать очень вытянутый холст. Пример показывает, как несколько преобразований позволяют задать сложное положение и поворот элементов. Форму можно менять с помощью сигналов; привязки элементов управления добавлены для демонстрации. Существует и более сложный вариант с анимацией изменения масштаба.

Автор визуализации — @giammaria.

Спецификация Vega в JSON <>

{
    "$schema": "https://vega.github.io/schema/vega/v6.json",
    "description": "Извилистая временная шкала. Такая форма подходит для случаев, когда вытянутый холст неудобен. Форму можно настраивать с помощью многих сигналов ниже. Привязки элементов управления добавлены для демонстрации.",
    "padding": 15,
    "width": 300,
    "signals": [
      {
        "name": "width",
        "init": "width",
        "bind": {
          "name": "Длина прямого участка",
          "input": "range",
          "min": 0,
          "max": 2000,
          "step": 1
        },
        "on": [
          {"events": [{"signal": "annotations"}], "update": "width+1"},
          {"events": [{"signal": "annotations"}], "update": "width-1"},
          {"events": [{"signal": "mO"}], "update": "width+1"},
          {"events": [{"signal": "mO"}], "update": "width-1"},
          {"events": [{"signal": "tLO"}], "update": "width+1"},
          {"events": [{"signal": "tLO"}], "update": "width-1"},
          {"events": [{"signal": "sN"}], "update": "width+1"},
          {"events": [{"signal": "sN"}], "update": "width-1"},
          {"events": [{"signal": "mO"}], "update": "height+1"},
          {"events": [{"signal": "mO"}], "update": "height-1"},
          {"events": [{"signal": "tLO"}], "update": "height+1"},
          {"events": [{"signal": "tLO"}], "update": "height-1"}
        ]
      },
      {
        "name": "sH",
        "description": "Диаметр дуг извилистой линии.",
        "value": 125,
        "bind": {
          "name": "Диаметр дуги",
          "input": "range",
          "min": 25,
          "max": 400,
          "step": 1
        }
      },
      {
        "name": "labelsOnHover",
        "description": "Показывать подписи событий только при наведении.",
        "value": false,
        "bind": {"name": "Подписи при наведении", "input": "checkbox"}
      },
      {
        "name": "sN",
        "description": "Число дуг извилистой линии.",
        "value": 2.20,
        "bind": {
          "name": "Число дуг",
          "input": "range",
          "min": 0,
          "max": 20,
          "step": 0.01
        }
      },
      {
        "name": "tC",
        "description": "Число отметок оси на временной шкале.",
        "value": 21,
        "bind": {
          "name": "Число отметок",
          "input": "range",
          "min": 0,
          "max": 100,
          "step": 1
        }
      },
      {
        "name": "tLO",
        "description": "Смещение подписей отметок.",
        "value": 8
      },
      {
        "name": "mO",
        "description": "Смещение отметок событий.",
        "value": 35,
        "bind": {
          "name": "Отступ событий",
          "input": "range",
          "min": 0,
          "max": 50,
          "step": 0.5
        }
      },
      {
        "name": "sR0P",
        "description": "Положение начала временной шкалы в процентах ширины холста.",
        "value": 0,
        "bind": {
          "name": "Начало шкалы, %",
          "input": "range",
          "min": 0,
          "max": 1,
          "step": 0.01
        }
      },
      {
        "name": "sLP",
        "description": "Положение конца временной шкалы в процентах общей длины холста.",
        "value": 1,
        "bind": {
          "name": "Длина шкалы, %",
          "input": "range",
          "min": 0,
          "max": 1,
          "step": 0.01
        }
      },
      {
        "name": "annotationStart",
        "value": "",
        "bind": {"name": "Подпись в начале", "input": "text"}
      },
      {
        "name": "annotationEnd",
        "value": "Сегодня",
        "bind": {"name": "Подпись в конце", "input": "text"}
      },
      {
        "name": "includeArrows",
        "value": true,
        "bind": {"name": "Показывать стрелки", "input": "checkbox"}
      },
      {
        "name": "sT",
        "description": "Толщина извилистой линии.",
        "value": 5,
        "bind": {
          "name": "Толщина линии",
          "input": "range",
          "min": 1,
          "max": 10,
          "step": 0.5
        }
      },
      {
        "name": "domain",
        "init": "[year(now())-100, year(now())]",
        "description": "Границы области определения временной шкалы; при null рассчитываются автоматически."
      },
      {
        "name": "sRange",
        "description": "Диапазон извилистой шкалы.",
        "update": "[sR0P*width,sL*sLP]"
      },
      {
        "name": "annotations",
        "description": "Пояснения в начале и конце временной шкалы.",
        "update": "{start: (isValid(annotationStart) ? annotationStart : ''), end: (isValid(annotationEnd) ? annotationEnd : '')}"
      },
      {
        "name": "sD",
        "update": "[2, 2]",
        "description": "Штриховой узор извилистой линии."
      },
      {
        "name": "reverse",
        "description": "Признак обратного направления временной шкалы.",
        "value": false
      },
      {
        "name": "sPct",
        "description": "Ширина прямых участков временной шкалы в процентах.",
        "value": 1,
        "update": "sPct < 0.25 ? 0 : sPct < 0.75 ? 0.5 : 1"
      },
      {
        "name": "sW",
        "description": "Горизонтальная длина прямых участков.",
        "update": "sPct*width"
      },
      {
        "name": "sL",
        "description": "Общая длина линии.",
        "update": "(sN+1)*sW+(sN)*sH*PI/2"
      },
      {
        "name": "sA",
        "description": "Длина участка дуги.",
        "update": "(sH*PI/2)"
      },
      {
        "name": "sWsA",
        "description": "Суммарная длина прямого участка и дуги.",
        "update": "(sW + sH*PI/2)"
      },
      {
        "name": "sDomain",
        "description": "Область определения извилистой шкалы.",
        "init": "domain ? domain : [+extent(pluck(data('dataset'), 'domain'))[0], +extent(pluck(data('dataset'), 'domain'))[1]]"
      },
      {
        "name": "hoverFocus",
        "value": 0,
        "on": [
          {
            "events": {"type": "mouseover"},
            "update": "isValid(datum) && isValid(isValid(datum['domain'])) ? datum : null"
          },
          {"events": {"type": "mouseout"}, "update": "null"}
        ]
      },
      {
        "name": "height",
        "description": "Рассчитанная высота.",
        "update": "extent(pluck(data('serpentine'), 'y'))[1]"
      }
    ],
    "scales": [
      {
        "name": "sS1",
        "type": "linear",
        "zero": false,
        "reverse": {"signal": "reverse"},
        "domain": {"signal": "sDomain"},
        "range": {"signal": "sRange"}
      },
      {
        "name": "footerY",
        "type": "band",
        "domain": {"data": "footer", "field": "id"},
        "range": [
          {"signal": "height+60"},
          {"signal": "height+60+length(data('footer'))*11"}
        ]
      }
    ],
    "marks": [
      {
        "name": "axis_group",
        "description": "Группа всех элементов оси: пояснений, линии области определения, стрелок, отметок и их подписей на прямых участках и дугах.",
        "type": "group",
        "marks": [
          {
            "name": "annotations",
            "description": "Текст в начале и конце временной шкалы, настраиваемый сигналом 'annotations'.",
            "from": {"data": "domain_extent"},
            "on": [{"trigger": "annotations", "modify": "annotations"}],
            "type": "text",
            "interactive": false,
            "encode": {
              "update": {
                "x": {"field": "x"},
                "y": {"field": "y"},
                "text": {
                  "signal": "datum['category'] === 'start' ? annotations['start'] : annotations['end']"
                },
                "fontSize": {"value": 10},
                "baseline": {"value": "middle"},
                "align": {"field": "align"},
                "angle": {"field": "angle"},
                "dx": {"field": "dx"},
                "dy": {"signal": "1"},
                "fill": {"value": "gray"}
              }
            }
          },
          {
            "name": "serpentine_line",
            "description": "Извилистая линия, служащая линией оси.",
            "type": "line",
            "from": {"data": "serpentine"},
            "interactive": false,
            "encode": {
              "update": {
                "x": {"field": "x"},
                "y": {"field": "y"},
                "strokeDash": {"signal": "sD"},
                "stroke": {"value": "#888"},
                "strokeWidth": {"signal": "sT"}
              }
            }
          },
          {
            "name": "arrow_marks",
            "description": "Стрелки направления в начале и конце каждого сочетания дуги и прямого участка.",
            "type": "text",
            "from": {"data": "segment_ends"},
            "interactive": false,
            "encode": {
              "update": {
                "x": {"field": "x"},
                "y": {"field": "y"},
                "dy": {"value": 1},
                "text": {"value": "➤"},
                "fontSize": {"signal": "18"},
                "fill": {"value": "#000"},
                "stroke": {"value": "#fff"},
                "strokeWidth": {"value": 1},
                "angle": {"signal": "datum['direction'] === '→' ? 0 : 180"},
                "align": {"value": "center"},
                "baseline": {"value": "middle"}
              }
            }
          },
          {
            "name": "tick_marks",
            "description": "Линия каждой отметки, нарисованная текстовым примитивом.",
            "from": {"data": "ticks"},
            "type": "text",
            "interactive": false,
            "encode": {
              "update": {
                "x": {"field": "x"},
                "y": {"field": "y"},
                "dy": {
                  "signal": "2.5*( datum['type'] === 'straight' ? 1 : datum['side'] === 'right' ? (round(datum['alpha']*(180/PI)) >= 90 ? -1 : 4) : (round(datum['alpha']*(180/PI)) > 89 ? -1 : 4))"
                },
                "text": {"signal": "'|'"},
                "fontSize": {"signal": "7"},
                "fill": {"value": "#000"},
                "angle": {"field": "labelAngle"},
                "align": {"value": "center"},
                "baseline": {
                  "signal": "datum['type'] === 'straight' ? 'top' : 'bottom'"
                }
              }
            }
          },
          {
            "name": "tick_labels_straight",
            "description": "Подписи отметок на прямых участках.",
            "from": {"data": "ticks"},
            "type": "text",
            "interactive": false,
            "encode": {
              "update": {
                "x": {"field": "x"},
                "y": {"field": "y"},
                "dy": {"field": "dy"},
                "text": {"field": "domain"},
                "fontSize": {"signal": "10"},
                "fill": {"value": "#000"},
                "align": {"value": "center"},
                "angle": {"field": "labelAngle"},
                "baseline": {"signal": "datum['type'] === 'straight' ? 'top' : 'bottom'"}
              }
            }
          }
        ]
      },
      {
        "name": "milestone_connecting_lines",
        "description": "Линии, соединяющие отметки событий с подписями.",
        "from": {"data": "milestones"},
        "type": "text",
        "interactive": false,
        "encode": {
          "update": {
            "text": {"signal": "'|'"},
            "x": {"field": "x"},
            "y": {"field": "y"},
            "fontSize": {"value": 15},
            "fontWeight": {"value": 100},
            "dy": {
              "signal": "datum['type'] === 'arc' && (round(datum['alpha']*(180/PI)) > 89) ? 0.35 * (mO+3.5) : datum['dy']/2"
            },
            "align": {"value": "center"},
            "baseline": {"value": "middle"},
            "angle": {"field": "labelAngle"},
            "fillOpacity": {"value": 0.35},
            "opacity": {
              "signal": "isValid(hoverFocus) && datum['label'] === hoverFocus['label'] ? 1 : labelsOnHover ? 0 : 1"
            }
          }
        }
      },
      {
        "name": "milestone_markers",
        "description": "Отметки событий на временной шкале.",
        "from": {"data": "milestones"},
        "type": "symbol",
        "interactive": true,
        "encode": {
          "update": {
            "x": {"field": "x"},
            "y": {"field": "y"},
            "size": {"signal": "labelsOnHover && isValid(hoverFocus) && datum['label'] === hoverFocus['label'] ? 200 : 150"},
            "fill": {"signal": "labelsOnHover ? isValid(hoverFocus) && datum['label'] === hoverFocus['label'] ? 'firebrick' : '#e4b3b4' : 'firebrick'"},
            "stroke": {"signal": "labelsOnHover ? isValid(hoverFocus) && datum['label'] === hoverFocus['label'] ? '#fff' : 'firebrick' : '#fff'"},
            "strokeWidth": {"signal": "labelsOnHover ? 1 : 2"},
            "cursor": {"signal": "labelsOnHover ? 'pointer' : 'default'"}
          }
        }
      },
      {
        "name": "milestone_label_backgrounds",
        "description": "Белый фон подписей событий.",
        "from": {"data": "milestones"},
        "type": "text",
        "interactive": false,
        "encode": {
          "update": {
            "x": {"field": "x"},
            "y": {"field": "y"},
            "dy": {"field": "dy"},
            "text": {"signal": "datum['domain'] + ' - ' + datum['label']"},
            "fontSize": {"signal": "10"},
            "fill": {"value": "#fff"},
            "stroke": {"value": "#fff"},
            "strokeWidth": {"value": 7},
            "angle": {"field": "labelAngle"},
            "align": {"value": "center"},
            "baseline": {
              "signal": "datum['type'] === 'straight' ? 'top' : 'bottom'"
            },
            "opacity": {
              "signal": "isValid(hoverFocus) && datum['label'] === hoverFocus['label'] ? 1 : labelsOnHover ? 0 : 1"
            }
          }
        }
      },
      {
        "name": "milestone_labels",
        "description": "Подписи событий.",
        "from": {"data": "milestones"},
        "type": "text",
        "interactive": false,
        "encode": {
          "update": {
            "x": {"field": "x"},
            "y": {"field": "y"},
            "dy": {"field": "dy"},
            "text": {"signal": "datum['domain'] + ' - ' + datum['label']"},
            "fontSize": {"signal": "10"},
            "fill": {"value": "firebrick"},
            "angle": {"field": "labelAngle"},
            "align": {"value": "center"},
            "baseline": {
              "signal": "datum['type'] === 'straight' ? 'top' : 'bottom'"
            },
            "opacity": {
              "signal": "isValid(hoverFocus) && datum['label'] === hoverFocus['label'] ? 1 : labelsOnHover ? 0 : 1"
            }
          }
        }
      }
    ],
    "data": [
      {
        "name": "dataset",
        "values": [
          {"domain": 1928, "label": "Важное событие A"},
          {"domain": 1939, "label": "Важное событие B"},
          {"domain": 1954, "label": "Важное событие C"},
          {"domain": 1962, "label": "Важное событие D"},
          {"domain": 1974, "label": "Важное событие E"},
          {"domain": 1990, "label": "Важное событие F"},
          {"domain": 2001, "label": "Важное событие G"},
          {"domain": 2015, "label": "Важное событие H"}
        ]
      },
      {
        "name": "serpentineDomain",
        "values": [{}],
        "transform": [
          {
            "type": "formula",
            "expr": "sequence(sDomain[0],sDomain[1], 0.1 )",
            "as": "domain"
          },
          {"type": "flatten", "fields": ["domain"]}
        ]
      },
      {
        "name": "milestoneDomain",
        "source": "dataset",
        "transform": [{"type": "project", "fields": ["domain"]}]
      },
      {
        "name": "tickDomain",
        "values": [{}],
        "transform": [
          {"type": "formula", "expr": "sequence(1,tC+1, 1)", "as": "id"},
          {"type": "flatten", "fields": ["id"]},
          {
            "type": "formula",
            "expr": "datum['id'] === 1 ? sDomain[0] : datum['id'] === tC ? sDomain[1] : null",
            "as": "domain"
          },
          {
            "type": "formula",
            "expr": "round(isValid(datum['domain']) ? datum['domain'] : (sDomain[0] + (sDomain[1]-sDomain[0])*((datum['id']-1)/(tC-1))))",
            "as": "domain"
          },
          {"type": "project", "fields": ["domain"]}
        ]
      },
      {
        "name": "componentEncodings",
        "values": [
          {"category": "start"},
          {"category": "serpentine"},
          {"category": "milestone"},
          {"category": "tick"},
          {"category": "end"}
        ],
        "transform": [
          {"type": "formula", "expr": "now()", "as": "timestamp"},
          {
            "type": "formula",
            "expr": "datum['category'] === 'start' ? [sDomain[reverse ? 1 : 0]] : datum['category'] === 'serpentine' ? pluck(data('serpentineDomain'), 'domain') : datum['category'] === 'milestone' ? pluck(data('dataset'), 'domain') : datum['category']==='tick' ? pluck(data('tickDomain'), 'domain') : datum['category'] === 'end' ? [sDomain[reverse ? 0 : 1]] : null",
            "as": "domain"
          },
          {"type": "flatten", "fields": ["domain"]},
          {"type": "formula", "expr": "+datum['domain']", "as": "domain"},
          {
            "type": "window",
            "ops": ["row_number"],
            "sort": {"field": "domain"},
            "groupby": ["category"],
            "as": ["id"]
          },
          {
            "type": "formula",
            "expr": "scale('sS1', datum['domain'])",
            "as": "sK"
          },
          {
            "type": "formula",
            "expr": "floor(datum['sK'] / (sW + sH*PI/2))",
            "as": "i"
          },
          {"type": "formula", "expr": "datum['sK'] % (sW + sH*PI/2)", "as": "r"},
          {"type": "formula", "expr": "(datum['r'] - sW)/(sH/2)", "as": "alpha"},
          {
            "type": "formula",
            "expr": "(((datum['i']+1)*sWsA)-sA) >= datum['sK'] ? 'straight' : 'arc'",
            "as": "type"
          },
          {
            "type": "formula",
            "expr": "(datum['i']%2 == 0) ? min(datum['r'],sW) : max(sW-datum['r'], 0)",
            "as": "xStraight"
          },
          {
            "type": "formula",
            "expr": "datum['type'] === 'straight' ? datum['xStraight'] : datum['xStraight'] + (datum['i']%2 == 0 ? sin(datum['alpha'])*sH/2 : -sin(datum['alpha'])*sH/2)",
            "as": "x"
          },
          {
            "type": "formula",
            "expr": "datum['type'] === 'straight' ? datum['i']*sH : (datum['i']*sH) + (1 - cos(datum['alpha']))*sH/2",
            "as": "y"
          },
          {
            "type": "formula",
            "expr": "datum['type'] === 'straight' ? null : datum['i']%2===0?'right':'left'",
            "as": "side"
          },
          {
            "type": "formula",
            "expr": "datum['type'] === 'straight' ? null : datum['alpha']<PI/2?'top':'bottom'",
            "as": "hemisphere"
          },
          {
            "type": "formula",
            "expr": "datum['type'] === 'straight' ? 0 : (datum['side'] === 'left' ? -1 : 1) *datum['alpha']*(180/PI)+(datum['alpha'] < PI/2 ? 0 : 180)",
            "as": "labelAngle"
          },
          {
            "type": "formula",
            "expr": "datum['i']%2===0 ?'→':'←'",
            "as": "direction"
          },
          {
            "type": "formula",
            "expr": "datum['type'] === 'straight' ? datum['direction'] : datum['side'] === 'left' ? datum['hemisphere'] === 'top'? '←' : '→' :  datum['hemisphere'] === 'top'? '→' : '←'",
            "as": "direction"
          }
        ]
      },
      {
        "name": "serpentine",
        "source": "componentEncodings",
        "transform": [
          {"type": "filter", "expr": "datum['category'] === 'serpentine'"}
        ]
      },
      {
        "name": "ticks",
        "source": "componentEncodings",
        "transform": [
          {"type": "filter", "expr": "datum['category'] === 'tick'"},
          {
            "type": "formula",
            "expr": "!isValid(datum['side']) ? (tLO+3.5) : datum['side'] === 'right' ? (round(datum['alpha']*(180/PI)) >= 90 ? -1 : 1.75) * (tLO+3.5) : (round(datum['alpha']*(180/PI)) > 89 ? -1 : 1.75) * (tLO+3.5)",
            "as": "dy"
          }
        ]
      },
      {
        "name": "domain_extent",
        "source": "componentEncodings",
        "transform": [
          {
            "type": "filter",
            "expr": "datum['category'] === 'start' || datum['category'] === 'end'"
          },
          {
            "type": "formula",
            "expr": "datum['category'] === 'start' ? 'right' : datum['direction'] === '←' && datum['type'] === 'straight' ? 'right' : datum['side'] === 'left' ? 'right' : 'left'",
            "as": "align"
          },
          {
            "type": "formula",
            "expr": "datum['category'] === 'start' ? -tLO-5 :  datum['direction'] === '←'  && datum['type'] === 'straight' ? -tLO-5 : datum['side'] === 'left' ? -tLO-5 : tLO+5",
            "as": "dx"
          }
        ]
      },
      {
        "name": "segment_ends",
        "source": "componentEncodings",
        "transform": [
          {
            "type": "filter",
            "expr": "includeArrows && datum['category'] === 'serpentine'"
          },
          {
            "type": "joinaggregate",
            "fields": ["id", "id"],
            "ops": ["min", "max"],
            "groupby": ["i"],
            "as": ["minId", "maxId"]
          },
          {
            "type": "filter",
            "expr": "datum['id'] === (reverse ? datum['maxId'] : datum['minId'])"
          }
        ]
      },
      {
        "name": "footer",
        "values": [
          {
            "id": 1,
            "text": [
              "Источник данных:",
              "Our World in Data"
            ]
          },
          {
            "id": 2,
            "text": ["Значки:", "Font Awesome"]
          },
          {
            "id": 3,
            "text": ["Автор визуализации:", "Мэдисон Джаммария"]
          }
        ]
      },
      {
        "name": "milestones",
        "source": "componentEncodings",
        "transform": [
          {"type": "filter", "expr": "datum['category'] === 'milestone'"},
          {
            "type": "lookup",
            "key": "domain",
            "from": "dataset",
            "fields": ["domain"],
            "values": ["label", "color"]
          },
          {
            "type": "formula",
            "expr": "!isValid(datum['side']) ? -(mO) : datum['side'] === 'right' ? (round(datum['alpha']*(180/PI)) >= 90 ? 1 : -0.5) * (mO+3.5) : (round(datum['alpha']*(180/PI)) > 89 ? 1 : -0.5) * (mO+3.5)",
            "as": "dy"
          },
          {
            "type": "formula",
            "expr": "(isValid(hoverFocus) && hoverFocus['domain']===datum['domain']) ? 1 : 0.4",
            "as": "fillOpacity"
          }
        ]
      }
    ]
  }