Published
Edited
Dec 9, 2020
1 star
Insert cell
Insert cell
Insert cell
Insert cell
{
const dom = html`<div style="margin-left:100px" class="graph-container">
</div>`;
yield dom;
let chart = new SingleRowBarChart()
.container(dom)
.svgWidth(width)
.svgHeight(100)
.barHeight(30)
.borderRadius(borderRadius)
.scale(d3.scaleLinear().domain([0, 100]).range([0, width-200]))
.render()

setInterval(d=>{
const vals = [
Math.random(),
Math.random(),
Math.random(),
Math.random(),
];
const sum = d3.sum(vals);
const unified = vals.map(d=>d/sum*100).map(Math.round);
chart.data({"values": [
{
"id":'id'+1,
"value": unified[0],
"color": "#30938B",
"name": `Soil Moisture: <b>${unified[0]}</b>`
},
{
"id":'id'+2,
"value": unified[1],
"color": "#ED6B31",
"name": `Soil Temperature: <b>${unified[1]}</b>`
},
{
"id":'id'+3,
"value": unified[2],
"color": "#6C97C7",
"name": `Freeze Risk: <b>${unified[2]}</b>`
},
{
"id":'id'+4,
"value": unified[3],
"color": "#3C5A7E",
"name": `Drought Risk: <b>${unified[3]}</b>`
}
]
.sort( () => .5 - Math.random() )
})
.render()
},2000)

}
Insert cell
class SingleRowBarChart {
constructor() {
this.addPrototypeFuncs();
// Exposed variables
var attrs = {
id: 'ID' + Math.floor(Math.random() * 1000000), // Id for event handlings
svgWidth: 400,
svgHeight: 400,
marginTop: 5,
marginBottom: 5,
marginRight: 5,
marginLeft: 5,
container: 'body',
defaultTextFill: '#2C3E50',
defaultFont: 'Helvetica,sans-serif',
resizeEventListenerId: 'ID' + Math.floor(Math.random() * 1000000),
barHeight: 10,
scale: null,
duration: 500,
delay: 500,
animate: true,
textWidth: 0,
displayTypeInformation: true,
data: {
"values": [
{
"id": 'id' + 1,
"value": 25,
"color": "#30938B",
"name": "Soil Moisture: <b>25</b>"
},
{
"id": 'id' + 2,
"value": 25,
"color": "#ED6B31",
"name": "Soil Temperature: <b>25</b>"
},
{
"id": 'id' + 3,
"value": 25,
"color": "#6C97C7",
"name": "Freeze Risk: <b>25</b>"
},
{
"id": 'id' + 4,
"value": 25,
"color": "#3C5A7E",
"name": "Drought Risk: <b>25</b>"
}
]
}
};

this.getState = () => attrs;
this.setState = (obj) => Object.assign(attrs, obj)

// Dynamically set getter and setter functions for Chart class
Object.keys(attrs).forEach((key) => {
//@ts-ignore
this[key] = function (_) {
if (!arguments.length) {
return attrs[key];
} else {
attrs[key] = _
}
return this;
};
});
}

//Main chart object
main() {
const attrs = this.getState();

//Add svg
const svg = select(attrs.container)
.patternify({
tag: 'svg',
selector: 'svg-chart-container'
})
.attr('overflow', 'visible')
.style('font-family', attrs.defaultFont)
.attr('width', attrs.svgWidth)
.attr('height', attrs.svgHeight)
const defs = svg.patternify({tag:'defs',selector:'defs-element'})
const clipPath = defs.patternify({tag:'clipPath',selector:'clippath-element'})
.attr('id','round-corner')
const clipRect = clipPath.patternify({tag:'rect',selector:'clip-rect'})
.attr('x',0)
.attr('y',0)
.attr('rx',attrs.borderRadius||0)
.attr('ry',attrs.borderRadius||0)
.attr('width',attrs.scale.range()[1])
.attr('height',attrs.barHeight)

// Set d3 container
this.setState({ d3Container: select(attrs.container) });

//Drawing containers
var container = svg;

//Calculated properties
var calc = {};
calc.id = 'ID' + Math.floor(Math.random() * 1000000); // id for event handlings

//Assign each data x
calc.types = Array.from(new Set(attrs.data.values.map(d => d.type)));

// Assign x coordinates to each bar
attrs.data.values.filter(v => v).forEach((v, j, arr) => {
if (j == 0) {
v.x = 0;
} else {
v.x = arr[j - 1].x + attrs.scale(arr[j - 1].value)
}
})

//Add container g element
var chart = container
.patternify({ tag: 'g', selector: 'chart' })

// Draw rectangle group wrappers
const rectsWrapper = chart
.patternify({ tag: 'g', selector: 'rect-wrapper' })
.attr('transform', `translate(${attrs.textWidth})`)

// Draw rectangles
const rects = rectsWrapper.patternify({ tag: 'rect', selector: 'rects', data: attrs.data.values })
.attr('height', attrs.barHeight)
.attr('fill', d => d.color)
.attr('clip-path','url(#round-corner)')
.attr('stroke', d => d.color)

// If animation disabled, set x position
if (!attrs.animate) {
rects
.attr('x', d => d.prevX)
.attr('width', function (d) {
return Math.max(d.prevWidth, 0);
})
}

// Transition rectangles to their new position
rects.transition()
.duration(attrs.duration)
.delay((d, i, arr) => i / arr.length * attrs.delay)
.attr('x', d => d.x)
.attr('width', d => Math.max(attrs.scale(d.value), 0))
.on('end', function (d) {
d.prevWidth = attrs.scale(d.value);
d.prevX = d.x;
})

// Calculate optimal text x positions and set colors
attrs.data.values.forEach((d, i, arr) => {
d.textY = 0;
d.isBottom = false;
const maxString = d.name.split(' ')
.map(d => create('div').html(d).node().innerText)
.sort((a, b) => a.length > b.length ? -1 : 1)[0];
d.textWidth = textWidth(maxString, '10px');
d.textTotalWidth = textWidth(d.name, '10px')
const barWidth = attrs.scale(d.value);
if (d.textWidth > barWidth - 2) {
d.isBottom = true;
d.textY = 20;
d.textColor = hcl(d.color).darker()
} else {
const isDark = hexColorIsDark(color(d.color).hex());
d.textColor = isDark ? 'white' : attrs.defaultTextFill;
}
})

// Calculate Y positions
attrs.data.values.forEach((d, i, arr) => {
d.isMiddle = false;
if (d.isBottom) {
if (arr[i - 1] && arr[i + 1] && !arr[i - 1].isBottom && !arr[i + 1].isBottom) {
d.isMiddle = true;
d.textY = attrs.barHeight;
}
}
})

// Override text positions for some cases
attrs.data.values.forEach(d => {
d.isCornerLine = false;
d.left = null;
})
const bottomCircles = attrs.data.values.filter(d => d.isBottom && !d.isMiddle);
const linesCount = bottomCircles.length;
const sidesCount = Math.round(linesCount / 2);
bottomCircles.forEach((d, i) => {
d.isCornerLine = true;
if (i < sidesCount) {
d.textIndex = i;
d.left = true;
} else {
d.textIndex = sidesCount - Math.abs(sidesCount - i) - 1;
d.left = false;
}
d.textY = attrs.barHeight + d.textIndex * 12
})

const maxX = max(bottomCircles.filter(d => !d.left), d => d.x + attrs.scale(d.value) / 2);
const minX = min(bottomCircles.filter(d => d.left), d => d.x + attrs.scale(d.value) / 2);
const leftMaxTextWidth = max(bottomCircles.filter(d => d.left), d => d.textTotalWidth);
bottomCircles.filter(d => !d.left).forEach(d => d.bottomTextX = maxX + 10);
bottomCircles.filter(d => d.left).forEach(d => d.bottomTextX = minX - 10 - leftMaxTextWidth);

// Draw foreign object
const innerFor = rectsWrapper.patternify({ tag: 'foreignObject', selector: 'bar-inner-text', data: attrs.data.values })
.style('overflow', 'visible')
.attr('width', 500)
.attr('height', 100)
.attr('y', d => d.textY || 0)
.attr('x', d => {
const x = d.prevX || d.x + 2;
return x;
})

// Draw foreign object divs
const innerTexts = innerFor.patternify({
tag: 'xhtml:div', selector: 'fo-div', data: d => [d]
})
.style('font-size', 9.5 + 'px')
.style('opacity', d => d.isCornerLine ? 0 : 1)
.style('color', d => d.textColor)
.style('width', d => {
if (d.textY > 0) {
return 500 + 'px'
}
return attrs.scale(d.value) + 'px'
})
.html(d => {
return `
<div style="display: table; height: ${attrs.barHeight}px; overflow: hidden;">
<div style="display: table-cell; vertical-align: middle;">
<div>
${d.name ? d.name : ''}
</div>
</div>
</div>`
})


// Hide texts initially
if (attrs.animate) {
innerTexts.style('opacity', 0)
}

// Transition texts to specific x coordinates
innerFor.transition()
.delay(attrs.delay)
.duration(attrs.duration)
.attr('x', d => {
if (d.isCornerLine) {
return d.bottomTextX
}
if (d.isMiddle) {
return d.x + (attrs.scale(d.value) - d.textTotalWidth) / 2
}
return d.x + 2;
})

// Make texts visible again
innerTexts.transition()
.delay(attrs.duration + attrs.delay)
.duration(attrs.duration)
.style('opacity', 1)

// Bottom Text Link Lines
const middleRects = rectsWrapper.patternify({ tag: 'rect', selector: 'text-link-line', data: attrs.data.values.filter(d => d.isMiddle) })
.attr('width', 0.5)
.attr('height', attrs.barHeight / 2 + 10)
.attr('y', attrs.barHeight / 2)
.attr('x', d => (d.prevX || d.x) + (d.prevWidth || attrs.scale(d.value)) / 2)

// Hide middle rects initially
if (attrs.animate) {
middleRects.style('opacity', 0)
}

// Position middle rects to correct position
middleRects.transition()
.delay(attrs.delay)
.duration(attrs.duration)
.style('opacity', 1)
.attr('x', d => d.x + attrs.scale(d.value) / 2)

// ---- BOTTOM TEXT CORNERED LINES ---------
const bottomCornerLines = rectsWrapper.patternify({ tag: 'rect', selector: 'bottom-cornet-line-vertical', data: bottomCircles })
.attr('width', 0.5)
.attr('height', d => d.textY)
.attr('opacity', 0)
.attr('y', attrs.barHeight / 2)
.attr('x', d => (d.prevX || d.x) + (d.prevWidth || attrs.scale(d.value)) / 2)
if (attrs.animate) {
bottomCornerLines
.attr('x', 0)
}

// Position bottom cornet lines
bottomCornerLines.transition()
.duration(attrs.duration)
.delay(attrs.delay)
.attr('opacity', 1)
.attr('x', d => (d.x) + (attrs.scale(d.value)) / 2)
const bottomHorCornerLines = rectsWrapper.patternify({ tag: 'rect', selector: 'bottom-hor-line', data: bottomCircles })
.attr('width', d => {
const dx = d.prevX || d.x;
const width = (d.prevWidth || attrs.scale(d.value)) / 2;
let result = 0;
if (d.left) {
result = (dx + width) - d.bottomTextX - d.textTotalWidth - 5
} else {
result = d.bottomTextX - dx - 10;
}
return result > 0 ? result : 0;
})
.attr('height', 0.5)
.attr('opacity', 0)
.attr('y', d => d.textY + attrs.barHeight / 2)
.attr('x', d => {
const dx = d.prevX || d.x;
const width = (d.prevWidth || attrs.scale(d.value)) / 2
if (d.left) {
return dx + width - ((dx + width) - d.bottomTextX - d.textTotalWidth - 5);
} else {
return dx + width;
}
})

if (attrs.animate) {
bottomHorCornerLines
.attr('x', 0)
}
bottomHorCornerLines.transition()
.duration(attrs.duration)
.delay(attrs.delay)
.attr('width', d => {
let result = 0
if (d.left) {
result = (d.x + attrs.scale(d.value) / 2) - d.bottomTextX - d.textTotalWidth - 5
} else {
result = d.bottomTextX - d.x - 14;
}
return Math.max(result, 0)
})
.attr('x', d => {
if (d.left) {
return d.x + attrs.scale(d.value) / 2 - ((d.x + attrs.scale(d.value) / 2) - d.bottomTextX - d.textTotalWidth - 5);
} else {
return d.x + attrs.scale(d.value) / 2;
}
})
.attr('opacity', 1)

this.reRenderOnResize();

//######################################### UTIL FUNCS ##################################
function textWidth(text, fontProp) {
var tag = document.createElement("div");
tag.style.position = "absolute";
tag.style.left = "-99in";
tag.style.whiteSpace = "nowrap";
tag.style.fontSize = fontProp;
tag.innerHTML = text;
document.body.appendChild(tag);
var result = tag.clientWidth;
document.body.removeChild(tag);
return result;
}

// Utility func which checks whether color is dark
function hexColorIsDark(c) {
var c = c.substring(1); // strip #
var rgb = parseInt(c, 16); // convert rrggbb to decimal
var r = (rgb >> 16) & 0xff; // extract red
var g = (rgb >> 8) & 0xff; // extract green
var b = (rgb >> 0) & 0xff; // extract blue
var luma = 0.2126 * r + 0.7152 * g + 0.0722 * b; // per ITU-R BT.709
if (luma < 186) {
return true;
}
return false;
}
};

addPrototypeFuncs() {
//----------- PROTOTYPE FUNCTIONS ----------------------
selection.prototype.patternify = function (params) {
var container = this;
var selector = params.selector;
var elementTag = params.tag;
var data = params.data || [selector];

// Pattern in action
var selection = container.selectAll('.' + selector).data(data, (d, i) => {
if (typeof d === 'object') {
if (d.id) {
return d.id;
}
}
return i;
});
selection.exit().remove();
selection = selection.enter().append(elementTag).merge(selection);
selection.attr('class', selector);
return selection;
};
}

// Listen resize event and resize on change
reRenderOnResize() {
const {
resizeEventListenerId,
d3Container,
svgWidth
} = this.getState();
select(window).on('resize.' + resizeEventListenerId, () => {
const { timeoutId, transationTimeoutId } = this.getState();
if (timeoutId) clearTimeout(timeoutId);
if (transationTimeoutId) clearTimeout(transationTimeoutId);
const newTimeoutId = setTimeout(d => {
const { disableResizeTransition } = this.getState();
const containerRect = d3Container.node().getBoundingClientRect();
const newSvgWidth = containerRect.width > 0 ? containerRect.width : svgWidth;
this.setState({
svgWidth: newSvgWidth
});
if (disableResizeTransition) {
this.setState({ transition: false })
}
this.render();
this.setState({});
const newTransationTimeoutId = setTimeout(v => {
this.setState({ transition: true })
}, 500)
this.setState({ transationTimeoutId: newTransationTimeoutId })
}, 1000)
this.setState({ timeoutId: newTimeoutId })
});
}

// Run visual
render() {
this.main();
return this;
};
// Set border radius of visual
borderRadius(borderRadius){
if(borderRadius==undefined) return this.getState().borderRadius;
this.setState({borderRadius:borderRadius});
return this;
}

}
Insert cell
d3 = require('d3@v6')
Insert cell
select = d3.select
Insert cell
selection = d3.selection
Insert cell
create = d3.create
Insert cell
hcl = d3.hcl
Insert cell
color = d3.color
Insert cell
min = d3.min
Insert cell
max = d3.max
Insert cell
import {slider} from "@jashkenas/inputs";
Insert cell

Purpose-built for displays of data

Observable is your go-to platform for exploring data and creating expressive data visualizations. Use reactive JavaScript notebooks for prototyping and a collaborative canvas for visual data exploration and dashboard creation.
Learn more