Using Observable Plot, I wish to recreate the https://merrysky.net weather timeline with colored bands and text labels.
I have gotten an initial version working, but it has some flaws:
Questions:
Of course, I am open to solutions using the underlying D3 API, as well.
Screenshot of current progress:
UPDATE: added simple example:
const data = [{
text: 'Heavy Freezing Drizzle',
utc: new Date('2024-06-16'), end: new Date('2024-06-17'),
}, {
text: 'Light Snow Showers',
utc: new Date('2024-06-17'), end: new Date('2024-06-18'),
}, {
text: 'Clear',
utc: new Date('2024-06-18'), end: new Date('2024-06-22'),
}, {
text: 'Thunderstorms with light hail',
utc: new Date('2024-06-22'), end: new Date('2024-06-23'),
}]
const plot = Plot.plot({
height: 100,
marks: [
Plot.rectY(data, {
x1: (d) => d.utc,
x2: (d) => d.end,
y: 1,
fill: (d) => d.text == 'Clear' ? 'lightgray': 'SkyBlue'
}),
Plot.text(data, {
x: (d) => (Number(d.utc) + Number(d.end))/2,
text: 'text'
}),
],
});
const div = document.querySelector('#myplot');
div.append(plot);
<!DOCTYPE html>
<script src="https://cdn.jsdelivr.net/npm/d3@7"></script>
<script src="https://cdn.jsdelivr.net/npm/@observablehq/[email protected]"></script>
"Clear" is the only text mark that should be rendered because the other text marks overflow their section rect marks.
<div id="myplot"></div>
You are essentially asking how to do overlap detection. Here's a quick algorithm that I stuffed into the same style "render" method we used in your last question. I've tried to comment it well:
<!DOCTYPE html>
<div id="myplot"></div>
<script src="https://cdn.jsdelivr.net/npm/d3@7"></script>
<script src="https://cdn.jsdelivr.net/npm/@observablehq/[email protected]"></script>
<script type="module">
const div = document.querySelector('#myplot');
const codes = ['tiny', 'very, very, very, very, very, large'];
const data = [];
for (let i = 0; i < 20; i++) {
data.push({
x: i,
y: Math.random() * 100,
code: codes[Math.floor(Math.random() * codes.length)] //<-- random label of varying size
});
}
const plot = Plot.plot({
marks: [
Plot.text(data, {
x: 'x',
y: 100,
textAnchor: 'start',
text: function (d) {
return d.code;
},
render: (i, s, v, d, c, next) => {
const g = next(i, s, v, d, c),
textLabels = d3.select(g).selectAll('text').nodes();
setTimeout(() => { //<-- run in setTimeout as we need the text elements to render to get their size
for (let i = 0; i < textLabels.length - 1; i++) { //<-- loop the labels
const cu = textLabels[i],
cuBBox = cu.getBoundingClientRect(); //<-- get the size of the current label
for (let j = i + 1; j < textLabels.length; j++) { //<-- loop the next N adjacent labels
const ne = textLabels[j],
neBBox = ne.getBoundingClientRect();
if (cuBBox.x + cuBBox.width > neBBox.x) {
d3.select(ne).remove(); //<-- if the adjacent label overlaps current, remove adjacent
} else {
i = j - 1; //<-- we've found the first adjacent that doesn't overlap, return to top loop and let this one become our next "current"
break;
}
}
}
},0);
return g;
},
}),
Plot.line(data, { x: 'x', y: 'y' }),
],
});
div.append(plot);
</script>
Edits Based on Comments
Here's a modification that "ellipses" the text based on the size of the sibling rect
s.
<!DOCTYPE html>
<script src="https://cdn.jsdelivr.net/npm/d3@7"></script>
<script src="https://cdn.jsdelivr.net/npm/@observablehq/[email protected]"></script>
"Clear" is the only text mark that should be rendered because the other text
marks overflow their section rect marks.
<div id="myplot"></div>
<script>
const data = [
{
text: 'Heavy Freezing Drizzle',
utc: new Date('2024-06-16'),
end: new Date('2024-06-17'),
},
{
text: 'Light Snow Showers',
utc: new Date('2024-06-17'),
end: new Date('2024-06-18'),
},
{
text: 'Clear',
utc: new Date('2024-06-18'),
end: new Date('2024-06-22'),
},
{
text: 'Thunderstorms with light hail',
utc: new Date('2024-06-22'),
end: new Date('2024-06-23'),
},
];
const plot = Plot.plot({
height: 100,
marks: [
Plot.rectY(data, {
x1: (d) => d.utc,
x2: (d) => d.end,
y: 1,
fill: (d) => (d.text == 'Clear' ? 'lightgray' : 'SkyBlue'),
}),
Plot.text(data, {
x: (d) => (Number(d.utc) + Number(d.end)) / 2,
text: 'text',
render: (i, s, v, d, c, next) => {
const g = next(i, s, v, d, c);
setTimeout(() => {
const textLabels = d3.select(g).selectAll('text').nodes(),
rects = d3.select(g.previousSibling).selectAll('rect').nodes();
for (let i = 0; i < textLabels.length; i++) {
const t = textLabels[i],
r = rects[i],
rW = r.getBBox().width;
let txt = v.text[i],
tW = t.getComputedTextLength();
while (tW > rW && txt.length > 0) {
txt = txt.slice(0, -1);
t.textContent = txt + "...";
tW = t.getComputedTextLength();
}
}
}, 10);
return g;
},
}),
],
});
const div = document.querySelector('#myplot');
div.append(plot);
</script>