Public
Edited
Sep 17, 2023
Insert cell
Insert cell
function Scrubber(values, {
format = value => value,
initial = 0,
direction = 1,
delay = null,
autoplay = true,
loop = true,
loopDelay = null,
alternate = false
} = {}) {
values = Array.from(values);
const form = html`<form style="font: 12px var(--sans-serif); font-variant-numeric: tabular-nums; display: flex; height: 33px; align-items: center;">
<button name=b type=button style="margin-right: 0.4em; width: 5em;"></button>
<label style="display: flex; align-items: center;">
<input name=i type=range min=0 max=${values.length - 1} value=${initial} step=1 style="width: 180px;">
<output name=o style="margin-left: 0.4em;"></output>
</label>
</form>`;
let frame = null;
let timer = null;
let interval = null;
function start() {
form.b.textContent = "Pause";
if (delay === null) frame = requestAnimationFrame(tick);
else interval = setInterval(tick, delay);
}
function stop() {
form.b.textContent = "Play";
if (frame !== null) cancelAnimationFrame(frame), frame = null;
if (timer !== null) clearTimeout(timer), timer = null;
if (interval !== null) clearInterval(interval), interval = null;
}
function running() {
return frame !== null || timer !== null || interval !== null;
}
function tick() {
if (form.i.valueAsNumber === (direction > 0 ? values.length - 1 : direction < 0 ? 0 : NaN)) {
if (!loop) return stop();
if (alternate) direction = -direction;
if (loopDelay !== null) {
if (frame !== null) cancelAnimationFrame(frame), frame = null;
if (interval !== null) clearInterval(interval), interval = null;
timer = setTimeout(() => (step(), start()), loopDelay);
return;
}
}
if (delay === null) frame = requestAnimationFrame(tick);
step();
}
function step() {
form.i.valueAsNumber = (form.i.valueAsNumber + direction + values.length) % values.length;
form.i.dispatchEvent(new CustomEvent("input", {bubbles: true}));
}
form.i.oninput = event => {
if (event && event.isTrusted && running()) stop();
form.value = values[form.i.valueAsNumber];
form.o.value = format(form.value, form.i.valueAsNumber, values);
};
form.b.onclick = () => {
if (running()) return stop();
direction = alternate && form.i.valueAsNumber === values.length - 1 ? -1 : 1;
form.i.valueAsNumber = (form.i.valueAsNumber + direction) % values.length;
form.i.dispatchEvent(new CustomEvent("input", {bubbles: true}));
start();
};
form.i.oninput();
if (autoplay) start();
else stop();
Inputs.disposal(form).then(stop);
return form;
}
Insert cell
function roundToTwoDecimal(value) {
return Math.round(value * 100) / 100;
}
Insert cell
Insert cell
data = FileAttachment("price (14).csv").csv().then(data => {
// Iterates over the dataset and converts the 'Month' field to a JavaScript Date object
// and converts the 'prices' field to a numeric datatype
data.forEach(d => {
const year = d.Month.slice(0, 4);
const month = d.Month.slice(5, 7);
d.Date = new Date(`${year}-${month}-01`);
d["prices"] = +d["prices"];
});
data = data.filter(d => !isNaN(d["prices"])); // Filter out entries with missing 'prices' values

// Sort data by Date and disaggregation
data.sort((a, b) => d3.ascending(a.Date, b.Date) || d3.ascending(a.disaggregation, b.disaggregation));

// Initialize previousPrices dictionary
let previousPrices = {};

// Initialize previousPrices values for each unique disaggregation
const uniqueDisaggregations = [...new Set(data.map(d => d.disaggregation))];
uniqueDisaggregations.forEach(disaggregation => {
previousPrices[disaggregation] = null;
});

// Compute percentage changes within each disaggregation
for (let i = 0; i < data.length; i++) {
if (previousPrices[data[i].disaggregation] !== null) {
data[i].percentageChange = roundToTwoDecimal(((data[i].prices / previousPrices[data[i].disaggregation]) - 1) * 100);
} else {
data[i].percentageChange = 0; // or you can set any other default value
}
previousPrices[data[i].disaggregation] = data[i].prices;
}

return data;
});

Insert cell
// Function to get the previous month of a given date
function getPreviousMonth(date) {
let newDate = new Date(date);
newDate.setMonth(date.getMonth() - 1);
return newDate;
}
Insert cell
function getPercentageChangeForDate(data, disaggregation, date) {
let record = data.find(d => d.disaggregation === disaggregation && +d.Date === +date);
return record ? roundToTwoDecimal(record.percentageChange) : null;
}
Insert cell
// Function to get label position based on disaggregation
function getLabelYPosition(disaggregation) {
switch(disaggregation) {
case "GOLD_PRICE": return 1.2; // Adjust based on your requirements
case "OIL_PRICE": return 1.4; // Adjust based on your requirements
case "COCOA_PRICE": return 1.6; // Adjust based on your requirements
default: return 0;
}
}

Insert cell
viewof dateslider = Scrubber(data.map((d) => d.Date), {
format: Plot.formatIsoDate,
initial: 1,
loop: false,
autoplay: false
})
Insert cell
function getEndDataPoints(data, dateslider) {
return data.filter(d => +d.Date === +dateslider);
}
Insert cell
{
const bisector = d3.bisector((i) => data[i].Date);
const basis = (I, Y) => Y[I[bisector.center(I, dateslider)]];
const formatDate = (date) => {
const currentMonth = d3.timeFormat("%b %Y")(date);
const previousMonth = d3.timeFormat("%b %Y")(getPreviousMonth(date));
return `${previousMonth} - ${currentMonth}`;
};

let relevantGoldPriceData = data.filter(d => d.disaggregation === "GOLD_PRICE" && +d.Date === +dateslider);
let relevantOilPriceData = data.filter(d => d.disaggregation === "OIL_PRICE" && +d.Date === +dateslider);
let relevantCocoaPriceData = data.filter(d => d.disaggregation === "COCOA_PRICE" && +d.Date === +dateslider);


// Plot the data
return Plot.plot({
marginTop: 20,
style: "overflow: visible; fontFamily: 'Lato'",

y: {
type: "log",
grid: true,
label: "Change in price (%)",
tickFormat: ((f) => (x) => f((x - 1) * 100))(d3.format("+d"))
},
marks: [
Plot.ruleY([1]),
Plot.ruleX([dateslider]),
Plot.lineY(data, Plot.normalizeY(basis, {
x: "Date",
y: "prices",
stroke: "disaggregation"
})),
// in your plot config
Plot.text(data, Plot.selectLast(Plot.normalizeY(basis, {
x: "Date",
y: "prices",
z: "disaggregation",
text: (d, i) => {
const change = getPercentageChangeForDate(data, d.disaggregation, dateslider);
const arrow = change > 0 ? "↑" : (change < 0 ? "↓" : "");
return `(${d.disaggregation}) ${formatDate(dateslider)} ${arrow} (${change}%)`;
},
fill: d => getPercentageChangeForDate(data, d.disaggregation, dateslider) > 0 ? "green" : "red",
textAnchor: "start",
dx: 3,
}))),
Plot.text(relevantGoldPriceData, {
px: d3.max(data, d => d.Date),
py: 6,
dy: -30,
textAnchor: "end",
frameAnchor: "top-right",
fontVariant: "tabular-nums",
text: (d) => {
const change = d.percentageChange;
const arrow = change > 0 ? "↑" : (change < 0 ? "↓" : "");
return [
`${d.disaggregation} in ${formatDate(dateslider)}`,
` ${arrow} (${change}%)`,
`(price: ${d.prices.toFixed(2)} per ounce in US$) `
].join(" ");
},
fill: d => d.percentageChange > 0 ? "green" : "red"
}),
Plot.text(relevantOilPriceData, {
px: d3.max(data, d => d.Date),
py: 6,
dy: -20,
textAnchor: "end",
frameAnchor: "top-right",
fontVariant: "tabular-nums",
text: (d) => {
const change = d.percentageChange;
const arrow = change > 0 ? "↑" : (change < 0 ? "↓" : "");
return [
`${d.disaggregation} in ${formatDate(dateslider)}`,
` ${arrow} (${change}%)`,
`(price: ${d.prices.toFixed(2)} per barrel in US$) `
].join(" ");
},
fill: d => d.percentageChange > 0 ? "green" : "red"
}),
Plot.text(relevantCocoaPriceData, {
px: d3.max(data, d => d.Date),
py: 6,
dy: -10,
textAnchor: "end",
frameAnchor: "top-right",
fontVariant: "tabular-nums",
text: (d) => {
const change = d.percentageChange;
const arrow = change > 0 ? "↑" : (change < 0 ? "↓" : "");
return [
`${d.disaggregation} in ${formatDate(dateslider)}`,
` ${arrow} (${change}%)`,
`(price: ${d.prices.toFixed(2)} per tonne in US$) `
].join(" ");
},
fill: d => d.percentageChange > 0 ? "green" : "red"
})

]
});
}
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