Извилистая временная шкала
Извилистая временная шкала подходит для задач, где неудобно использовать очень вытянутый холст. Пример показывает, как несколько преобразований позволяют задать сложное положение и поворот элементов. Форму можно менять с помощью сигналов; привязки элементов управления добавлены для демонстрации. Существует и более сложный вариант с анимацией изменения масштаба.
Автор визуализации — @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"
}
]
}
]
}