Reputation: 657
I would like to colour a line above a certain threshold in one colour and below the threshold another colour (see image below).
How would I go about doing this?
I've tried using gradients and the colouring of the line works fine but I cannot figure out how to set the position of the threshold. In the definition for gradients the colour stops have to be a number between 0 and 1 (offset fraction, indicating its position within the gradient).
I would instead like to specify the offset as a value on the y-axis.
Is this possible? How would I do it?
Example of a chart using gradient to change the color of the line above a threshold:
Gradient definition:
"color": {
"x1": 1,
"y1": 1,
"x2": 1,
"y2": 0,
"gradient": "linear",
"stops": [
{
"offset": 0,
"color": "red"
},
{
"offset": 0.5,
"color": "red"
},
{
"offset": 0.5,
"color": "blue"
},
{
"offset": 1,
"color": "blue"
}
]
}
Code and running spec for the chart above: https://vega.github.io/editor/#/gist/06c345f6fd61127781e285a227243f4c/spec.json
Gradient documentation: https://vega.github.io/vega-lite/docs/gradient.html
Upvotes: 2
Views: 867
Reputation: 2451
And here is a fun animated version..
Save this to a .html file on your desktop and see the magic of vega-lite and JavaScript at work.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Vega-Lite Example</title>
<script src="https://cdn.jsdelivr.net/npm/vega@5"></script>
<script src="https://cdn.jsdelivr.net/npm/vega-lite@5"></script>
<script src="https://cdn.jsdelivr.net/npm/vega-embed@6"></script>
</head>
<body>
<div id="vis"></div>
<script>
// Load the data
let data = [
{"date": "2022-01-21T00:00:00", "price": 190},
{"date": "2022-01-22T00:00:00", "price": 105},
{"date": "2022-01-23T00:00:00", "price": 202},
{"date": "2022-01-24T00:00:00", "price": 270},
{"date": "2022-01-25T00:00:00", "price": 100},
{"date": "2022-01-26T00:00:00", "price": 220},
{"date": "2022-01-27T00:00:00", "price": 221},
{"date": "2022-01-28T00:00:00", "price": 100},
{"date": "2022-01-29T00:00:00", "price": 185},
{"date": "2022-01-30T00:00:00", "price": 202},
{"date": "2022-01-31T00:00:00", "price": 102},
{"date": "2022-02-01T00:00:00", "price": 300},
{"date": "2022-02-02T00:00:00", "price": 250},
{"date": "2022-02-03T00:00:00", "price": 280},
{"date": "2022-02-04T00:00:00", "price": 270},
{"date": "2022-02-05T00:00:00", "price": 80},
{"date": "2022-02-06T00:00:00", "price": 120},
{"date": "2022-02-07T00:00:00", "price": 171},
{"date": "2022-02-08T00:00:00", "price": 190},
{"date": "2022-02-09T00:00:00", "price": 85},
{"date": "2022-02-10T00:00:00", "price": 202},
{"date": "2022-02-11T00:00:00", "price": 230}
];
const threshold = 180;
function updateData() {
// Modify the price of each data point randomly
data = data.map(d => {
return {
...d,
price: Math.floor(Math.random() * 150) + 130 // Random price between 100 and 300
};
});
renderChart(); // Call renderChart whenever the data changes
}
// Call updateData every 2 seconds
setInterval(updateData, 2000);
function renderChart() {
// Iterate through the data and add fake rows for values that cross the threshold
const newData = [];
for (let i = 0; i < data.length - 1; i++) {
const d1 = data[i];
const d2 = data[i + 1];
// Check if the price crosses the threshold between these two data points
if ((d1.price < threshold && d2.price >= threshold) || (d1.price >= threshold && d2.price < threshold)) {
// Calculate the interpolated point where the line crosses the threshold
const t = (threshold - d1.price) / (d2.price - d1.price);
const interpolatedPrice = threshold;
const interpolatedTimestamp = new Date(new Date(d1.date).getTime() + t * (new Date(d2.date).getTime() - new Date(d1.date).getTime()));
interpolatedTimestamp.setSeconds(0); // Round to the nearest minute
const interpolatedDate = interpolatedTimestamp.toISOString();
// Add a fake data point for the interpolated value
newData.push({
date: d1.date,
price: d1.price,
lastDay: 0,
color: d1.price < threshold ? 'red' : 'blue'
});
newData.push({
date: interpolatedDate,
price: interpolatedPrice,
lastDay: 0,
color: d1.price < threshold ? 'blue' : 'red'
});
newData.push({
date: interpolatedDate,
price: interpolatedPrice,
lastDay: 0,
color: d1.price < threshold ? 'red' : 'blue'
});
newData.push({
date: d2.date,
price: d2.price,
lastDay: 0,
color: d2.price < threshold ? 'red' : 'blue'
});
} else {
// No interpolation needed, just copy the original data point
newData.push({
date: d1.date,
price: d1.price,
lastDay: 0,
color: d1.price < threshold ? 'red' : 'blue'
});
}
}
// Add the last data point with the color and lastDay properties
const lastDataPoint = data[data.length - 1];
newData.push({
date: lastDataPoint.date,
price: lastDataPoint.price,
lastDay: 1,
color: lastDataPoint.price < threshold ? 'red' : 'blue'
});
const processedData = newData.map(d => {
return {
date: new Date(d.date),
price: d.price,
lastDay: d.lastDay,
color: d.color
};
});
// Test out new data source
//console.log(processedData);
var thresholdX = threshold.toString();
var thresholdZ = threshold.toString();
// Define the Vega-Lite specification
const spec = {
"$schema": "https://vega.github.io/schema/vega-lite/v5.json",
"data": {"values": processedData},
"height": 400,
"width": 400,
"view": {"stroke": null},
"encoding": {
"x": {"field": "date", "type": "temporal","title": null,
"axis": {
"tickCount": 10,
"labelAlign": "left",
"labelExpr": "[timeFormat(datum.value, '%d'), timeFormat(datum.value, '%d') == '01' ? timeFormat(datum.value, '%b') : '']",
"labelOffset": 4,
"labelPadding": -24,
"tickSize": 30,
"gridDash": {
"condition": {"test": "timeFormat(datum.value, '%d') == '01'", "value": []},
"value": [2,2]
},
"tickDash": {
"condition": {"test": "timeFormat(datum.value, '%d') == '01'", "value": []},
"value": [2,2]
}
}
},
"y": {"field": "price", "type": "quantitative", "impute": {"value": null},"title": null,
"scale": {"zero": false}
},
"color": {
"field": "color",
"type": "nominal",
"scale": {"domain": ["red", "blue"], "range": ["#EC685C", "#2A84EC"]},
"legend": null
},
"tooltip": [
{"field": "date", "type": "temporal"},
{"field": "price", "type": "quantitative"}
]
},
"layer": [
// layer for horizontal rule
{
"transform": [
{"calculate": thresholdX, "as": "threshold2"}
],
"mark": {
"type": "line",
"strokeDash": [2, 2],
"strokeWidth": 1
},
"encoding": {
"y": {"field": "threshold2", "type": "quantitative","title": null,
"scale": {"zero": false}
},
"tooltip": {"field": "threshold2", "type": "quantitative"},
"color": {"value": "black"}
}
},
// + fill
{
"transform": [
{filter: "datum.price >= " + thresholdX},
{"calculate": thresholdX, "as": "threshold2"}
],
"mark": {
"type": "area"
},
"encoding": {
"y2": {"field": "threshold2", "type": "quantitative","title": null
},
"color": {"value": "#2A84EC"},
"opacity": {"value": 0.3}
}
},
// - fill
{
"transform": [
{filter: "datum.price <= " + thresholdX},
{"calculate": thresholdX, "as": "threshold2"}
],
"mark": {
"type": "area"
},
"encoding": {
"y2": {"field": "threshold2", "type": "quantitative","title": null
},
"color": {"value": "#EC685C"},
"opacity": {"value": 0.3}
}
},
// layer for actual line
{
"mark": {
"type": "line",
"strokeWidth": 2
}
},
// layer for easy tooltip. Big hidden circles
{
"params": [
{
"name": "paintbrush",
"select": {"type": "point", "on": "mouseover", "nearest": true}
}
],
"mark": {"type": "circle", "tooltip": true},
"encoding": {
"size": {"value": 150},
"color": {"value": "transparent"}
}
},
// Layer for new text mark where lastDay equals 1
{
"transform": [{"filter": "datum.lastDay == 1"}],
"mark": {
"type": "text",
"align": "right",
"baseline": "middle",
"dx": 40,
"dy": -0,
"fontWeight": 500,
"fontSize": 16
},
"encoding": {
"x": {"field": "date", "type": "temporal", "axis": {"title": null}},
"y": {"field": "price", "type": "quantitative", "impute": {"value": null}, "title": null},
"text": {"field": "price", "type": "quantitative", "format": ".0f"}
}
},
// Layer for new text mark where lastDay equals 1
{
"transform": [{"filter": "datum.lastDay == 1"}],
"mark": {
"type": "circle"
},
"encoding": {
"size": {"value": 60}
}
}
],
"config": {
"legend": null,
"axis": {"grid": false},
"view": {"toolbar": false},
"renderer": "svg"
}
};
// Render the chart using Vega-Embed
const embedOpt = {"mode": "vega-lite", "actions": false};
vegaEmbed("#vis", spec, embedOpt);
}
renderChart(); // Render the chart for the first time
</script>
</body>
</html>
Upvotes: 0
Reputation: 2451
I having been thinking about this issue and have come up with another approach. Instead of trying to fix the chart we just need to fix the data and introduce some fake rows of data where the values cross the threshold point. In my case this is 200.
So if my dataset is:
{"date": "2022-01-01T00:00:00", "price": 100},
{"date": "2022-01-02T00:00:00", "price": 150},
{"date": "2022-01-03T00:00:00", "price": 180},
{"date": "2022-01-04T00:00:00", "price": 270},
{"date": "2022-01-05T00:00:00", "price": 80}
We need to add some additional "fake" rows where the price crosses the threshold between the two data points:
{"date": "2022-01-03T00:00:00", "price": 200},
{"date": "2022-01-04T00:00:00", "price": 200},
To get the lines to work perfectly we need to calculate at approx what time did the line cross the threshold. We can find this out my looking at the range of start and end. If midnight was 180 and 24 hrs later it was 270 then we know it moved 90 during 24 hrs. So we just need to know how long did id take to move 20 (180 to 200). And that is easy with javascript. You could also apply the same logic with a view in SQL.
{"date": "2022-01-03T00:00:00", "price": 180},
{"date": "2022-01-04T00:00:00", "price": 270},
This can be done with Javascript like this:
if ((d1.price < threshold && d2.price >= threshold) || (d1.price >= threshold && d2.price < threshold)) {
// Calculate the interpolated point where the line crosses the threshold
const t = (threshold - d1.price) / (d2.price - d1.price);
const interpolatedPrice = threshold;
const interpolatedTimestamp = new Date(new Date(d1.date).getTime() + t * (new Date(d2.date).getTime() - new Date(d1.date).getTime()));
interpolatedTimestamp.setSeconds(0); // Round to the nearest minute
const interpolatedDate = interpolatedTimestamp.toISOString();
I also found it was necessary to add 2 fake rows each time. One for each color otherwise vega-lite would show gaps in the chart. Please use the javascript below to show a multi-color line chart the crosses a threshold.
Happy charting!
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Vega-Lite Example</title>
<script src="https://cdn.jsdelivr.net/npm/vega@5"></script>
<script src="https://cdn.jsdelivr.net/npm/vega-lite@5"></script>
<script src="https://cdn.jsdelivr.net/npm/vega-embed@6"></script>
</head>
<body>
<div id="vis"></div>
<script>
// Load the data
const data = [
{"date": "2022-01-10T00:00:00", "price": 202},
{"date": "2022-01-11T00:00:00", "price": 198},
{"date": "2022-01-12T00:00:00", "price": 100},
{"date": "2022-01-13T00:00:00", "price": 200},
{"date": "2022-01-14T00:00:00", "price": 200},
{"date": "2022-01-15T00:00:00", "price": 150},
{"date": "2022-01-16T00:00:00", "price": 180},
{"date": "2022-01-17T00:00:00", "price": 270},
{"date": "2022-01-18T00:00:00", "price": 170},
{"date": "2022-01-19T00:00:00", "price": 220},
{"date": "2022-01-20T00:00:00", "price": 221},
{"date": "2022-01-21T00:00:00", "price": 190},
{"date": "2022-01-22T00:00:00", "price": 185},
{"date": "2022-01-23T00:00:00", "price": 202},
{"date": "2022-01-24T00:00:00", "price": 270},
{"date": "2022-01-25T00:00:00", "price": 160},
{"date": "2022-01-26T00:00:00", "price": 220},
{"date": "2022-01-27T00:00:00", "price": 221},
{"date": "2022-01-28T00:00:00", "price": 190},
{"date": "2022-01-29T00:00:00", "price": 185},
{"date": "2022-01-30T00:00:00", "price": 202},
{"date": "2022-01-31T00:00:00", "price": 202},
{"date": "2022-02-01T00:00:00", "price": 300},
{"date": "2022-02-02T00:00:00", "price": 250},
{"date": "2022-02-03T00:00:00", "price": 280},
{"date": "2022-02-04T00:00:00", "price": 270},
{"date": "2022-02-05T00:00:00", "price": 180},
{"date": "2022-02-06T00:00:00", "price": 120},
{"date": "2022-02-07T00:00:00", "price": 171},
{"date": "2022-02-08T00:00:00", "price": 190},
{"date": "2022-02-09T00:00:00", "price": 185},
{"date": "2022-02-10T00:00:00", "price": 202},
{"date": "2022-02-11T00:00:00", "price": 230}
];
const threshold = 180;
// Iterate through the data and add fake rows for values that cross the threshold
const newData = [];
for (let i = 0; i < data.length - 1; i++) {
const d1 = data[i];
const d2 = data[i + 1];
// Check if the price crosses the threshold between these two data points
if ((d1.price < threshold && d2.price >= threshold) || (d1.price >= threshold && d2.price < threshold)) {
// Calculate the interpolated point where the line crosses the threshold
const t = (threshold - d1.price) / (d2.price - d1.price);
const interpolatedPrice = threshold;
const interpolatedTimestamp = new Date(new Date(d1.date).getTime() + t * (new Date(d2.date).getTime() - new Date(d1.date).getTime()));
interpolatedTimestamp.setSeconds(0); // Round to the nearest minute
const interpolatedDate = interpolatedTimestamp.toISOString();
// Add a fake data point for the interpolated value
newData.push({
date: d1.date,
price: d1.price,
lastDay: 0,
color: d1.price < threshold ? 'red' : 'blue'
});
newData.push({
date: interpolatedDate,
price: interpolatedPrice,
lastDay: 0,
color: d1.price < threshold ? 'blue' : 'red'
});
newData.push({
date: interpolatedDate,
price: interpolatedPrice,
lastDay: 0,
color: d1.price < threshold ? 'red' : 'blue'
});
newData.push({
date: d2.date,
price: d2.price,
lastDay: 0,
color: d2.price < threshold ? 'red' : 'blue'
});
} else {
// No interpolation needed, just copy the original data point
newData.push({
date: d1.date,
price: d1.price,
lastDay: 0,
color: d1.price < threshold ? 'red' : 'blue'
});
}
}
// Add the last data point with the color and lastDay properties
const lastDataPoint = data[data.length - 1];
newData.push({
date: lastDataPoint.date,
price: lastDataPoint.price,
lastDay: 1,
color: lastDataPoint.price < threshold ? 'red' : 'blue'
});
const processedData = newData.map(d => {
return {
date: new Date(d.date),
price: d.price,
lastDay: d.lastDay,
color: d.color
};
});
// Test out new data source
console.log(processedData);
var thresholdX = threshold.toString();
var thresholdZ = threshold.toString();
console.log(thresholdX);
// Define the Vega-Lite specification
const spec = {
"$schema": "https://vega.github.io/schema/vega-lite/v5.json",
"data": {"values": processedData},
"height": 400,
"width": 400,
"view": {"stroke": null},
"encoding": {
"x": {"field": "date", "type": "temporal","title": null,
"axis": {
"tickCount": 10,
"labelAlign": "left",
"labelExpr": "[timeFormat(datum.value, '%d'), timeFormat(datum.value, '%d') == '01' ? timeFormat(datum.value, '%b') : '']",
"labelOffset": 4,
"labelPadding": -24,
"tickSize": 30,
"gridDash": {
"condition": {"test": "timeFormat(datum.value, '%d') == '01'", "value": []},
"value": [2,2]
},
"tickDash": {
"condition": {"test": "timeFormat(datum.value, '%d') == '01'", "value": []},
"value": [2,2]
}
}
},
"y": {"field": "price", "type": "quantitative", "impute": {"value": null},"title": null,
"scale": {"zero": false}
},
"color": {
"field": "color",
"type": "nominal",
"scale": {"domain": ["red", "blue"], "range": ["#EC685C", "#2A84EC"]},
"legend": null
},
"tooltip": [
{"field": "date", "type": "temporal"},
{"field": "price", "type": "quantitative"}
]
},
"layer": [
// layer for horizontal rule
{
"transform": [
{"calculate": thresholdX, "as": "threshold2"}
],
"mark": {
"type": "line",
"strokeDash": [2, 2],
"strokeWidth": 1
},
"encoding": {
"y": {"field": "threshold2", "type": "quantitative","title": null,
"scale": {"zero": false}
},
"tooltip": {"field": "threshold2", "type": "quantitative"},
"color": {"value": "black"}
}
},
// + fill
{
"transform": [
{filter: "datum.price >= " + thresholdX},
{"calculate": thresholdX, "as": "threshold2"}
],
"mark": {
"type": "area"
},
"encoding": {
"y2": {"field": "threshold2", "type": "quantitative","title": null
},
"color": {"value": "#2A84EC"},
"opacity": {"value": 0.3}
}
},
// - fill
{
"transform": [
{filter: "datum.price <= " + thresholdX},
{"calculate": thresholdX, "as": "threshold2"}
],
"mark": {
"type": "area"
},
"encoding": {
"y2": {"field": "threshold2", "type": "quantitative","title": null
},
"color": {"value": "#EC685C"},
"opacity": {"value": 0.3}
}
},
// layer for actual line
{
"mark": {
"type": "line",
"strokeWidth": 2
}
},
// layer for easy tooltip. Big hidden circles
{
"params": [
{
"name": "paintbrush",
"select": {"type": "point", "on": "mouseover", "nearest": true}
}
],
"mark": {"type": "circle", "tooltip": true},
"encoding": {
"size": {"value": 150},
"color": {"value": "transparent"}
}
},
// Layer for new text mark where lastDay equals 1
{
"transform": [{"filter": "datum.lastDay == 1"}],
"mark": {
"type": "text",
"align": "right",
"baseline": "middle",
"dx": 40,
"dy": -0,
"fontWeight": 500,
"fontSize": 16
},
"encoding": {
"x": {"field": "date", "type": "temporal", "axis": {"title": null}},
"y": {"field": "price", "type": "quantitative", "impute": {"value": null}, "title": null},
"text": {"field": "price", "type": "quantitative", "format": ".0f"}
}
},
// Layer for new text mark where lastDay equals 1
{
"transform": [{"filter": "datum.lastDay == 1"}],
"mark": {
"type": "circle"
},
"encoding": {
"size": {"value": 60}
}
}
],
"config": {
"legend": null,
"axis": {"grid": false},
"view": {"toolbar": false},
"renderer": "svg"
}
};
// Render the chart using Vega-Embed
const embedOpt = {"mode": "vega-lite", "actions": false};
vegaEmbed("#vis", spec, embedOpt);
</script>
</body>
</html>
Upvotes: 0
Reputation: 30324
Here is a hacky way in Vega for you.
{
"$schema": "https://vega.github.io/schema/vega/v5.json",
"description": "A basic stacked area chart example.",
"width": 500,
"height": 200,
"padding": 5,
"data": [
{
"name": "table",
"values": [
{"x": 0, "y": 28},
{"x": 1, "y": 43},
{"x": 2, "y": 81},
{"x": 3, "y": 19},
{"x": 4, "y": 52},
{"x": 5, "y": 24},
{"x": 6, "y": 87},
{"x": 7, "y": 17},
{"x": 8, "y": 68},
{"x": 9, "y": 49}
],
"transform": [{"type": "formula", "as": "y2", "expr": "datum.y-1"}]
}
],
"scales": [
{
"name": "x",
"type": "point",
"range": "width",
"domain": {"data": "table", "field": "x"}
},
{
"name": "y",
"type": "linear",
"range": "height",
"nice": true,
"zero": true,
"domain": {"data": "table", "field": "y"}
},
{"name": "color", "type": "sequential", "range": {"scheme": "rainbow"}}
],
"axes": [
{"orient": "bottom", "scale": "x", "zindex": 1},
{"orient": "left", "scale": "y", "zindex": 1}
],
"marks": [
{
"type": "area",
"from": {"data": "table"},
"encode": {
"enter": {
"interpolate": {"value": "monotone"},
"x": {"scale": "x", "field": "x"},
"y": {"scale": "y", "field": "y"},
"y2": {"scale": "y", "field": "y2"},
"fill": {"signal": "gradient('color', [0,1], [1,1])"}
}
}
}
]
}
Upvotes: 3