To approximate the width of strings without touching the DOM. (Or rather: getBoundingClientRect is used to calibrate a static function.) Choose a font-family upfront; font-size can be passed as a parameter.
Calibration script seems to suck in Firefox… but the generated calibrated function should work fine in Firefox!
function measureText(string, fontSize = 10) {
const widths = [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.2796875,0.2765625,0.3546875,0.5546875,0.5546875,0.8890625,0.665625,0.190625,0.3328125,0.3328125,0.3890625,0.5828125,0.2765625,0.3328125,0.2765625,0.3015625,0.5546875,0.5546875,0.5546875,0.5546875,0.5546875,0.5546875,0.5546875,0.5546875,0.5546875,0.5546875,0.2765625,0.2765625,0.584375,0.5828125,0.584375,0.5546875,1.0140625,0.665625,0.665625,0.721875,0.721875,0.665625,0.609375,0.7765625,0.721875,0.2765625,0.5,0.665625,0.5546875,0.8328125,0.721875,0.7765625,0.665625,0.7765625,0.721875,0.665625,0.609375,0.721875,0.665625,0.94375,0.665625,0.665625,0.609375,0.2765625,0.3546875,0.2765625,0.4765625,0.5546875,0.3328125,0.5546875,0.5546875,0.5,0.5546875,0.5546875,0.2765625,0.5546875,0.5546875,0.221875,0.240625,0.5,0.221875,0.8328125,0.5546875,0.5546875,0.5546875,0.5546875,0.3328125,0.5,0.2765625,0.5546875,0.5,0.721875,0.5,0.5,0.5,0.3546875,0.259375,0.353125,0.5890625]
const avg = 0.5279276315789471
return string
.split('')
.map(c => c.charCodeAt(0) < widths.length ? widths[c.charCodeAt(0)] : avg)
.reduce((cur, acc) => acc + cur) * fontSize
}
So we have this chart in React and we want the right axis to autosize to fit labels of varying length. One solution is to render the whole axis, get its bounding box, and then re-render the whole chart with the computer axis width as a margin parameter. But that sorta sucks because you double your render cycles (or fracture your render code in weird unreadable ways, with un-React-like DOM manipulation).
This solution is fragile and hacky in other ways, but may be perfectly suitable in many cases: just do the math with hardcoded character widths! Sorry! The measureText function here is calibrated once to a font (by you! manually! by default, Helvetica, natch) and then takes a string and font size and returns the computed rendered width. Just copy and paste the measureText function into your project.
Obviously assumes no kerning, no ligatures, no wrapping, etc. Only supports ASCII; otherwise it assumes a default average character width. It’s also missing a lot of weird thin spaces and such.
This has surely been done a million times (including by my coworker it turns out lol), there’s stuff like adambisek/string-pixel-width, but I couldn’t quickly find something quick & easy & copypastable.
Good night.
<!DOCTYPE html>
<html>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title></title>
<link rel="stylesheet" type="text/css" href="style.css"/>
<body>
<div id="controls">
<form onSubmit="return updateFontFamily(event)">
font-family: <input id="input" value="helvetica">;
<input type="submit" value="Go">
</form>
<hr/>
<p>Copy and paste me:</p>
<textarea id="output" onclick="this.focus();this.select()" readonly="readonly"></textarea>
<p>Example usage:</p>
<textarea id="ex" readonly="readonly"></textarea>
</div>
<svg>
<defs>
<marker id="mark" viewBox="0 0 10 10" refX="1" refY="6"
markerWidth="6" markerHeight="12" orient="auto">
<line y1="0" y2="12" x1="1" x2="1" />
</marker>
</defs>
<g id="calibrate"></g>
<g id="demo"></g>
</svg>
</body>
<script src="https://d3js.org/d3.v4.min.js"></script>
<script src="calibrate.js" charset="utf-8"></script>
<script src="demo.js" charset="utf-8"></script>
<script>
var measureText
calibrate()
startDemo()
</script>
</html>
// Update button onclick handler
function updateFontFamily(event) {
event.preventDefault()
d3.select("svg").style("font-family", d3.select("#input").node().value)
calibrate()
}
// This evaluates and prints the updated function
function calibrate() {
var widths = calculate()
// this is weird and broken-up cuz "normal" function declarations don't seem to play well with eval
var fnName = 'measureText'
var fnParams = '(string, fontSize = 10)'
var fn = `{
const widths = ${JSON.stringify(widths)}
const avg = ${d3.mean(widths.filter(d => d !== 0))}
return string
.split('')
.map(c => c.charCodeAt(0) < widths.length ? widths[c.charCodeAt(0)] : avg)
.reduce((cur, acc) => acc + cur) * fontSize
}`
eval(`${fnName} = function${fnParams} ${fn}`)
d3.select("#output").text(`function ${fnName}${fnParams} ${fn}`)
// fill in example textbox
var ex = `${fnName}('Hello world', 12)`
d3.select("#ex").text(`${ex}
// ${eval(ex)}`)
}
// This does the actual calculation
function calculate() {
var chars = []
var widths = []
// For "!" through "~"...
for (var i = 33; i < 127; i++) {
chars[i] = String.fromCharCode(i)
}
// Create element, measure bounding client rect, put in array
var letter = d3.select("#calibrate").selectAll("text.calibrate")
.data(chars)
.enter()
.append("text")
.classed("calibrate", true)
.text(d => d)
.each(function(d) {
var bb = this.getBoundingClientRect()
widths.push(bb.width)
})
// A naked space (charCode 32) doesn't take up any space, so, special case...
var space = d3.select("#calibrate").append("text")
.classed("calibrate", true)
.text("t t")
.each(function(d) {
var bb = this.getBoundingClientRect()
widths[32] = bb.width - 2 * widths["t".charCodeAt(0)]
})
// Clean up after self
d3.select("svg").selectAll("text.calibrate").remove()
// These are from 10px font; normalize to 1px, and return
return widths.map(d => d/10)
}
var demo
function startDemo() {
var samples = [
'Hello world',
'No DOM measurements in this demo',
'The quick brown hare trailed the tortoise in the limit',
'I am at the office eating a bagel',
'Toph',
'Notice the kerning-induced error above',
'That\'s my given name to myself by myself, see I see movies',
'ASCII only :(',
'The moré ‘Unicöde’ the worse the åpprøxîmátioñ'
]
var g = d3.select('#demo')
.selectAll('g')
.data(samples)
var gEnter = g.enter()
.append('g')
.each(function(str) {
var g = d3.select(this)
g.append('text')
.classed('str', true)
.text(str)
g.append('line')
.attr('marker-end', 'url(#mark)')
.attr('marker-start', 'url(#mark)')
g.append('text')
.classed('px', true)
.attr('dx', 5)
.attr('dy', 3.5)
})
g = g.merge(gEnter)
.attr('transform', (d,i) => `translate(30,${i * 50 + 50})`)
render()
clearInterval(demo)
demo = setInterval(render, 750)
function render() {
g.each(function(str) {
var randomSize = Math.random() * 40 + 10
var measure = measureText(str, randomSize)
var g = d3.select(this)
g.select("text.str")
.style('font-size', `${randomSize}px`)
g.select('line')
.attr('y1', -randomSize/3)
.attr('y2', -randomSize/3)
.attr('x2', measure)
g.select("text.px")
.attr('x', measure)
.attr('y', -randomSize/3)
.text(`${measure.toFixed(0)}px`)
})
}
}
function stopDemo() {
clearInterval(demo)
}
html, body {
width: 100%;
height: 100%;
margin: 0;
}
svg {
font-family: helvetica;
overflow: visible;
width: 100%;
height: 100%;
}
line {
stroke: violet;
}
#calibrate text {
font-size: 10px;
}
text.px {
fill: violet;
font-size: 10px;
}
#controls {
position: absolute;
background: rgba(255,255,255,.9);
border: 3px double black;
right: 10px;
top: 10px;
padding: 10px;
font-family: sans-serif;
font-size: 11px;
}
#controls form {
font-family: monospace;
}
#controls input {
width: 75px;
font-family: monospace;
}
#controls input[type="submit"] {
width: auto;
}
#controls p {
text-transform: uppercase;
margin-bottom: 5px;
color: gray;
}
#controls textarea {
width: 210px;
overflow: scroll;
margin: 0;
padding: 2px;
white-space: pre;
font-family: monospace;
}
#controls #output {
border: 1px solid #aca;
background: #efe;
height: 110px;
}
#controls #ex {
height: 30px;
}