A stand-alone copy of Mike Bostock’s notebook, Walmart’s growth. See https://talk.observablehq.com/t/how-to-copy-and-paste-those-examples-to-my-webpages/7050/3 for details.
<html>
<head>
<title>Walmart’s growth</title>
<script src="https://unpkg.com/d3@7"></script>
<script src="https://unpkg.com/topojson-client@3"></script>
<script src="https://unpkg.com/htl@0.3"></script>
<script src="https://unpkg.com/@observablehq/inputs@0.10"></script>
</head>
<body>
<div id=container style="max-width: 960px"></div>
<div id=ui></div>
<script>
(async () => {
const projection = d3.geoAlbersUsa().scale(1280).translate([480, 300]);
const parseDate = d3.utcParse("%m/%d/%Y");
const data = (await d3.tsv("./walmart.tsv")).map(d => {
const p = projection(d);
p.date = parseDate(d.date);
return p;
}).sort((a, b) => a.date - b.date);
const us = await d3.json("https://cdn.jsdelivr.net/npm/us-atlas@1/us/10m.json");
us.objects.lower48 = {
type: "GeometryCollection",
geometries: us.objects.states.geometries.filter(d => d.id !== "02" && d.id !== "15")
};
// chart
const svg = d3.select("#container").append("svg")
.attr("viewBox", [0, 0, 960, 600]);
svg.append("path")
.datum(topojson.merge(us, us.objects.lower48.geometries))
.attr("fill", "#ddd")
.attr("d", d3.geoPath());
svg.append("path")
.datum(topojson.mesh(us, us.objects.lower48, (a, b) => a !== b))
.attr("fill", "none")
.attr("stroke", "white")
.attr("stroke-linejoin", "round")
.attr("d", d3.geoPath());
const g = svg.append("g")
.attr("fill", "none")
.attr("stroke", "black");
const dot = g.selectAll("circle")
.data(data)
.join("circle")
.attr("transform", d => `translate(${d})`);
svg.append("circle")
.attr("fill", "blue")
.attr("transform", `translate(${data[0]})`)
.attr("r", 3);
let previousDate = -Infinity;
function update(date) {
dot // enter
.filter(d => d.date > previousDate && d.date <= date)
.transition().attr("r", 3);
dot // exit
.filter(d => d.date <= previousDate && d.date > date)
.transition().attr("r", 0);
previousDate = date;
}
// UI
const scrubber = Scrubber(d3.utcWeek.every(2).range(...d3.extent(data, d => d.date)), { format: d3.utcFormat("%Y %b %-d"), loop: false });
scrubber.addEventListener("input", () => update(scrubber.value));
d3.select("#ui").append(() => scrubber);
})();
// https://observablehq.com/@mbostock/scrubber
const html = htl.html;
function Scrubber(values, {
format = value => value,
initial = 0,
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;
let direction = 1;
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;
}
</script>
</body>
</html>