Public
Edited
Apr 25, 2023
1 fork
Importers
8 stars
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
samplePlot = unitChart(disability, {
group: d => d["Group"],
value: "Total",
mark,

width: 600,
height: 300
})
Insert cell
Insert cell
Insert cell
sampleTextPlot = unitChart(sampleData, {
group: "kind",
value: "total",
mark: "text",
text: (group) => livestockEmoji[group],
fontSize: 24 // Optional
})
Insert cell
livestockEmoji = ({
"Heifers/Goat": "🐐",
Buffalo: "🐃",
Pig: "🐖",
Rabbit: "🐇",
Sheep: "🐑",
Cow: "🐄"
})
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
// Icons from https://tablericons.com/
images = ({
Others: await FileAttachment("others.svg").url(),
"Water and snow": await FileAttachment("water.svg").url(),
Croplands: await FileAttachment("cropland.svg").url(),
"Forest and grasslands": await FileAttachment("forest.svg").url(),
"Built-up": await FileAttachment("building@2.svg").url()
})
Insert cell
Insert cell
function unitChart(
data,
{
// Required
group,
value,

// Optional
width = 640,
height = 400,
margin = 10,
marginTop,
marginRight,
marginLeft,
marginBottom,
scheme = schemeCategory10,
formatLabel = (d) => `${accessor(group)(d)} (${d.__ratio.toFixed(1)}%)`,

cellPadding = 0.0333,

mark = "square", // "circle" | "text" | "image"
showLegend = true,
legendCellSize = width / 32,

// Options for text mark
text = (d) => d,
fontSize, // (number in px) Can be used set font size of text marks

// Options for image marq

src = function () {
throw "src not not passed in the options";
}
} = {}
) {
const getValue = accessor(value);
const getGroup = accessor(group);
const w = width;
const h = height ?? (showLegend ? (w * 2) / 3 : w);

marginTop = marginTop ?? margin;
marginRight = marginRight ?? margin;
marginBottom = marginBottom ?? margin;
marginLeft = marginLeft ?? margin;

const svg = d3
.create("svg")
.attr("viewBox", [0, 0, w, h])
.attr("width", w)
.attr("height", h)
.style("display", "block")
.style("background", "white")
.style("height", "auto")
.style("height", "intrinsic")
.style("max-width", "100%");

if (height) {
svg.style("width", w).style("height", h);
}

const boundedWidth = w - marginLeft - marginRight;
const boundedHeight = h - marginTop - marginBottom;
const plotSize = boundedWidth < boundedHeight ? boundedWidth : boundedHeight;

const scale = d3
.scaleBand()
.domain(d3.range(10))
.range([0, plotSize])
.paddingInner(cellPadding);

const color = d3.scaleOrdinal(scheme).domain(d3.range(data.length));

let dataCopy = [...data].sort((a, b) => getValue(b) - getValue(a));
const total = computeTotal(dataCopy, getValue);
dataCopy = computeRatio(dataCopy, getValue, total);

const plotData = computePlotData(dataCopy, getGroup);
const groups = [...new Set(plotData.map((d) => d.group))];

const cellSize = scale.bandwidth();
const halfCellSize = cellSize / 2;
const plot = svg
.append("g")
.attr("transform", `translate(${marginLeft},${marginTop})`);

const cells = plot
.selectAll(".cell")
.data(plotData)
.join("g")
.attr("fill", (d) => (d.index === -1 ? "#ddd" : color(d.index)));

if (mark === "circle") {
cells
.append("circle")
.attr("cx", (d) => scale(d.x) + halfCellSize)
.attr("cy", (d) => scale(d.y) + halfCellSize)
.attr("r", halfCellSize);
} else if (mark === "text") {
cells
.append("text")
.text((c) => text(c.group))
.attr("x", (d) => scale(d.x) + halfCellSize)
.attr("y", (d) => scale(d.y) + halfCellSize)
.attr("text-anchor", "middle")
.attr("dominant-baseline", "middle")
.attr("font-size", `${fontSize || cellSize * 0.5}px`);
} else if (mark === "image") {
cells
.append("image")
.attr("x", (d) => scale(d.x))
.attr("y", (d) => scale(d.y))
.attr("width", cellSize)
.attr("height", cellSize)
.attr("preserveAspectRatio", "xMinYMin")
.attr("href", (d) => src(d.group));
} else {
cells
.append("rect")
.attr("x", (d) => scale(d.x))
.attr("y", (d) => scale(d.y))
.attr("width", cellSize)
.attr("height", cellSize);
}

if (showLegend) {
const legend = svg
.append("g")
.attr(
"transform",
(d, i) => `translate(${marginLeft + cellSize + plotSize},${marginTop})`
)
.selectAll(".legend")
.data(dataCopy.filter((d) => groups.includes(getGroup(d))))
.join("g")
.attr("class", "legend")
.attr("transform", (d, i) => `translate(0,${i * legendCellSize * 1.5})`);

if (mark === "text") {
legend
.append("text")
.text((d) => text(getGroup(d)))
.attr("x", legendCellSize / 2) // 2
.attr("y", legendCellSize / 2) // 2
.attr("text-anchor", "middle")
.attr("dominant-baseline", "middle")
.attr("font-size", `${legendCellSize * 1.1}px`); // this controls the icon size; let's make it larger - and make corresponding text larger
} else if (mark === "image") {
legend
.append("image")
.attr("width", legendCellSize)
.attr("height", legendCellSize)
.attr("preserveAspectRatio", "xMinYMin")
.attr("href", (d) => src(getGroup(d)));
} else {
legend
.append("rect")
.attr("width", legendCellSize)
.attr("height", legendCellSize)
.attr("rx", (d) => (mark === "circle" ? legendCellSize : 0))
.attr("fill", (d, i) => color(i));
}
legend
.append("text")
.attr("x", legendCellSize * 1.5) //1.25
.attr("y", legendCellSize * 0.5) //0.5
.attr("dominant-baseline", "middle")
.text(formatLabel)
.style("font-family", fontFamily)
.style("font-size", `${legendCellSize * 0.8}px`);
}
return svg.node();
}
Insert cell
fontFamily = `-apple-system, BlinkMacSystemFont, "avenir next", avenir, helvetica, "helvetica neue", ubuntu, roboto, noto, "segoe ui", arial, sans-serif`
Insert cell
function computePlotData(chartData, getGroup) {
const data = chartData.map((d) => ({ ...d, cells: Math.round(d.__ratio) }));

const ratioTotal = computeTotal(data, accessor("__ratio"));

const waffle = [];
let dataIndex = 0;
let cellIndex = 1;

// One empty cell will be extra
// So add it to the to a candidate group
let hasExtraCell = ratioTotal < 100;
let candidateGroup;
let extraCellAdded = false;
if (hasExtraCell) {
candidateGroup = data
.map((d) => ({ ...d, diff: Math.abs(d.__ratio - d.cells) }))
.sort((a, b) => b.diff - a.diff);

candidateGroup = getGroup(candidateGroup[0]);
}
for (let y = 0; y < 10; y++)
for (let x = 0; x < 10; x++) {
// if (hasExtraCell && x == 0 && y == 0) continue;

if (!data[dataIndex]) {
waffle.push({ x, y, group: "Undefined", index: -1 });
break;
}

const group = getGroup(data[dataIndex]);

waffle.push({ x, y, group, index: dataIndex });
cellIndex++;
if (hasExtraCell && !extraCellAdded && candidateGroup === group) {
cellIndex--; // Do one extra pass, which will add one extra cell in this group
extraCellAdded = true;
continue;
}
if (cellIndex > data[dataIndex].cells) {
dataIndex++;
cellIndex = 1;
}
}

return waffle;
}
Insert cell
computeRatio = (arr, accessor, total) =>
arr.map((d) => ({
...d,
__ratio: (accessor(d) / total) * 100
}))
Insert cell
computeTotal = (arr, fn) => arr.reduce((sum, d) => sum + fn(d), 0)
Insert cell
// Data from district profile of Palpa, Nepal
// https://observablehq.com/@adb/nepal-cbs-2017-2018-district-profile-palpa#livestock
sampleData = [
{
total: 72973,
kind: "Cow"
},
{
total: 86696,
kind: "Buffalo"
},
{
total: 233716,
kind: "Heifers/Goat"
},
{
total: 2674,
kind: "Sheep"
},
{
total: 70896,
kind: "Pig"
},
{
total: 888,
kind: "Rabbit"
}
]
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
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