Public
Edited
Jun 25, 2023
4 stars
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
function drawStripedCircle({
svg,
selection,
features,
centroids,
width,
height,
margin,
svgHeight,
svgWidth,
stroke = "#fff",
strokeWidth = 1.25,
fontFamily = "var(--sans-serif)",
debug
} = {}) {
const canvas = selection
.selectAll(".small-multiple-canvas")
.data(features)
.join("g")
.attr("class", "small-multiple-canvas")
.attr(
"transform",
(d, i) => `translate(${centroids[i][0]},${centroids[i][1]})`
);

const D = features.map((f) => {
const name = getFeatureName(f);
const d = getDataByDistrict(data, name);
const harvestedPercentage = Math.min(
(100 * d.harvestedArea) / d.sownArea,
100
);
return { ...d, name, harvestedPercentage };
});
const PRD = D.map((d) => d.production);
const PRDExtent = d3.extent(PRD);
const ID = D.map(
(d) => `stripped-pattern-${d.name.toLowerCase().replaceAll(" ", "-")}`
);

const rScale = d3.scaleSqrt().domain(PRDExtent).range([5, 30]);
const computeR = (d, i) => (PRD[i] === 0 ? 0 : rScale(PRD[i]));

D.forEach((d, i) => {
const { harvestedPercentage } = d;

makePattern(
d3.select(svg),
ID[i],
computeR(null, i) * 2,
[100 - harvestedPercentage, harvestedPercentage],
colorScale.range()
);
});

canvas
.append("circle")
.attr("r", computeR)
.attr("fill", (d, i) => `url(#${ID[i]})`)
.attr("stroke", stroke)
.attr("stroke-width", strokeWidth);

canvas
.append("circle")
.attr("fill", "transparent")
.attr("stroke", stroke)
.attr("stroke-width", strokeWidth)
.attr("r", computeR)
.append("title")
.text(
(d, i) => `${D[i].name}

Production: ${formatNumber(D[i].production)} metric tons

Area sown: ${formatNumber(D[i].sownArea)} acres
Area harvested: ${formatNumber(D[i].harvestedArea)} acres
Loss: ${formatPercentage(1 - D[i].harvestedPercentage / 100)}`
);

canvas
.append("text")
.attr("class", "district-label")
.attr("stroke-width", 0)
.attr("text-anchor", "middle")
.attr("dominant-baseline", "hanging")
.attr("paint-order", "stroke")
.attr("fill", "#222")
.attr("fill-opacity", (d, i) => (computeR(d, i) === 0 ? 0 : 1))
.attr("stroke", "#fff")
.attr("stroke-width", 3)
.attr("stroke-opacity", (d, i) => (computeR(d, i) === 0 ? 0 : 0.75))
.attr("style", "font-size: 0.7rem")
.attr("font-weight", "500")
.attr("dy", (d, i) => computeR(d, i) * 1.15)
.style("font-family", fontFamily)
.text((d) => getFeatureName(d));

if (debug) {
selection
.selectAll(".debug-small-multiple-canvas")
.data(features)
.join("g")
.attr("class", "debug-small-multiple-canvas")
.append("rect")
.attr("x", (d, i) => centroids[i][0] - width / 2)
.attr("y", (d, i) => centroids[i][1] - height / 2)
.attr("width", width)
.attr("height", height)
.attr("stroke", "#0ff")
.attr("fill", "none");
}

// Draw legend
const ticks = rScale.ticks(2);
const r = rScale(d3.max(ticks));
const radiusLegend = circleLegend({
scale: rScale,

marginTop: 0,
marginBottom: 0,
stroke: "#666",
strokeWidth: 0.75,
tickFormat: (d, i, g) => {
const str = rScale.tickFormat(4, "s")(d);

return i === g.length - 1 ? `${str} metric tons produced` : str;
},
tickFont: `10px ${fontFamily}`,
tickStrokeWidth: 0.75,
tickStroke: "#666"
});

selection
.append("g")
.attr("class", "radius-legend")
.attr("transform", `translate(${margin},${svgHeight - r * 2 - margin})`)
.node()
.appendChild(radiusLegend);

const sqSize = 15;
const sqGap = 6;
const colorLegendWidth = 120;
const colorLegendFontSize = 12;
const colorLegend = selection
.append("g")
.attr("class", "color-legend")
.attr(
"transform",
`translate(${svgWidth - colorLegendWidth - margin},${margin})`
);

const swatches = colorLegend
.selectAll(".swatch")
.data(colorScale.domain())
.join("g")
.attr("class", "swatch");

swatches
.append("rect")
.attr("width", sqSize)
.attr("height", sqSize)
.attr("y", (d, i) => i * (sqGap + sqSize))
.attr("fill", colorScale);

swatches
.append("text")
.attr("x", sqGap + sqSize)
.attr("y", (d, i) => i * (sqGap + sqSize))
.attr("dy", sqSize * 0.66)
.attr("font-size", colorLegendFontSize)
.attr("font-family", fontFamily)
.attr("dominant-baseline", "middle")
.text((d) => d);
}
Insert cell
formatNumber = d3.format(",.2f")
Insert cell
formatPercentage = d3.format(".2%")
Insert cell
colorScale = d3.scaleOrdinal(
["Area lost", "Area harvested"],
["hsl(176, 11%, 75%)", "hsl(209, 32%, 41%)"]
)
Insert cell
// import { makePattern } from "@jgaffuri/striped-circle"

// From: https://observablehq.com/@jgaffuri/striped-circle
makePattern = function (
svg,
id,
size,
percentages,
colors,
orientation = 0,
rotationPos = null,
withCorrection = true
) {
const frec = (v) => {
const t = Math.pow(6 * v, 1 / 3);
const t2 = t * t;
const t4 = t2 * t2;
const t6 = t4 * t2;
return (
(t * (1 - (1267 / 9428) * t2 + (31 / 6507) * t4 - (1 / 32473) * t6)) /
(1 - (2251 / 14902) * t2 + (63 / 9593) * t4 - (1 / 13890) * t6)
);
};

const d_ = (perc) => {
const theta = frec(perc * 2 * Math.PI);
return 0.5 * (1 - Math.cos(theta / 2));
};

const d = (perc) => {
if (perc > 0.5) return 1 - d_(1 - perc);
return d_(perc);
};

let defs = svg.select("defs");
if (defs.size() === 0) defs = svg.append("defs");

const pattern = defs
.append("pattern")
.attr("id", id)
.attr("patternUnits", "objectBoundingBox")
.attr("width", 1)
.attr("height", 1);

//set orientation
if (orientation) {
rotationPos = rotationPos || [0, 0];
pattern.attr(
"patternTransform",
"rotate(" +
orientation +
"," +
rotationPos[0] +
"," +
rotationPos[1] +
")"
);
}

//specify stripes
let cumPer = 0,
x0 = 0;
const corr = withCorrection ? d : (x) => x;
for (let i = 0; i < percentages.length; i++) {
cumPer = Math.min(1, cumPer + percentages[i] * 0.01);
const x1 = size * corr(cumPer);

pattern
.append("rect")
.attr("x", x0)
.attr("y", 0)
.attr("width", x1 - x0)
.attr("height", size)
.attr("fill", colors[i]);
x0 = x1;
}
}
Insert cell
function getDataByDistrict(data, district) {
return data.find((d) => d.dzongkhag === district);
}
Insert cell
getDataByDistrict(data, "Punakha")
Insert cell
data = {
const data = dataByCrop.reduce((obj, d, i) => {
const { dzongkhag, crop } = d;
if (obj[dzongkhag] === undefined) {
obj[dzongkhag] = { crop };
}

if (d.attribute.toLowerCase().includes("sown")) {
obj[dzongkhag].sownArea = d.value;
obj[dzongkhag].sownAreaUnit = d.unit;
} else if (d.attribute.toLowerCase().includes("harvested")) {
obj[dzongkhag].harvestedArea = d.value;
obj[dzongkhag].harvestedAreaUnit = d.unit;
} else if (d.attribute.toLowerCase().includes("production")) {
obj[dzongkhag].production = d.value;
obj[dzongkhag].productionUnit = d.unit;
}

return obj;
}, {});
return Object.entries(data).map(([dzongkhag, obj]) => {
let { sownArea, harvestedArea, harvestedAreaUnit } = obj;

sownArea = sownArea ?? harvestedArea;

const lostArea = sownArea - harvestedArea;
return {
dzongkhag,
sownArea,
lostArea,
lostAreaUnit: harvestedAreaUnit,
...obj
};
});
}
Insert cell
function mapWithFeatureWiseSmallMultiples({
// **
// Required
// **
features,
projection = d3.geoIdentity().reflectY(true),
getFeatureData = (d, i, arr) => {},
drawSmallMultiples = () => {},

// **
// Optional
// **
width = 640,
height = 400,
margin = 20,
smallMultipleWidth = 60,
smallMultipleHeight = 60,

featuresFill = "hsl(48, 65%, 90%)", //"hsl(48, 89%, 90%)",
featuresStroke = "#fff", // "hsl(294, 20%, 67%)", // stroke color for features
featuresStrokeWidth = 2, // stroke width for features

backgroundFill = "white",

debug
} = {}) {
// Adding margin to chart
const mapExtents = [
[margin, margin],
[width - margin, height - margin]
];
const smallMultipleInnerR =
Math.min(smallMultipleWidth, smallMultipleHeight) / 2;
const smallMultipleOuterR =
Math.min(smallMultipleWidth, smallMultipleHeight) * Math.SQRT2 * 0.5;

projection = projection.scale === undefined ? projection() : projection;

// https://github.com/d3/d3-geo/blob/main/README.md#projection_fitSize
projection.fitExtent(mapExtents, features);
projection.clipExtent(mapExtents);

const path = d3.geoPath(projection);

const F = Array.isArray(features)
? features.slice()
: features.features.slice();
const D = F.map(getFeatureData);

const C = F.map(computeLabelPole);
const CP = C.map(projection);
const CPNO = applyForce(CP, {
width,
height,
radius: smallMultipleOuterR,
padding: smallMultipleOuterR / 20
});

const svg = DOM.svg(width, height);
d3.select(svg).style("background", backgroundFill);

// Draw features
const boundaries = d3
.select(svg)
.append("g")
.attr("class", "features")
.selectAll(".feature")
.data(F)
.join("path")
.attr("class", "feature")
.attr("d", path)
.attr("fill", featuresFill)
.attr("stroke", featuresStroke)
.attr("stroke-width", featuresStrokeWidth)
.attr("stroke-linejoin", "round")
.attr("stroke-linecap", "round");

// Draw small multiples
const smallMultiples = d3
.select(svg)
.append("g")
.attr("class", "small-multiples");
drawSmallMultiples({
svg,
selection: smallMultiples,
features: F,
centroids: CPNO,
width: smallMultipleWidth,
height: smallMultipleHeight,
svgHeight: height,
svgWidth: width,
margin,
debug
});

// Draw debug elements
if (debug) {
const debugElements = d3.select(svg).append("g").attr("class", "debug");

const smMultiElements = debugElements
.selectAll(".debug-sm-multiples-og")
.data(CP)
.join("g")
.attr("class", "debug-sm-multiples-og");

smMultiElements
.append("circle")
.attr("r", 2)
.attr("cx", (d) => d[0])
.attr("cy", (d) => d[1])
.attr("fill", "#ff0");

const smMultiNonOverlappingElements = debugElements
.selectAll(".debug-sm-multiples")
.data(CPNO)
.join("g")
.attr("class", "debug-sm-multiples");
smMultiNonOverlappingElements
.append("circle")
.attr("r", 2)
.attr("cx", (d) => d[0])
.attr("cy", (d) => d[1])
.attr("fill", "#f0f");
smMultiNonOverlappingElements
.append("circle")
.attr("r", smallMultipleOuterR)
.attr("cx", (d) => d[0])
.attr("cy", (d) => d[1])
.attr("stroke", "#f0f")
.attr("fill", "none");
// smMultiNonOverlappingElements
// .append("rect")
// .attr("x", (d) => d[0] - smallMultipleWidth / 2)
// .attr("y", (d) => d[1] - smallMultipleHeight / 2)
// .attr("width", smallMultipleWidth)
// .attr("height", smallMultipleHeight)
// .attr("stroke", "#f0f")
// .attr("fill", "none");
}

return svg;
}
Insert cell
Insert cell
dataStore = new Map([
["Cereals", tidyAgriData(cereals)],
["Oilseeds & Legumes", tidyAgriData(oilseeds_and_legumes)],
["Vegetables", tidyAgriData(vegetables)],
["Spices", tidyAgriData(spices)],
["Roots and tubers", tidyAgriData(roots_and_tubers)],
["Fruits", tidyAgriData(fruits)]
])
Insert cell
keyRegex = /(?<crop>.*) - (?<attribute>.*) \((?<unit>.*)\)/
Insert cell
function tidyAgriData(arr) {
return arr.map(tidyAgriDataObject).flat();
}
Insert cell
function tidyAgriDataObject(obj) {
const { Dzongkhag, ...rest } = obj;

const facts = Object.entries(rest)
.map(([k, v]) => {
const match = k.match(keyRegex);
if (match) {
const { crop, attribute, unit } = match.groups;
return { dzongkhag: Dzongkhag, crop, attribute, unit, value: v };
}
})
.filter(Boolean);
return facts;
}
Insert cell
Insert cell
Insert cell
Insert cell
function normalizeWinding(features) {
return rewind({ ...features }, true);
}
Insert cell
function computeLabelPole(feat, precision) {
if (feat.geometry.type === "Polygon") {
return polylabel(feat.geometry.coordinates, precision);
}

return d3.geoCentroid(feat);
}
Insert cell
getFeatureName(bhutan_dzongkhags_geo.features[0])
Insert cell
function getFeatureName(f) {
return f?.properties?.NAME_1;
}
Insert cell
Insert cell
import {
cereals,
oilseeds_and_legumes,
vegetables,
spices,
roots_and_tubers,
fruits
} from "@aaronkyle/bhutan-agriculture-statistics-2021"
Insert cell
Insert cell
bhutan_dzongkhags_geo = geotoolbox.map(gadm_BTN_admin1, (d) => {
let { NAME_1 } = d;

const namesSubstitutions = new Map([
["Chhukha", "Chukha"],
["Monggar", "Mongar"],
["Pemagatshel", "Pema Gatshel"],
["Samdrupjongkhar", "Samdrup Jongkhar"],
["Yangtse", "Trashi Yangtse"],
["Wangduephodrang", "Wangdue Phodrang"]
]);
return { ...d, NAME_1: namesSubstitutions.get(NAME_1) || NAME_1 };
})
Insert cell
import { rewind } from "@fil/rewind"
Insert cell
polylabel = (await import("https://cdn.skypack.dev/polylabel@1.1.0?min"))
.default
Insert cell
geotoolbox = require("geotoolbox@1.9.7/dist/index.min.js")
Insert cell
import { circleLegend } from "@bayre/circle-legend"
Insert cell
import { footer } from "@saneef/notebooks-footer"
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