From 1409349f2eeec42a8ea32d46ee123e1904b3e6f7 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Tue, 13 Feb 2024 16:19:38 -0800 Subject: [PATCH] improved plot example (#782) * improved plot example * signDisplay: always --- examples/plot/docs/components/burndownPlot.js | 39 +++ examples/plot/docs/components/dailyPlot.js | 119 ++------- examples/plot/docs/components/revive.js | 13 + examples/plot/docs/components/trend.js | 31 +-- .../plot/docs/data/plot-github-issues.json.ts | 33 ++- .../plot/docs/data/plot-github-stars.csv.ts | 15 +- .../plot/docs/data/plot-npm-downloads.csv.ts | 23 +- .../plot/docs/data/plot-version-data.csv.ts | 12 +- examples/plot/docs/index.md | 228 +++++++++--------- 9 files changed, 231 insertions(+), 282 deletions(-) create mode 100644 examples/plot/docs/components/burndownPlot.js create mode 100644 examples/plot/docs/components/revive.js diff --git a/examples/plot/docs/components/burndownPlot.js b/examples/plot/docs/components/burndownPlot.js new file mode 100644 index 000000000..30a5117a5 --- /dev/null +++ b/examples/plot/docs/components/burndownPlot.js @@ -0,0 +1,39 @@ +import * as Plot from "npm:@observablehq/plot"; +import {resize} from "npm:@observablehq/stdlib"; +import * as d3 from "npm:d3"; + +export function BurndownPlot(issues, {x, round = true, ...options} = {}) { + const [start, end] = x.domain; + const days = d3.utcDay.range(start, end); + const burndown = issues.flatMap((issue) => + Array.from( + days.filter((d) => issue.created_at <= d && (!issue.closed_at || d < issue.closed_at)), + (d) => ({date: d, number: issue.number, created_at: issue.created_at}) + ) + ); + return resize((width) => + Plot.plot({ + width, + round, + x, + ...options, + marks: [ + Plot.axisY({anchor: "right", label: null}), + Plot.areaY( + burndown, + Plot.groupX( + {y: "count"}, + { + x: "date", + y: 1, + curve: "step-before", + fill: (d) => d3.utcMonth(d.created_at), + tip: {format: {z: null}} + } + ) + ), + Plot.ruleY([0]) + ] + }) + ); +} diff --git a/examples/plot/docs/components/dailyPlot.js b/examples/plot/docs/components/dailyPlot.js index 0229b2059..d388695b6 100644 --- a/examples/plot/docs/components/dailyPlot.js +++ b/examples/plot/docs/components/dailyPlot.js @@ -1,109 +1,38 @@ import * as Plot from "npm:@observablehq/plot"; -import * as d3 from "npm:d3"; -export const today = d3.utcDay(d3.utcHour.offset(d3.utcHour(), -10)); -export const start = d3.utcYear.offset(today, -2); - -export function DailyPlot(data, {title, label = title, domain, width, height = 200, versions} = {}) { +export function DailyPlot(data, {round = true, annotations, ...options} = {}) { return Plot.plot({ - width, - height, - round: true, - marginRight: 60, - x: {domain: [start, today]}, - y: {domain, label, insetTop: versions ? 60 : 0}, + ...options, + round, marks: [ - Plot.axisY({ - anchor: "right", - label: `${title} (line = 28-day, blue = 7-day)` - }), - Plot.areaY( - data, - Plot.filter((d) => d.date >= start, { - x: "date", - y: "value", - curve: "step", - fillOpacity: 0.2 - }) - ), + Plot.axisY({anchor: "right", label: null}), + Plot.areaY(data, {x: "date", y: "value", curve: "step", fillOpacity: 0.2}), Plot.ruleY([0]), Plot.lineY( data, - Plot.filter( - (d) => d.date >= start, - Plot.windowY( - {k: 7, anchor: "start", strict: true}, - { - x: "date", - y: "value", - strokeWidth: 1, - stroke: "var(--theme-foreground-focus)" - } - ) - ) - ), - Plot.lineY( - data, - Plot.filter( - (d) => d.date >= start, - Plot.windowY({k: 28, anchor: "start", strict: true}, {x: "date", y: "value"}) + Plot.windowY( + {k: 7, anchor: "start", strict: true}, + {x: "date", y: "value", stroke: "var(--theme-foreground-focus)"} ) ), - versionsMarks(versions), - Plot.tip( - data, - Plot.pointerX({ + Plot.lineY(data, Plot.windowY({k: 28, anchor: "start", strict: true}, {x: "date", y: "value"})), + annotations && [ + Plot.ruleX(annotations, {x: "date", strokeOpacity: 0.1}), + Plot.text(annotations, { x: "date", - y: "value", - format: {y: ",.0f"} + text: "text", + href: "href", + target: "_blank", + rotate: -90, + dx: -3, + frameAnchor: "top-right", + lineAnchor: "bottom", + fontVariant: "tabular-nums", + fill: "currentColor", + stroke: "var(--theme-background)" }) - ) + ], + Plot.tip(data, Plot.pointerX({x: "date", y: "value"})) ] }); } - -function versionsMarks(versions) { - if (!versions) return []; - const clip = true; - return [ - Plot.ruleX(versions, { - filter: (d) => !isPrerelease(d.version), - x: "date", - strokeOpacity: (d) => (isMajor(d.version) ? 1 : 0.1), - clip - }), - Plot.text(versions, { - filter: (d) => !isPrerelease(d.version) && !isMajor(d.version), - x: "date", - text: "version", - rotate: -90, - dx: -10, - frameAnchor: "top-right", - fontVariant: "tabular-nums", - fill: "currentColor", - stroke: "var(--theme-background)", - clip - }), - Plot.text(versions, { - filter: (d) => isMajor(d.version), - x: "date", - text: "version", - rotate: -90, - dx: -10, - frameAnchor: "top-right", - fontVariant: "tabular-nums", - fill: "currentColor", - stroke: "white", - fontWeight: "bold", - clip - }) - ]; -} - -function isPrerelease(version) { - return /-/.test(version); -} - -function isMajor(version) { - return /^\d+\.0\.0$/.test(version); -} diff --git a/examples/plot/docs/components/revive.js b/examples/plot/docs/components/revive.js new file mode 100644 index 000000000..9be5d19ad --- /dev/null +++ b/examples/plot/docs/components/revive.js @@ -0,0 +1,13 @@ +export function revive(object) { + if (object && typeof object === "object") { + for (const key in object) { + const value = object[key]; + if (value && typeof value === "object") { + revive(value); + } else if (typeof value === "string" && /^\d{4}-\d{2}-\d{2}T/.test(value)) { + object[key] = new Date(value); + } + } + } + return object; +} diff --git a/examples/plot/docs/components/trend.js b/examples/plot/docs/components/trend.js index 53ba00142..32fc8d10f 100644 --- a/examples/plot/docs/components/trend.js +++ b/examples/plot/docs/components/trend.js @@ -1,31 +1,20 @@ -import * as d3 from "npm:d3"; import {html} from "npm:htl"; -export function trend( - value /*: number */, +export function Trend( + value, { - format = "+d", + locale = "en-US", + format, positive = "green", negative = "red", base = "muted", positiveSuffix = " ↗︎", negativeSuffix = " ↘︎", baseSuffix = "" - } = {} /* - as { - format: string | ((x: number) => string); - positive: string; - negative: string; - base: string; - positiveSuffix: string; - negativeSuffix: string; - baseSuffix: string; - } - */ -) /*: Node */ { - if (typeof format === "string") format = d3.format(format); - if (typeof format !== "function") throw new Error(`unsupported format ${format}`); - return html`${format(value)}${ - value > 0 ? positiveSuffix : value < 0 ? negativeSuffix : baseSuffix - }`; + } = {} +) { + const variant = value > 0 ? positive : value < 0 ? negative : base; + const text = value.toLocaleString(locale, {signDisplay: "always", ...format}); + const suffix = value > 0 ? positiveSuffix : value < 0 ? negativeSuffix : baseSuffix; + return html`${text}${suffix}`; } diff --git a/examples/plot/docs/data/plot-github-issues.json.ts b/examples/plot/docs/data/plot-github-issues.json.ts index 8a0bb692e..1c88cefd1 100644 --- a/examples/plot/docs/data/plot-github-issues.json.ts +++ b/examples/plot/docs/data/plot-github-issues.json.ts @@ -1,25 +1,20 @@ import {githubList} from "./github.js"; -async function load(repo) { - process.stdout.write("["); - let first = true; +async function load(repo: string) { + const issues: any[] = []; for await (const item of githubList(`/repos/${repo}/issues?state=all`)) { - if (first) first = false; - else process.stdout.write(","); - process.stdout.write( - `\n ${JSON.stringify({ - state: item.state, - pull_request: !!item.pull_request, - created_at: item.created_at, - closed_at: item.closed_at, - draft: item.draft, - reactions: {...item.reactions, url: undefined}, - title: item.title, - number: item.number - })}` - ); + issues.push({ + state: item.state, + pull_request: !!item.pull_request, + created_at: item.created_at, + closed_at: item.closed_at, + draft: item.draft, + reactions: {...item.reactions, url: undefined}, + title: item.title, + number: item.number + }); } - process.stdout.write("\n]\n"); + return issues; } -await load("observablehq/plot"); +process.stdout.write(JSON.stringify(await load("observablehq/plot"))); diff --git a/examples/plot/docs/data/plot-github-stars.csv.ts b/examples/plot/docs/data/plot-github-stars.csv.ts index 1572ff640..19831bcac 100644 --- a/examples/plot/docs/data/plot-github-stars.csv.ts +++ b/examples/plot/docs/data/plot-github-stars.csv.ts @@ -1,9 +1,12 @@ -import {githubList} from "./github.js"; import {csvFormat} from "d3-dsv"; +import {githubList} from "./github.js"; -const repo = "observablehq/plot"; -const stars: any[] = []; -for await (const item of githubList(`/repos/${repo}/stargazers`, {accept: "application/vnd.github.star+json"})) - stars.push({starred_at: item.starred_at, login: item.user.login}); +async function load(repo: string) { + const stars: any[] = []; + for await (const item of githubList(`/repos/${repo}/stargazers`, {accept: "application/vnd.github.star+json"})) { + stars.push({starred_at: item.starred_at, login: item.user.login}); + } + return stars; +} -process.stdout.write(csvFormat(stars)); +process.stdout.write(csvFormat(await load("observablehq/plot"))); diff --git a/examples/plot/docs/data/plot-npm-downloads.csv.ts b/examples/plot/docs/data/plot-npm-downloads.csv.ts index c1fec0adf..5d8e4fd54 100644 --- a/examples/plot/docs/data/plot-npm-downloads.csv.ts +++ b/examples/plot/docs/data/plot-npm-downloads.csv.ts @@ -1,25 +1,24 @@ import {csvFormat} from "d3-dsv"; +import {json} from "d3-fetch"; import {timeDay, utcDay} from "d3-time"; -import {utcFormat} from "d3-time-format"; -async function load(project) { - const end = utcDay(timeDay()); // exclusive +function formatDate(date: Date): string { + return date.toISOString().slice(0, 10); +} + +async function load(project: string, start: Date, end: Date) { const data: any[] = []; - const formatDate = utcFormat("%Y-%m-%d"); - const min = new Date("2021-01-01"); let batchStart = end; let batchEnd; - while (batchStart > min) { + while (batchStart > start) { batchEnd = batchStart; batchStart = utcDay.offset(batchStart, -365); - if (batchStart < min) batchStart = min; - const response = await fetch( + if (batchStart < start) batchStart = start; + const batch = await json( `https://api.npmjs.org/downloads/range/${formatDate(batchStart)}:${formatDate( utcDay.offset(batchEnd, -1) )}/${project}` ); - if (!response.ok) throw new Error(`fetch failed: ${response.status}`); - const batch = await response.json(); for (const {downloads: value, day: date} of batch.downloads.reverse()) { data.push({date: new Date(date), value}); } @@ -28,10 +27,10 @@ async function load(project) { // trim zeroes at both ends do { if (data[0].value === 0) data.shift(); - else if (data.at(-1).value !== 0) data.pop(); + else if (data.at(-1)?.value !== 0) data.pop(); else return data; } while (data.length); throw new Error("empty dataset"); } -process.stdout.write(csvFormat(await load("@observablehq/plot"))); +process.stdout.write(csvFormat(await load("@observablehq/plot", new Date("2021-01-01"), utcDay(timeDay())))); diff --git a/examples/plot/docs/data/plot-version-data.csv.ts b/examples/plot/docs/data/plot-version-data.csv.ts index 01fce053b..e62d536d9 100644 --- a/examples/plot/docs/data/plot-version-data.csv.ts +++ b/examples/plot/docs/data/plot-version-data.csv.ts @@ -1,17 +1,11 @@ import {csvFormat} from "d3-dsv"; import {json} from "d3-fetch"; -async function load(project) { - const downloads = new Map( - Object.entries((await json(`https://api.npmjs.org/versions/${encodeURIComponent(project)}/last-week`)).downloads) - ); +async function load(project: string) { + const {downloads} = await json(`https://api.npmjs.org/versions/${encodeURIComponent(project)}/last-week`); const info = await json(`https://registry.npmjs.org/${encodeURIComponent(project)}`); return Object.values(info.versions as {version: string}[]) - .map(({version}) => ({ - version, - date: new Date(info.time[version]), - downloads: downloads.get(version) - })) + .map(({version}) => ({version, date: new Date(info.time[version]), downloads: downloads[version]})) .sort((a, b) => (a.date > b.date ? 1 : -1)); } diff --git a/examples/plot/docs/index.md b/examples/plot/docs/index.md index 054cc82e7..127720ca8 100644 --- a/examples/plot/docs/index.md +++ b/examples/plot/docs/index.md @@ -1,167 +1,155 @@ # Observable Plot downloads ```js -import {trend} from "./components/trend.js"; -import {DailyPlot, today, start} from "./components/dailyPlot.js"; +import {revive} from "./components/revive.js"; +import {Trend} from "./components/trend.js"; +import {BurndownPlot} from "./components/burndownPlot.js"; +import {DailyPlot} from "./components/dailyPlot.js"; ``` ```js const versions = FileAttachment("data/plot-version-data.csv").csv({typed: true}); const downloads = FileAttachment("data/plot-npm-downloads.csv").csv({typed: true}); -const issues = FileAttachment("data/plot-github-issues.json").json().then((data) => data.map((d) => (d.open = d3.utcDay(new Date(d.created_at)), d.close = d.closed_at ? d3.utcDay(new Date(d.closed_at)) : null, d))); +const issues = FileAttachment("data/plot-github-issues.json").json().then(revive); const stars = FileAttachment("data/plot-github-stars.csv").csv({typed: true}); ``` ```js -const lastMonth = d3.utcDay.offset(today, -28); -const lastWeek = d3.utcDay.offset(today, -7); -const x = {domain: [start, today]}; +// These dates are declared globally to ensure consistency across plots. +const end = d3.utcDay(d3.utcHour.offset(d3.utcHour(), -10)); +const start = d3.utcYear.offset(end, -2); +const lastMonth = d3.utcDay.offset(end, -28); +const lastWeek = d3.utcDay.offset(end, -7); +const x = {domain: [start, end]}; ``` -```js -const burndown = issues - .filter((d) => !d.pull_request) - .flatMap((issue) => Array.from(d3.utcDay.range( - d3.utcDay.offset(Math.max(start, issue.open), -1), - d3.utcDay.offset(issue.close ?? today, 2) - ), (date) => ({ - date, - id: issue.number, - open: issue.open, - title: `#${issue.number}: ${issue.title}\n\nOpened ${ - issue.open.toISOString().slice(0,10)}${ - issue.close ? `\nClosed ${issue.close.toISOString().slice(0,10)}` : "" - }` - }) - ) - ); -``` - -
- -
- ${resize((width, height) => DailyPlot(downloads, {width, height, title: "Daily npm downloads", label: "downloads", domain: [0, 6000], versions}))} +
+

Daily npm downloads

+

28d and 7d moving average

+ ${resize((width) => + DailyPlot(downloads, { + width, + marginRight: 40, + x, + y: {insetTop: 40, domain: [0, 6000], label: "downloads"}, + annotations: versions.filter((d) => !/-/.test(d.version)).map((d) => ({date: d.date, text: d.version, href: `https://github.com/observablehq/plot/releases/v${d.version}`})) + }) + )}
-
- ${resize((width) => Plot.plot({ - width, - caption: "Downloads per version (last 7 days)", - x: {label: null, tickFormat: "s", round: true, axis: "top"}, - y: {type: "band", reverse: true}, - marginTop: 20, - marginBottom: 0, - color: {type: "categorical", scheme: "ylgnbu"}, - marks: [ - Plot.barX(versions, { - x: "downloads", - stroke: "white", - strokeWidth: 0.5, - y: d => d.version.split(".").slice(0,2).join("."), - fill: d => d.version.split(".").slice(0,2).join("."), - tip: { - channels: { - version: "version", - released: d => `${d3.utcDay.count(d.date, Date.now())} days ago`, - downloads: "downloads", - }, - format: {fill: false, x: false, y: false} - } - }), - Plot.textX(versions, Plot.stackX({ - x: "downloads", - y: d => d.version.split(".").slice(0,2).join("."), - text: d => d.downloads > 500 ? d.version : null, - fill: "white", - stroke: d => d.version.split(".").slice(0,2).join("."), - strokeWidth: 5, - pointerEvents: null - })) - ] - }) -)} +
+

Weekly downloads by version

+

Last 7d, grouped by major version

+ ${resize((width) => + Plot.plot({ + width, + x: {label: null, round: true, axis: "top"}, + y: {type: "band", reverse: true}, + marginBottom: 0, + color: {type: "ordinal", scheme: "ylgnbu"}, + marks: [ + Plot.barX(versions, { + x: "downloads", + stroke: "white", + strokeWidth: 0.5, + y: (d) => d.version.split(".").slice(0, 2).join("."), + fill: (d) => d.version.split(".").slice(0, 2).join("."), + tip: { + channels: { + version: "version", + released: (d) => `${d3.utcDay.count(d.date, Date.now()).toLocaleString("en-US")} days ago`, + downloads: "downloads", + }, + format: {fill: false, x: false, y: false} + } + }), + Plot.ruleX([0]), + Plot.textX(versions, Plot.stackX({ + x: "downloads", + y: (d) => d.version.split(".").slice(0, 2).join("."), + text: (d) => d.downloads > 500 ? d.version : null, + fill: "white", + stroke: (d) => d.version.split(".").slice(0, 2).join("."), + strokeWidth: 5, + pointerEvents: null + })) + ] + }) + )}
-
- + +
-

Open PRs

- ${d3.sum(issues, (d) => d.pull_request && d.state === "open" && !d.draft).toLocaleString("en-US")} +

Open issues over time

+ ${BurndownPlot(issues.filter((d) => !d.pull_request), {x, color: {legend: true, label: "open month"}})}
-
-
${resize((width, height) => Plot.plot({ - width, - height, - marginLeft: 0, - marginRight: 30, - round: true, - x, - y: {axis: "right", grid: true, label: "↑ Open issues"}, - marks: [ - Plot.areaY(burndown, { - x: "date", - y: 1, - curve: "step-before", - fill: "open", - z: "id", - title: "title", - tip: true - }), - Plot.ruleY([0]) - ] - }))}
-
${ - Inputs.table( +
+
+ ${Inputs.table( issues .filter((d) => d.state === "open" && d.reactions.total_count > 5) .sort((a, b) => b.reactions.total_count - a.reactions.total_count) .map((d) => ({ "title": {title: d.title, number: d.number}, "reactions": d.reactions.total_count, - "days old": d3.utcDay.count(d.open, today) + "days old": d3.utcDay.count(d.created_at, end) })), { + width, + header: { + title: "Top issues" + }, format: { title: (d) => html`${d.title}` } } - ) - }
+ )} +