diff --git a/examples/google-analytics/docs/components/lineChart.js b/examples/google-analytics/docs/components/lineChart.js new file mode 100644 index 000000000..e7488a558 --- /dev/null +++ b/examples/google-analytics/docs/components/lineChart.js @@ -0,0 +1,15 @@ +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, + margin: 0, + insetTop: 10, + insetLeft: -17, + insetRight: -17, + y: {zero: true, percent, domain: percent ? [0, 100] : undefined}, + marks: [Plot.areaY(data, {x, y, fillOpacity: 0.2}), Plot.lineY(data, {x, y, tip: true})] + }); +} diff --git a/examples/google-analytics/docs/components/marimekko.js b/examples/google-analytics/docs/components/marimekkoChart.js similarity index 52% rename from examples/google-analytics/docs/components/marimekko.js rename to examples/google-analytics/docs/components/marimekkoChart.js index 3df91ab64..ef874c1fd 100644 --- a/examples/google-analytics/docs/components/marimekko.js +++ b/examples/google-analytics/docs/components/marimekkoChart.js @@ -1,7 +1,41 @@ 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} = {}) { +export function marimekkoChart(data, {x, y, value, color, ...options} = {}) { + const xy = (options) => Marimekko({...options, x, y, value}); + return Plot.plot({ + ...options, + label: null, + x: {percent: true, tickFormat: (d) => d + (d === 100 ? "%" : "")}, + y: {percent: true, tickFormat: (d) => d + (d === 100 ? "%" : "")}, + color, + marks: [ + 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: x, + text: (d) => capitalize(d[x]), + anchor: "top", + lineAnchor: "bottom", + fontSize: 12, + dy: -6 + }) + ) + ) + ] + }); +} + +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; const [X, setX] = Plot.column(x); diff --git a/examples/google-analytics/docs/components/punchcardChart.js b/examples/google-analytics/docs/components/punchcardChart.js new file mode 100644 index 000000000..f3b3da8e6 --- /dev/null +++ b/examples/google-analytics/docs/components/punchcardChart.js @@ -0,0 +1,50 @@ +import * as Plot from "npm:@observablehq/plot"; +import * as d3 from "npm:d3"; + +export function punchcardChart(data, {label, value, ...options} = {}) { + const aggregatedValues = d3 + .rollups( + data, + (v) => d3.median(v, (d) => d[value]), + (d) => d.hour, + (d) => d.dayOfWeek + ) + .flatMap((d) => d[1].map((d) => d[1])); + return Plot.plot({ + ...options, + 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, 15], domain: d3.extent(aggregatedValues)}, + marks: [ + Plot.dot( + data, + Plot.group( + {r: "median"}, + { + y: "dayOfWeek", + x: "hour", + r: value, + 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/trendNumber.js b/examples/google-analytics/docs/components/trendNumber.js new file mode 100644 index 000000000..895716114 --- /dev/null +++ b/examples/google-analytics/docs/components/trendNumber.js @@ -0,0 +1,26 @@ +import {html} from "npm:htl"; + +export function trendNumber(data, {focus, value, ...options} = {}) { + const focusIndex = data.findIndex((d) => d === focus); + return formatTrend(focus[value] - data[focusIndex - 1]?.[value], options); +} + +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/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 664a2f4e1..34ced3747 100644 --- a/examples/google-analytics/docs/index.md +++ b/examples/google-analytics/docs/index.md @@ -3,312 +3,98 @@ ```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}); -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 {trendNumber} from "./components/trendNumber.js"; +import {lineChart} from "./components/lineChart.js"; +import {marimekkoChart} from "./components/marimekkoChart.js"; +import {punchcardChart} from "./components/punchcardChart.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') + .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]; - 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 +// 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); + 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 engagementRateChart = resize((width) => lineChart(summary, {width, y: "engagementRate", percent: true})); +const wauPerMauChart = resize((width) => lineChart(summary, {width, y: "wauPerMau", percent: true})); +const engagedSessionsChart = resize((width) => lineChart(summary, {width, y: "engagedSessions"})); -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() - ] - }); -} +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]); ``` -_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, {focus: activeUsers, value: "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, {focus: engagementRate, value: "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, {focus: wauPerMau, value: "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, {focus: engagedSessions, value: "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) => marimekkoChart(filteredChannelBreakdown, {width, height, x: "type", y: "channelGroup", value: "active28d", color}))}
- ${resize((width, height) => worldMap(countryData, {width, height, title: "Active users by country", caption: 'Current rolling 28-day active users by country', 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}))} +

Active users per day and hour of week

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

New users per day and hour of week

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