From c2b5c5180744174c378d57e61f7bdedc2d7f5b9f Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Mon, 12 Feb 2024 08:57:49 -0800 Subject: [PATCH 1/8] checkpoint google-analytics edits --- .../docs/components/formatTrend.js | 21 ++ .../docs/components/horizonChart.js | 49 +++ .../docs/components/lineChart.js | 14 + .../docs/components/marimekko.js | 29 -- .../docs/components/marimekkoChart.js | 89 +++++ .../docs/components/punchcardChart.js | 52 +++ .../google-analytics/docs/components/trend.js | 31 -- .../docs/components/worldMap.js | 44 +++ examples/google-analytics/docs/index.md | 326 ++++-------------- 9 files changed, 337 insertions(+), 318 deletions(-) create mode 100644 examples/google-analytics/docs/components/formatTrend.js create mode 100644 examples/google-analytics/docs/components/horizonChart.js create mode 100644 examples/google-analytics/docs/components/lineChart.js delete mode 100644 examples/google-analytics/docs/components/marimekko.js create mode 100644 examples/google-analytics/docs/components/marimekkoChart.js create mode 100644 examples/google-analytics/docs/components/punchcardChart.js delete mode 100644 examples/google-analytics/docs/components/trend.js create mode 100644 examples/google-analytics/docs/components/worldMap.js diff --git a/examples/google-analytics/docs/components/formatTrend.js b/examples/google-analytics/docs/components/formatTrend.js new file mode 100644 index 000000000..822ea71d3 --- /dev/null +++ b/examples/google-analytics/docs/components/formatTrend.js @@ -0,0 +1,21 @@ +import {html} from "npm:htl"; + +export function formatTrend( + value, + { + locale, + format = {}, + positive = "green", + negative = "red", + base = "muted", + positiveSuffix = " ↗︎", + negativeSuffix = " ↘︎", + baseSuffix = "" + } = {} +) { + if (format.signDisplay === undefined) format = {...format, signDisplay: "always"}; + return html`${value.toLocaleString( + locale, + format + )}${value > 0 ? positiveSuffix : value < 0 ? negativeSuffix : baseSuffix}`; +} diff --git a/examples/google-analytics/docs/components/horizonChart.js b/examples/google-analytics/docs/components/horizonChart.js new file mode 100644 index 000000000..37c32f4b1 --- /dev/null +++ b/examples/google-analytics/docs/components/horizonChart.js @@ -0,0 +1,49 @@ +import * as Plot from "npm:@observablehq/plot"; +import * as d3 from "npm:d3"; + +const bands = 5; +const opacityScale = d3.scaleLinear().domain([0, bands]).range([0.15, 0.85]); + +export function horizonChart(data, {width, height, metric, title, caption, format, z, color, order}) { + const step = d3.max(data, (d) => d[metric]) / bands; + return Plot.plot({ + width, + height, + subtitle: title, + caption, + axis: null, + // marginTop: 20, + marginLeft: 10, + marginRight: 10, + color, + y: {domain: [0, step]}, + x: {axis: true, domain: [new Date("2023-04-01"), new Date("2023-12-31")]}, + fy: {axis: null, domain: order, padding: 0.05}, + facet: {data, y: z}, + marks: [ + d3.range(bands).map((i) => + Plot.areaY(data, { + x: "date", + y: (d) => d[metric] - i * step, + fill: z, + fillOpacity: opacityScale(i), + clip: true + }) + ), + Plot.tip(data, Plot.pointerX({x: "date", channels: {users: metric}, format: {fy: false}})), + Plot.text( + data, + Plot.selectFirst({ + text: z, + fontSize: 12, + frameAnchor: "top-left", + dx: 6, + dy: 6, + stroke: "var(--theme-background)", + paintOrder: "stroke", + fill: "currentColor" + }) + ) + ] + }); +} diff --git a/examples/google-analytics/docs/components/lineChart.js b/examples/google-analytics/docs/components/lineChart.js new file mode 100644 index 000000000..b27d12d29 --- /dev/null +++ b/examples/google-analytics/docs/components/lineChart.js @@ -0,0 +1,14 @@ +import * as Plot from "npm:@observablehq/plot"; + +export function lineChart(data, {width, height = 94, x = "date", y, percent} = {}) { + return Plot.plot({ + width, + height, + axis: null, + insetTop: 10, + insetLeft: -15, + insetRight: -17, + y: {zero: true, percent, domain: percent ? [0, 100] : undefined}, + marks: [Plot.areaY(data, {x, y, fillOpacity: 0.25}), Plot.lineY(data, {x, y, tip: true})] + }); +} diff --git a/examples/google-analytics/docs/components/marimekko.js b/examples/google-analytics/docs/components/marimekko.js deleted file mode 100644 index 3df91ab64..000000000 --- a/examples/google-analytics/docs/components/marimekko.js +++ /dev/null @@ -1,29 +0,0 @@ -import * as Plot from "npm:@observablehq/plot"; -import * as d3 from "npm:d3"; - -export function Marimekko({x, y, z, value = z, anchor = "middle", inset = 0.5, ...options} = {}) { - const stackX = /\bleft$/i.test(anchor) ? Plot.stackX1 : /\bright$/i.test(anchor) ? Plot.stackX2 : Plot.stackX; - const stackY = /^top\b/i.test(anchor) ? Plot.stackY2 : /^bottom\b/i.test(anchor) ? Plot.stackY1 : Plot.stackY; - const [X, setX] = Plot.column(x); - const [Y, setY] = Plot.column(y); - const [Xv, setXv] = Plot.column(value); - const {x: Xs, x1, x2, transform: tx} = stackX({offset: "expand", y: Y, x: Xv, z: X, order: "appearance"}); - const {y: Ys, y1, y2, transform: ty} = stackY({offset: "expand", x, y: value, z: Y, order: "appearance"}); - return Plot.transform({x: Xs, x1, x2, y: Ys, y1, y2, z, inset, frameAnchor: anchor, ...options}, (data, facets) => { - const X = setX(Plot.valueof(data, x)); - setY(Plot.valueof(data, y)); - const Xv = setXv(new Float64Array(data.length)); - const Z = Plot.valueof(data, value); - for (const I of facets) { - const sum = d3.rollup( - I, - (J) => d3.sum(J, (i) => Z[i]), - (i) => X[i] - ); - for (const i of I) Xv[i] = sum.get(X[i]); - } - tx(data, facets); - ty(data, facets); - return {data, facets}; - }); -} diff --git a/examples/google-analytics/docs/components/marimekkoChart.js b/examples/google-analytics/docs/components/marimekkoChart.js new file mode 100644 index 000000000..027b87529 --- /dev/null +++ b/examples/google-analytics/docs/components/marimekkoChart.js @@ -0,0 +1,89 @@ +import * as Plot from "npm:@observablehq/plot"; +import * as d3 from "npm:d3"; + +export function marimekkoChart(data, {width, height, xDim, yDim, metric, title, caption, color}) { + const xy = (options) => Marimekko({...options, x: xDim, y: yDim, value: metric}); + return Plot.plot({ + width, + height, + subtitle: title, + caption, + label: null, + x: {percent: true, ticks: 10, tickFormat: (d) => (d === 100 ? `100%` : d)}, + y: {percent: true, ticks: 10, tickFormat: (d) => (d === 100 ? `100%` : d)}, + color, + marks: [ + Plot.text( + data, + xy({ + text: (d) => d[metric].toLocaleString("en"), + fontSize: 14, + fontWeight: 600, + stroke: yDim, + fill: "var(--theme-background)" + }) + ), + Plot.rect(data, xy({fill: yDim, fillOpacity: 1})), + Plot.frame({fill: "var(--theme-background)", fillOpacity: 0.2}), + Plot.text( + data, + xy({ + text: yDim, + fontSize: 11, + dy: -16, + fill: "var(--theme-background)" + }) + ), + Plot.text( + data, + xy({ + text: (d) => d[metric].toLocaleString("en"), + fontSize: 14, + fontWeight: 600, + fill: "var(--theme-background)" + }) + ), + Plot.text( + data, + Plot.selectMaxY( + xy({ + z: xDim, + text: (d) => `${d[xDim].slice(0, 1).toUpperCase()}${d[xDim].slice(1)}`, + anchor: "top", + lineAnchor: "bottom", + fontSize: 12, + dy: -6 + }) + ) + ) + ] + }); +} + +// TODO attribution +function Marimekko({x, y, z, value = z, anchor = "middle", inset = 0.5, ...options} = {}) { + const stackX = /\bleft$/i.test(anchor) ? Plot.stackX1 : /\bright$/i.test(anchor) ? Plot.stackX2 : Plot.stackX; + const stackY = /^top\b/i.test(anchor) ? Plot.stackY2 : /^bottom\b/i.test(anchor) ? Plot.stackY1 : Plot.stackY; + const [X, setX] = Plot.column(x); + const [Y, setY] = Plot.column(y); + const [Xv, setXv] = Plot.column(value); + const {x: Xs, x1, x2, transform: tx} = stackX({offset: "expand", y: Y, x: Xv, z: X, order: "appearance"}); + const {y: Ys, y1, y2, transform: ty} = stackY({offset: "expand", x, y: value, z: Y, order: "appearance"}); + return Plot.transform({x: Xs, x1, x2, y: Ys, y1, y2, z, inset, frameAnchor: anchor, ...options}, (data, facets) => { + const X = setX(Plot.valueof(data, x)); + setY(Plot.valueof(data, y)); + const Xv = setXv(new Float64Array(data.length)); + const Z = Plot.valueof(data, value); + for (const I of facets) { + const sum = d3.rollup( + I, + (J) => d3.sum(J, (i) => Z[i]), + (i) => X[i] + ); + for (const i of I) Xv[i] = sum.get(X[i]); + } + tx(data, facets); + ty(data, facets); + return {data, facets}; + }); +} diff --git a/examples/google-analytics/docs/components/punchcardChart.js b/examples/google-analytics/docs/components/punchcardChart.js new file mode 100644 index 000000000..6120c58df --- /dev/null +++ b/examples/google-analytics/docs/components/punchcardChart.js @@ -0,0 +1,52 @@ +import * as Plot from "npm:@observablehq/plot"; +import * as d3 from "npm:d3"; + +export function punchcardChart(data, {width, height, label} = {}) { + const aggregatedValues = d3 + .rollups( + data, + (v) => d3.median(v, (d) => d.activeUsers), + (d) => d.hour, + (d) => d.dayOfWeek + ) + .flatMap((d) => d[1].map((d) => d[1])); + + return Plot.plot({ + width, + height, + inset: 12, + padding: 0, + marginBottom: 10, + grid: true, + round: false, + label: null, + x: { + axis: "top", + domain: d3.range(24), + interval: 1, + tickFormat: (d) => (d % 12 || 12) + (d === 0 ? " AM" : d === 12 ? " PM" : "") + }, + y: { + domain: ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"], + tickFormat: (d) => d.substr(0, 3) + }, + r: {label, range: [1, 20], domain: d3.extent(aggregatedValues)}, + marks: [ + Plot.dot( + data, + Plot.group( + {r: "median"}, + { + y: "dayOfWeek", + x: "hour", + r: "activeUsers", + fill: "currentColor", + stroke: "var(--theme-background)", + sort: null, + tip: true + } + ) + ) + ] + }); +} diff --git a/examples/google-analytics/docs/components/trend.js b/examples/google-analytics/docs/components/trend.js deleted file mode 100644 index 53ba00142..000000000 --- a/examples/google-analytics/docs/components/trend.js +++ /dev/null @@ -1,31 +0,0 @@ -import * as d3 from "npm:d3"; -import {html} from "npm:htl"; - -export function trend( - value /*: number */, - { - format = "+d", - 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 - }`; -} diff --git a/examples/google-analytics/docs/components/worldMap.js b/examples/google-analytics/docs/components/worldMap.js new file mode 100644 index 000000000..4ea4626e7 --- /dev/null +++ b/examples/google-analytics/docs/components/worldMap.js @@ -0,0 +1,44 @@ +import * as Plot from "npm:@observablehq/plot"; +import {FileAttachment} from "npm:@observablehq/stdlib"; +// import * as d3 from "npm:d3"; +import * as topojson from "npm:topojson-client"; + +const world = await FileAttachment("../data/countries-110m.json").json(); + +// const countryLookup = d3.rollup( +// countries, +// (v) => v[0].engagementRate, +// (d) => (d.country === "United States" ? "United States of America" : d.country) +// ); + +const countryShapes = topojson.feature(world, world.objects.countries); +// const countryData = countryShapes.features.map((d) => ({...d, value: countryLookup.get(d.properties.name)})); + +export function worldMap(data, {width, height, title, caption} = {}) { + return Plot.plot({ + width, + height, + caption, + projection: "equal-earth", + color: { + scheme: "cool", + legend: true, + label: "Engagement rate", + tickFormat: "%" + }, + marks: [ + Plot.graticule(), + Plot.geo(countryShapes, { + fill: "var(--theme-foreground-fainter)", + stroke: "var(--theme-foreground)", + strokeWidth: 0.25 + }), + Plot.geo(countryShapes, { + fill: "value", + stroke: "var(--theme-foreground)", + strokeWidth: 0.5 + }), + Plot.sphere() + ] + }); +} diff --git a/examples/google-analytics/docs/index.md b/examples/google-analytics/docs/index.md index 664a2f4e1..4b21f10c2 100644 --- a/examples/google-analytics/docs/index.md +++ b/examples/google-analytics/docs/index.md @@ -6,309 +6,119 @@ const hourly = FileAttachment("data/google-analytics-time-of-day.csv").csv({type const channels = FileAttachment("data/google-analytics-channels.csv").csv({typed: true}); const channelBreakdown = FileAttachment("data/google-analytics-channel-breakdown.csv").csv({typed: true}); const countries = FileAttachment("data/google-analytics-country.csv").csv({typed: true}); -const world = FileAttachment("data/countries-110m.json").json(); ``` ```js -import {svg} from "npm:htl"; -import {Marimekko} from "./components/marimekko.js"; -import {trend} from "./components/trend.js"; +import {formatTrend} from "./components/formatTrend.js"; +import {horizonChart} from "./components/horizonChart.js"; +import {lineChart} from "./components/lineChart.js"; +import {marimekkoChart} from "./components/marimekkoChart.js"; +import {punchcardChart} from "./components/punchcardChart.js"; +import {worldMap} from "./components/worldMap.js"; ``` ```js -const bigPercent = d3.format(".0%"); -const bigNumber = d3.format(".3s"); -const date = d3.utcFormat("%m/%d/%Y"); const color = Plot.scale({ color: { domain: ["", "Organic Search", "Direct", "Referral", "Organic Social", "Unassigned"] } }); -const bands = 5; -const opacityScale = d3.scaleLinear().domain([0, bands]).range([0.15, 0.85]); const filteredChannelBreakdown = channelBreakdown .filter((d) => color.domain.includes(d.channelGroup) && d.type != "Unknown" && d.channelGroup !== 'Unassigned') .sort((a, b) => color.domain.indexOf(b.channelGroup) - color.domain.indexOf(a.channelGroup)); -const countryLookup = d3.rollup( - countries, - (v) => v[0].engagementRate, - (d) => (d.country === "United States" ? "United States of America" : d.country) -); - -const countryShapes = topojson.feature(world, world.objects.countries); -const countryData = countryShapes.features.map((d) => ({...d, value: countryLookup.get(d.properties.name)})); - function getCompareValue(data, metric) { const maxDate = data[data.length - 1].date; const compareDate = d3.utcDay.offset(maxDate, -1); - const match = data.find(d => date(d.date) === date(compareDate))[metric]; + const match = data.find((d) => +d.date === +compareDate)[metric]; return data[data.length - 1][metric] - match; } ``` -```js -function lineChart(data, {width, height, metric}) { - return Plot.plot({ - width, - height: 94, - axis: null, - insetTop: 10, - insetLeft: -15, - insetRight: -16.5, - marks: [ - Plot.ruleY([0]), - Plot.lineY(data, { - x: "date", - y: metric, - tip: true, - }) - ] - }); -} + -function horizonChart(data, {width, height, metric, title, caption, format, z, color, order}) { - const step = d3.max(data, (d) => d[metric]) / bands; - return Plot.plot({ - width, - height: height - 40, - subtitle: title, - caption, - axis: null, - marginTop: 20, - color, - y: {domain: [0, step]}, - x: {axis: true}, - fy: {axis: null, domain: order, padding: 0.05}, - facet: {data, y: z}, - marks: [ - d3.range(bands).map((i) => - Plot.areaY(data, { - x: "date", - y: (d) => d[metric] - i * step, - fill: z, - fillOpacity: opacityScale(i), - clip: true - }) - ), - Plot.tip(data, Plot.pointerX({x: "date", channels: {users: metric}, format: {fy: false}})), - Plot.text( - data, - Plot.selectFirst({ - text: z, - fontSize: 12, - frameAnchor: "top-left", - dx: 6, - dy: 6, - stroke: "var(--theme-background)", - paintOrder: "stroke", - fill: "currentColor" - }) - ) - ] +```js +function generateValue(target, defaultValue) { + return Generators.observe((notify) => { + const changed = ({target}) => notify(target.value ?? defaultValue); + if (defaultValue !== undefined) notify(defaultValue); + target.addEventListener("input", changed); + return () => target.removeEventListener("input", changed); }); } -function Punchcard(data, {width, height, label}) { - const aggregatedValues = d3 - .rollups(data, (v) => d3.median(v, (d) => d.activeUsers), (d) => d.hour, (d) => d.dayOfWeek) - .flatMap((d) => d[1].map((d) => d[1])); - - return Plot.plot({ - caption: `${label.slice(0, 1).toUpperCase()}${label.slice(1)} per day and hour of week`, - width, - height: height - 10, - inset: 12, - padding: 0, - marginBottom: 10, - grid: true, - round: false, - label: null, - x: { - axis: "top", - domain: d3.range(24), - interval: 1, - tickFormat: (d) => (d % 12 || 12) + (d === 0 ? " AM" : d === 12 ? " PM" : "") - }, - y: { - domain: ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"], - tickFormat: (d) => d.substr(0, 3) - }, - r: {label, range: [1, 20], domain: d3.extent(aggregatedValues)}, - marks: [ - Plot.dot( - data, - Plot.group( - {r: "median"}, - { - y: "dayOfWeek", - x: "hour", - r: "activeUsers", - fill: "currentColor", - stroke: "var(--theme-background)", - sort: null, - tip: true - } - ) - ) - ] - }); -} +const activeUsersChart = resize((width) => lineChart(summary, {width, y: "active28d"})); +const activeUsers = generateValue(activeUsersChart, summary[summary.length - 1]); +const engagementRateChart = resize((width) => lineChart(summary, {width, y: "engagementRate", percent: true})); +const engagementRate = generateValue(engagementRateChart, summary[summary.length - 1]); +const wauPerMauChart = resize((width) => lineChart(summary, {width, y: "wauPerMau", percent: true})); +const wauPerMau = generateValue(wauPerMauChart, summary[summary.length - 1]); +const engagedSessionsChart = resize((width) => lineChart(summary, {width, y: "engagedSessions"})); +const engagedSessions = generateValue(engagedSessionsChart, summary[summary.length - 1]); +``` -function worldMap(data, {width, height, title, caption}) { - return Plot.plot({ - width, - height: height - 60, - caption, - projection: "equal-earth", - color: { - scheme: "cool", - legend: true, - label: "Engagement rate", - tickFormat: "%" - }, - marks: [ - Plot.graticule(), - Plot.geo(data, { - fill: "var(--theme-foreground-fainter)", - stroke: "var(--theme-foreground)", - strokeWidth: 0.25 - }), - Plot.geo(data, { - fill: "value", - stroke: "var(--theme-foreground)", - strokeWidth: 0.5 - }), - Plot.sphere() - ] - }); +```js +function trendNumber(data, focusData, metric, options) { + const focusIndex = data.findIndex((d) => d === focusData); + return formatTrend(focusData[metric] - data[focusIndex - 1]?.[metric], options); } ``` -_Summary of metrics from the [Google Analytics Data API](https://developers.google.com/analytics/devguides/reporting/data/v1/quickstart-client-libraries), a sample of data pulled on ${date(d3.max(summary, d => d.date))}_ - -
-
-

Rolling 28-day Active users

- ${summary[summary.length-1].active28d.toLocaleString("en-US")} - ${trend(getCompareValue(summary, 'active28d'))} - ${resize((width) => areaChart(summary, {width, metric: 'active28d'}))} +
+
+

Rolling 28-day active users

+ ${activeUsers.active28d.toLocaleString("en-US")} + ${trendNumber(summary, activeUsers, "active28d")} + ${activeUsersChart}
-
-

Engagement Rate

- ${bigPercent(summary[summary.length-1].engagementRate)} - ${trend(getCompareValue(summary, "engagementRate"), {format: "+.2%"})} - ${resize((width) => lineChart(summary, {width, metric: "engagementRate"}))} +
+

Engagement rate

+ ${engagementRate.engagementRate.toLocaleString("en-US", {style: "percent"})} + ${trendNumber(summary, engagementRate, "engagementRate", {format: {style: "percent"}})} + ${engagementRateChart}
-
-

WAU to MAU ratio

- ${bigPercent(summary[summary.length-1].wauPerMau)} - ${trend(getCompareValue(summary, "wauPerMau"), {format: "+.2%"})} - ${resize((width) => lineChart(summary, {width, metric: 'wauPerMau'}))} +
+

WAU/MAU ratio

+ ${wauPerMau.wauPerMau.toLocaleString("en-US", {style: "percent"})} + ${trendNumber(summary, wauPerMau, "wauPerMau", {format: {style: "percent"}})} + ${wauPerMauChart}
-
-

Engaged Sessions

- ${summary[summary.length-1].engagedSessions.toLocaleString("en-US")} - ${trend(getCompareValue(summary, 'engagedSessions'))} - ${resize((width) => areaChart(summary, {width, metric: 'engagedSessions'}))} +
+

Engaged sessions

+ ${engagedSessions.engagedSessions.toLocaleString("en-US")} + ${trendNumber(summary, engagedSessions, "engagedSessions")} + ${engagedSessionsChart}
-
-
- ${resize((width, height) => horizonChart(channels, {width, height, metric:'active28d', title: 'Active users by channel', caption: 'Rolling 28-day active users', format: 's', z: 'channelGroup', color, order: color.domain.slice(1)}))} +
+
+

Active users by channel

+

Rolling 28-day active users

+
${resize((width, height) => horizonChart(channels, {width, height, metric: "active28d", format: 's', z: 'channelGroup', color}))}
- ${resize((width, height) => worldMap(countryData, {width, height, title: "Active users by country", caption: 'Current rolling 28-day active users by country', lookup: countryLookup}))} +

Active users by country

+

Current rolling 28-day active users

+ ${resize((width) => worldMap(null, {width, /*lookup: countryLookup*/}))}
- ${resize((width, height) => marrimekoChart(filteredChannelBreakdown, {width, height: height - 12, metric:'active28d', title: 'New vs. returning users by channel', caption: 'Rolling 28-day active users by channel and split by new vs. returning', format: '%', yDim: 'channelGroup', xDim: 'type', color}))} +

New vs. returning users by channel

+

Rolling 28-day active users by channel and split by new vs. returning

+ ${resize((width) => marimekkoChart(filteredChannelBreakdown, {width, metric:'active28d', format: '%', yDim: 'channelGroup', xDim: 'type', color}))}
- ${resize((width, height) => Punchcard(hourly, {width, height, label: "active users"}))} +

Active users per day and hour of week

+ ${resize((width) => punchcardChart(hourly, {width, label: "active users"}))}
+ +

This dashboard summarizes traffic to Observable Plot’s documentation from ${d3.extent(summary, (d) => d.date).map((d) => d.toLocaleDateString("en-US")).join(" to ")}. Data is pulled from the Google Analytics Data API.

From d2e25c24a34c85b159bfac423726a6db6b07913a Mon Sep 17 00:00:00 2001 From: Paul Buffa Date: Tue, 13 Feb 2024 16:00:47 -0700 Subject: [PATCH 2/8] paired down version --- .../docs/components/punchcardChart.js | 8 ++-- .../docs/components/worldMap.js | 44 ------------------- .../docs/data/countries-110m.json.ts | 7 --- .../data/google-analytics-channels.csv.js | 26 ----------- .../docs/data/google-analytics-country.csv.js | 17 ------- .../data/google-analytics-time-of-day.csv.js | 5 ++- .../docs/data/google-analytics.js | 4 +- examples/google-analytics/docs/index.md | 20 +++------ 8 files changed, 15 insertions(+), 116 deletions(-) delete mode 100644 examples/google-analytics/docs/components/worldMap.js delete mode 100644 examples/google-analytics/docs/data/countries-110m.json.ts delete mode 100644 examples/google-analytics/docs/data/google-analytics-channels.csv.js delete mode 100644 examples/google-analytics/docs/data/google-analytics-country.csv.js diff --git a/examples/google-analytics/docs/components/punchcardChart.js b/examples/google-analytics/docs/components/punchcardChart.js index 6120c58df..007f9abea 100644 --- a/examples/google-analytics/docs/components/punchcardChart.js +++ b/examples/google-analytics/docs/components/punchcardChart.js @@ -1,11 +1,11 @@ import * as Plot from "npm:@observablehq/plot"; import * as d3 from "npm:d3"; -export function punchcardChart(data, {width, height, label} = {}) { +export function punchcardChart(data, {width, height, label, metric} = {}) { const aggregatedValues = d3 .rollups( data, - (v) => d3.median(v, (d) => d.activeUsers), + (v) => d3.median(v, (d) => d[metric]), (d) => d.hour, (d) => d.dayOfWeek ) @@ -30,7 +30,7 @@ export function punchcardChart(data, {width, height, label} = {}) { domain: ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"], tickFormat: (d) => d.substr(0, 3) }, - r: {label, range: [1, 20], domain: d3.extent(aggregatedValues)}, + r: {label, range: [1, 15], domain: d3.extent(aggregatedValues)}, marks: [ Plot.dot( data, @@ -39,7 +39,7 @@ export function punchcardChart(data, {width, height, label} = {}) { { y: "dayOfWeek", x: "hour", - r: "activeUsers", + r: metric, fill: "currentColor", stroke: "var(--theme-background)", sort: null, diff --git a/examples/google-analytics/docs/components/worldMap.js b/examples/google-analytics/docs/components/worldMap.js deleted file mode 100644 index 4ea4626e7..000000000 --- a/examples/google-analytics/docs/components/worldMap.js +++ /dev/null @@ -1,44 +0,0 @@ -import * as Plot from "npm:@observablehq/plot"; -import {FileAttachment} from "npm:@observablehq/stdlib"; -// import * as d3 from "npm:d3"; -import * as topojson from "npm:topojson-client"; - -const world = await FileAttachment("../data/countries-110m.json").json(); - -// const countryLookup = d3.rollup( -// countries, -// (v) => v[0].engagementRate, -// (d) => (d.country === "United States" ? "United States of America" : d.country) -// ); - -const countryShapes = topojson.feature(world, world.objects.countries); -// const countryData = countryShapes.features.map((d) => ({...d, value: countryLookup.get(d.properties.name)})); - -export function worldMap(data, {width, height, title, caption} = {}) { - return Plot.plot({ - width, - height, - caption, - projection: "equal-earth", - color: { - scheme: "cool", - legend: true, - label: "Engagement rate", - tickFormat: "%" - }, - marks: [ - Plot.graticule(), - Plot.geo(countryShapes, { - fill: "var(--theme-foreground-fainter)", - stroke: "var(--theme-foreground)", - strokeWidth: 0.25 - }), - Plot.geo(countryShapes, { - fill: "value", - stroke: "var(--theme-foreground)", - strokeWidth: 0.5 - }), - Plot.sphere() - ] - }); -} diff --git a/examples/google-analytics/docs/data/countries-110m.json.ts b/examples/google-analytics/docs/data/countries-110m.json.ts deleted file mode 100644 index f959d20b4..000000000 --- a/examples/google-analytics/docs/data/countries-110m.json.ts +++ /dev/null @@ -1,7 +0,0 @@ -export {}; - -process.stdout.write( - await fetch( - "https://static.observableusercontent.com/files/33f462daa7c36572a70ef54acb6e2521ea40271b4c8df544214ba731d91b9e4d927ed22f559881096780fc81711bea4ca9d12c9115c59ce2415ea7f1cb5115e3?response-content-disposition=attachment%3Bfilename*%3DUTF-8%27%27world-110m-2020.json" - ).then((d) => d.text()) -); diff --git a/examples/google-analytics/docs/data/google-analytics-channels.csv.js b/examples/google-analytics/docs/data/google-analytics-channels.csv.js deleted file mode 100644 index 975241e0b..000000000 --- a/examples/google-analytics/docs/data/google-analytics-channels.csv.js +++ /dev/null @@ -1,26 +0,0 @@ -import {csvFormat} from "d3-dsv"; -import {runReport} from "./google-analytics.js"; - -const response = await runReport({ - dateRanges: [{startDate: "2023-04-01", endDate: "2023-12-31"}], - dimensions: [{name: "date"}, {name: "firstUserDefaultChannelGroup"}], - metrics: [{name: "active28DayUsers"}, {name: "bounceRate"}, {name: "engagementRate"}, {name: "totalUsers"}], - orderBys: [{dimension: {dimensionName: "date"}}] -}); - -process.stdout.write( - csvFormat( - response.rows.map((d) => ({ - date: formatDate(d.dimensionValues[0].value), - channelGroup: d.dimensionValues[1].value, - active28d: d.metricValues[0].value, - bounceRate: d.metricValues[1].value, - engagementRate: d.metricValues[2].value, - totalUsers: d.metricValues[3].value - })) - ) -); - -function formatDate(date) { - return `${date.slice(0, 4)}-${date.slice(4, 6)}-${date.slice(6, 8)}`; -} diff --git a/examples/google-analytics/docs/data/google-analytics-country.csv.js b/examples/google-analytics/docs/data/google-analytics-country.csv.js deleted file mode 100644 index 047bbb435..000000000 --- a/examples/google-analytics/docs/data/google-analytics-country.csv.js +++ /dev/null @@ -1,17 +0,0 @@ -import {csvFormat} from "d3-dsv"; -import {runReport} from "./google-analytics.js"; - -const response = await runReport({ - dateRanges: [{startDate: "2023-04-01", endDate: "2023-12-31"}], - dimensions: [{name: "country"}], - metrics: [{name: "engagementRate"}] -}); - -process.stdout.write( - csvFormat( - response.rows.map((d) => ({ - country: d.dimensionValues[0].value, - engagementRate: d.metricValues[0].value - })) - ) -); diff --git a/examples/google-analytics/docs/data/google-analytics-time-of-day.csv.js b/examples/google-analytics/docs/data/google-analytics-time-of-day.csv.js index 3000bd269..b641cdf00 100644 --- a/examples/google-analytics/docs/data/google-analytics-time-of-day.csv.js +++ b/examples/google-analytics/docs/data/google-analytics-time-of-day.csv.js @@ -4,7 +4,7 @@ import {runReport} from "./google-analytics.js"; const response = await runReport({ dateRanges: [{startDate: "2023-04-01", endDate: "2023-12-31"}], dimensions: [{name: "hour"}, {name: "dayOfWeekName"}], - metrics: [{name: "activeUsers"}] + metrics: [{name: "activeUsers"}, {name: "newUsers"}] }); process.stdout.write( @@ -12,7 +12,8 @@ process.stdout.write( response.rows.map((d) => ({ hour: d.dimensionValues[0].value, dayOfWeek: d.dimensionValues[1].value, - activeUsers: d.metricValues[0].value + activeUsers: d.metricValues[0].value, + newUsers: d.metricValues[1].value })) ) ); diff --git a/examples/google-analytics/docs/data/google-analytics.js b/examples/google-analytics/docs/data/google-analytics.js index 0fba624f6..6b250b4c4 100644 --- a/examples/google-analytics/docs/data/google-analytics.js +++ b/examples/google-analytics/docs/data/google-analytics.js @@ -16,7 +16,9 @@ const defaultDimensionFilter = { filter: { fieldName: "fullPageUrl", stringFilter: { - value: "observablehq.com/plot/" + value: "observablehq.com/plot", + matchType: "BEGINS_WITH", + caseSensitive: false } } }; diff --git a/examples/google-analytics/docs/index.md b/examples/google-analytics/docs/index.md index 4b21f10c2..674b8ce0f 100644 --- a/examples/google-analytics/docs/index.md +++ b/examples/google-analytics/docs/index.md @@ -3,18 +3,14 @@ ```js const summary = FileAttachment("data/google-analytics-summary.csv").csv({typed: true}); const hourly = FileAttachment("data/google-analytics-time-of-day.csv").csv({typed: true}); -const channels = FileAttachment("data/google-analytics-channels.csv").csv({typed: true}); const channelBreakdown = FileAttachment("data/google-analytics-channel-breakdown.csv").csv({typed: true}); -const countries = FileAttachment("data/google-analytics-country.csv").csv({typed: true}); ``` ```js import {formatTrend} from "./components/formatTrend.js"; -import {horizonChart} from "./components/horizonChart.js"; import {lineChart} from "./components/lineChart.js"; import {marimekkoChart} from "./components/marimekkoChart.js"; import {punchcardChart} from "./components/punchcardChart.js"; -import {worldMap} from "./components/worldMap.js"; ``` ```js @@ -103,21 +99,15 @@ function trendNumber(data, focusData, metric, options) {

Active users by channel

Rolling 28-day active users

-
${resize((width, height) => horizonChart(channels, {width, height, metric: "active28d", format: 's', z: 'channelGroup', color}))}
+
${resize((width) => marimekkoChart(filteredChannelBreakdown, {width, metric:'active28d', format: '%', yDim: 'channelGroup', xDim: 'type', color}))}
-

Active users by country

-

Current rolling 28-day active users

- ${resize((width) => worldMap(null, {width, /*lookup: countryLookup*/}))} -
-
-

New vs. returning users by channel

-

Rolling 28-day active users by channel and split by new vs. returning

- ${resize((width) => marimekkoChart(filteredChannelBreakdown, {width, metric:'active28d', format: '%', yDim: 'channelGroup', xDim: 'type', color}))} +

Active users per day and hour of week

+ ${resize((width) => punchcardChart(hourly, {width, label: "active users", metric: "activeUsers"}))}
-

Active users per day and hour of week

- ${resize((width) => punchcardChart(hourly, {width, label: "active users"}))} +

New users per day and hour of week

+ ${resize((width) => punchcardChart(hourly, {width, label: "new users", metric: "newUsers"}))}
From 34765465cba7ea60c6b50626bf6462bd6c811821 Mon Sep 17 00:00:00 2001 From: Paul Buffa Date: Tue, 13 Feb 2024 16:10:17 -0700 Subject: [PATCH 3/8] remove unused horizon chart component --- .../docs/components/horizonChart.js | 49 ------------------- 1 file changed, 49 deletions(-) delete mode 100644 examples/google-analytics/docs/components/horizonChart.js diff --git a/examples/google-analytics/docs/components/horizonChart.js b/examples/google-analytics/docs/components/horizonChart.js deleted file mode 100644 index 37c32f4b1..000000000 --- a/examples/google-analytics/docs/components/horizonChart.js +++ /dev/null @@ -1,49 +0,0 @@ -import * as Plot from "npm:@observablehq/plot"; -import * as d3 from "npm:d3"; - -const bands = 5; -const opacityScale = d3.scaleLinear().domain([0, bands]).range([0.15, 0.85]); - -export function horizonChart(data, {width, height, metric, title, caption, format, z, color, order}) { - const step = d3.max(data, (d) => d[metric]) / bands; - return Plot.plot({ - width, - height, - subtitle: title, - caption, - axis: null, - // marginTop: 20, - marginLeft: 10, - marginRight: 10, - color, - y: {domain: [0, step]}, - x: {axis: true, domain: [new Date("2023-04-01"), new Date("2023-12-31")]}, - fy: {axis: null, domain: order, padding: 0.05}, - facet: {data, y: z}, - marks: [ - d3.range(bands).map((i) => - Plot.areaY(data, { - x: "date", - y: (d) => d[metric] - i * step, - fill: z, - fillOpacity: opacityScale(i), - clip: true - }) - ), - Plot.tip(data, Plot.pointerX({x: "date", channels: {users: metric}, format: {fy: false}})), - Plot.text( - data, - Plot.selectFirst({ - text: z, - fontSize: 12, - frameAnchor: "top-left", - dx: 6, - dy: 6, - stroke: "var(--theme-background)", - paintOrder: "stroke", - fill: "currentColor" - }) - ) - ] - }); -} From 2c80edb9c064b2a55dd749652dd4b8db0ae9353c Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Tue, 13 Feb 2024 18:42:48 -0800 Subject: [PATCH 4/8] fix height, note --- examples/google-analytics/docs/index.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/google-analytics/docs/index.md b/examples/google-analytics/docs/index.md index 674b8ce0f..3fd6a9671 100644 --- a/examples/google-analytics/docs/index.md +++ b/examples/google-analytics/docs/index.md @@ -99,7 +99,7 @@ function trendNumber(data, focusData, metric, options) {

Active users by channel

Rolling 28-day active users

-
${resize((width) => marimekkoChart(filteredChannelBreakdown, {width, metric:'active28d', format: '%', yDim: 'channelGroup', xDim: 'type', color}))}
+
${resize((width, height) => marimekkoChart(filteredChannelBreakdown, {width, height, metric:'active28d', format: '%', yDim: 'channelGroup', xDim: 'type', color}))}

Active users per day and hour of week

@@ -111,4 +111,4 @@ function trendNumber(data, focusData, metric, options) {
-

This dashboard summarizes traffic to Observable Plot’s documentation from ${d3.extent(summary, (d) => d.date).map((d) => d.toLocaleDateString("en-US")).join(" to ")}. Data is pulled from the Google Analytics Data API.

+
This dashboard summarizes traffic to Observable Plot’s documentation from ${d3.extent(summary, (d) => d.date).map((d) => d.toLocaleDateString("en-US")).join(" to ")}. Data is pulled from the Google Analytics Data API.
From 85651a126f488bedfed4bed48b8221eb9a1852f4 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Wed, 14 Feb 2024 14:55:29 -0800 Subject: [PATCH 5/8] more polish --- .../docs/components/marimekkoChart.js | 56 +++++-------------- .../docs/components/punchcardChart.js | 10 ++-- examples/google-analytics/docs/index.md | 10 ++-- 3 files changed, 23 insertions(+), 53 deletions(-) diff --git a/examples/google-analytics/docs/components/marimekkoChart.js b/examples/google-analytics/docs/components/marimekkoChart.js index 027b87529..ef874c1fd 100644 --- a/examples/google-analytics/docs/components/marimekkoChart.js +++ b/examples/google-analytics/docs/components/marimekkoChart.js @@ -1,54 +1,24 @@ import * as Plot from "npm:@observablehq/plot"; import * as d3 from "npm:d3"; -export function marimekkoChart(data, {width, height, xDim, yDim, metric, title, caption, color}) { - const xy = (options) => Marimekko({...options, x: xDim, y: yDim, value: metric}); +export function marimekkoChart(data, {x, y, value, color, ...options} = {}) { + const xy = (options) => Marimekko({...options, x, y, value}); return Plot.plot({ - width, - height, - subtitle: title, - caption, + ...options, label: null, - x: {percent: true, ticks: 10, tickFormat: (d) => (d === 100 ? `100%` : d)}, - y: {percent: true, ticks: 10, tickFormat: (d) => (d === 100 ? `100%` : d)}, + x: {percent: true, tickFormat: (d) => d + (d === 100 ? "%" : "")}, + y: {percent: true, tickFormat: (d) => d + (d === 100 ? "%" : "")}, color, marks: [ - Plot.text( - data, - xy({ - text: (d) => d[metric].toLocaleString("en"), - fontSize: 14, - fontWeight: 600, - stroke: yDim, - fill: "var(--theme-background)" - }) - ), - Plot.rect(data, xy({fill: yDim, fillOpacity: 1})), - Plot.frame({fill: "var(--theme-background)", fillOpacity: 0.2}), - Plot.text( - data, - xy({ - text: yDim, - fontSize: 11, - dy: -16, - fill: "var(--theme-background)" - }) - ), - Plot.text( - data, - xy({ - text: (d) => d[metric].toLocaleString("en"), - fontSize: 14, - fontWeight: 600, - fill: "var(--theme-background)" - }) - ), + Plot.rect(data, xy({fill: y})), + Plot.text(data, xy({text: y, dy: -16, fill: "black"})), + Plot.text(data, xy({text: (d) => d[value].toLocaleString("en-US"), fontSize: 14, fontWeight: 600, fill: "black"})), Plot.text( data, Plot.selectMaxY( xy({ - z: xDim, - text: (d) => `${d[xDim].slice(0, 1).toUpperCase()}${d[xDim].slice(1)}`, + z: x, + text: (d) => capitalize(d[x]), anchor: "top", lineAnchor: "bottom", fontSize: 12, @@ -60,7 +30,11 @@ export function marimekkoChart(data, {width, height, xDim, yDim, metric, title, }); } -// TODO attribution +function capitalize([t, ...text]) { + return t.toUpperCase() + text.join(""); +} + +// https://observablehq.com/@observablehq/plot-marimekko function Marimekko({x, y, z, value = z, anchor = "middle", inset = 0.5, ...options} = {}) { const stackX = /\bleft$/i.test(anchor) ? Plot.stackX1 : /\bright$/i.test(anchor) ? Plot.stackX2 : Plot.stackX; const stackY = /^top\b/i.test(anchor) ? Plot.stackY2 : /^bottom\b/i.test(anchor) ? Plot.stackY1 : Plot.stackY; diff --git a/examples/google-analytics/docs/components/punchcardChart.js b/examples/google-analytics/docs/components/punchcardChart.js index 007f9abea..f3b3da8e6 100644 --- a/examples/google-analytics/docs/components/punchcardChart.js +++ b/examples/google-analytics/docs/components/punchcardChart.js @@ -1,19 +1,17 @@ import * as Plot from "npm:@observablehq/plot"; import * as d3 from "npm:d3"; -export function punchcardChart(data, {width, height, label, metric} = {}) { +export function punchcardChart(data, {label, value, ...options} = {}) { const aggregatedValues = d3 .rollups( data, - (v) => d3.median(v, (d) => d[metric]), + (v) => d3.median(v, (d) => d[value]), (d) => d.hour, (d) => d.dayOfWeek ) .flatMap((d) => d[1].map((d) => d[1])); - return Plot.plot({ - width, - height, + ...options, inset: 12, padding: 0, marginBottom: 10, @@ -39,7 +37,7 @@ export function punchcardChart(data, {width, height, label, metric} = {}) { { y: "dayOfWeek", x: "hour", - r: metric, + r: value, fill: "currentColor", stroke: "var(--theme-background)", sort: null, diff --git a/examples/google-analytics/docs/index.md b/examples/google-analytics/docs/index.md index 3fd6a9671..effdd91d6 100644 --- a/examples/google-analytics/docs/index.md +++ b/examples/google-analytics/docs/index.md @@ -21,7 +21,7 @@ const color = Plot.scale({ }); const filteredChannelBreakdown = channelBreakdown - .filter((d) => color.domain.includes(d.channelGroup) && d.type != "Unknown" && d.channelGroup !== 'Unassigned') + .filter((d) => color.domain.includes(d.channelGroup) && d.type != "Unknown" && d.channelGroup !== "Unassigned") .sort((a, b) => color.domain.indexOf(b.channelGroup) - color.domain.indexOf(a.channelGroup)); function getCompareValue(data, metric) { @@ -99,16 +99,14 @@ function trendNumber(data, focusData, metric, options) {

Active users by channel

Rolling 28-day active users

-
${resize((width, height) => marimekkoChart(filteredChannelBreakdown, {width, height, metric:'active28d', format: '%', yDim: 'channelGroup', xDim: 'type', color}))}
+
${resize((width, height) => marimekkoChart(filteredChannelBreakdown, {width, height, x: "type", y: "channelGroup", value: "active28d", color}))}

Active users per day and hour of week

- ${resize((width) => punchcardChart(hourly, {width, label: "active users", metric: "activeUsers"}))} + ${resize((width) => punchcardChart(hourly, {width, label: "active users", value: "activeUsers"}))}

New users per day and hour of week

- ${resize((width) => punchcardChart(hourly, {width, label: "new users", metric: "newUsers"}))} + ${resize((width) => punchcardChart(hourly, {width, label: "new users", value: "newUsers"}))}
- -
This dashboard summarizes traffic to Observable Plot’s documentation from ${d3.extent(summary, (d) => d.date).map((d) => d.toLocaleDateString("en-US")).join(" to ")}. Data is pulled from the Google Analytics Data API.
From 563a40a6d0d7732c24d863443db394600003d0d4 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Wed, 14 Feb 2024 14:58:11 -0800 Subject: [PATCH 6/8] more polish --- examples/google-analytics/docs/components/lineChart.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/examples/google-analytics/docs/components/lineChart.js b/examples/google-analytics/docs/components/lineChart.js index b27d12d29..e7488a558 100644 --- a/examples/google-analytics/docs/components/lineChart.js +++ b/examples/google-analytics/docs/components/lineChart.js @@ -5,10 +5,11 @@ export function lineChart(data, {width, height = 94, x = "date", y, percent} = { width, height, axis: null, + margin: 0, insetTop: 10, - insetLeft: -15, + insetLeft: -17, insetRight: -17, y: {zero: true, percent, domain: percent ? [0, 100] : undefined}, - marks: [Plot.areaY(data, {x, y, fillOpacity: 0.25}), Plot.lineY(data, {x, y, tip: true})] + marks: [Plot.areaY(data, {x, y, fillOpacity: 0.2}), Plot.lineY(data, {x, y, tip: true})] }); } From d1222f17256dfee153a1a84fefb80bce81dd088f Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Wed, 14 Feb 2024 15:03:05 -0800 Subject: [PATCH 7/8] more polish --- .../{formatTrend.js => trendNumber.js} | 5 +++ examples/google-analytics/docs/index.md | 32 ++++++------------- 2 files changed, 15 insertions(+), 22 deletions(-) rename examples/google-analytics/docs/components/{formatTrend.js => trendNumber.js} (73%) diff --git a/examples/google-analytics/docs/components/formatTrend.js b/examples/google-analytics/docs/components/trendNumber.js similarity index 73% rename from examples/google-analytics/docs/components/formatTrend.js rename to examples/google-analytics/docs/components/trendNumber.js index 822ea71d3..60ea6797b 100644 --- a/examples/google-analytics/docs/components/formatTrend.js +++ b/examples/google-analytics/docs/components/trendNumber.js @@ -1,5 +1,10 @@ import {html} from "npm:htl"; +export function trendNumber(data, {focus, value, format} = {}) { + const focusIndex = data.findIndex((d) => d === focus); + return formatTrend(focus[value] - data[focusIndex - 1]?.[value], format); +} + export function formatTrend( value, { diff --git a/examples/google-analytics/docs/index.md b/examples/google-analytics/docs/index.md index effdd91d6..34ced3747 100644 --- a/examples/google-analytics/docs/index.md +++ b/examples/google-analytics/docs/index.md @@ -7,7 +7,7 @@ const channelBreakdown = FileAttachment("data/google-analytics-channel-breakdown ``` ```js -import {formatTrend} from "./components/formatTrend.js"; +import {trendNumber} from "./components/trendNumber.js"; import {lineChart} from "./components/lineChart.js"; import {marimekkoChart} from "./components/marimekkoChart.js"; import {punchcardChart} from "./components/punchcardChart.js"; @@ -23,13 +23,6 @@ const color = Plot.scale({ const filteredChannelBreakdown = channelBreakdown .filter((d) => color.domain.includes(d.channelGroup) && d.type != "Unknown" && d.channelGroup !== "Unassigned") .sort((a, b) => color.domain.indexOf(b.channelGroup) - color.domain.indexOf(a.channelGroup)); - -function getCompareValue(data, metric) { - const maxDate = data[data.length - 1].date; - const compareDate = d3.utcDay.offset(maxDate, -1); - const match = data.find((d) => +d.date === +compareDate)[metric]; - return data[data.length - 1][metric] - match; -} ``` ```js +// Like Generators.input, but works with resize, and adds a default value. function generateValue(target, defaultValue) { return Generators.observe((notify) => { const changed = ({target}) => notify(target.value ?? defaultValue); @@ -52,45 +46,39 @@ function generateValue(target, defaultValue) { } const activeUsersChart = resize((width) => lineChart(summary, {width, y: "active28d"})); -const activeUsers = generateValue(activeUsersChart, summary[summary.length - 1]); const engagementRateChart = resize((width) => lineChart(summary, {width, y: "engagementRate", percent: true})); -const engagementRate = generateValue(engagementRateChart, summary[summary.length - 1]); const wauPerMauChart = resize((width) => lineChart(summary, {width, y: "wauPerMau", percent: true})); -const wauPerMau = generateValue(wauPerMauChart, summary[summary.length - 1]); const engagedSessionsChart = resize((width) => lineChart(summary, {width, y: "engagedSessions"})); -const engagedSessions = generateValue(engagedSessionsChart, summary[summary.length - 1]); -``` -```js -function trendNumber(data, focusData, metric, options) { - const focusIndex = data.findIndex((d) => d === focusData); - return formatTrend(focusData[metric] - data[focusIndex - 1]?.[metric], options); -} +const activeUsers = generateValue(activeUsersChart, summary[summary.length - 1]); +const engagementRate = generateValue(engagementRateChart, summary[summary.length - 1]); +const wauPerMau = generateValue(wauPerMauChart, summary[summary.length - 1]); +const engagedSessions = generateValue(engagedSessionsChart, summary[summary.length - 1]); ```

Rolling 28-day active users

${activeUsers.active28d.toLocaleString("en-US")} - ${trendNumber(summary, activeUsers, "active28d")} + ${trendNumber(summary, {focus: activeUsers, value: "active28d"})} ${activeUsersChart}

Engagement rate

${engagementRate.engagementRate.toLocaleString("en-US", {style: "percent"})} - ${trendNumber(summary, engagementRate, "engagementRate", {format: {style: "percent"}})} + ${trendNumber(summary, {focus: engagementRate, value: "engagementRate", format: {style: "percent"}})} ${engagementRateChart}

WAU/MAU ratio

${wauPerMau.wauPerMau.toLocaleString("en-US", {style: "percent"})} - ${trendNumber(summary, wauPerMau, "wauPerMau", {format: {style: "percent"}})} + ${trendNumber(summary, {focus: wauPerMau, value: "wauPerMau", format: {style: "percent"}})} ${wauPerMauChart}

Engaged sessions

${engagedSessions.engagedSessions.toLocaleString("en-US")} - ${trendNumber(summary, engagedSessions, "engagedSessions")} + ${trendNumber(summary, {focus: engagedSessions, value: "engagedSessions"})} ${engagedSessionsChart}
From c367d7f738256fa428cbf1f467f0455ec7c4d2eb Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Wed, 14 Feb 2024 15:06:02 -0800 Subject: [PATCH 8/8] fix options --- examples/google-analytics/docs/components/trendNumber.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/google-analytics/docs/components/trendNumber.js b/examples/google-analytics/docs/components/trendNumber.js index 60ea6797b..895716114 100644 --- a/examples/google-analytics/docs/components/trendNumber.js +++ b/examples/google-analytics/docs/components/trendNumber.js @@ -1,8 +1,8 @@ import {html} from "npm:htl"; -export function trendNumber(data, {focus, value, format} = {}) { +export function trendNumber(data, {focus, value, ...options} = {}) { const focusIndex = data.findIndex((d) => d === focus); - return formatTrend(focus[value] - data[focusIndex - 1]?.[value], format); + return formatTrend(focus[value] - data[focusIndex - 1]?.[value], options); } export function formatTrend(