diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 261d074d..be3db838 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -65,7 +65,7 @@ jobs:
- lint-js
- lint-styles
- test
- if: github.ref == 'refs/heads/develop'
+ if: github.ref == 'refs/heads/feature/pie_chart_poc'
uses: 18F/analytics.usa.gov/.github/workflows/deploy.yml@develop
with:
API_APP_NAME: ${{ vars.API_APP_NAME_DEV }}
diff --git a/eslint.config.js b/eslint.config.js
index cc767dc9..3e1bcea1 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -34,6 +34,7 @@ module.exports = [
"sass/**/*",
"ga4-data/**/*",
"js/lib/touchpoints.js",
+ "js/lib/chart_helpers/pie_chart.js",
],
},
{
@@ -92,10 +93,11 @@ module.exports = [
"ga4-data/**/*",
"js/lib/touchpoints.js",
"**/__tests__/*.js",
+ "js/lib/chart_helpers/pie_chart.js",
],
rules: {
...jsdoc.configs.recommended.rules,
- "jsdoc/check-indentation": "error",
+ "jsdoc/check-indentation": "warn",
"jsdoc/check-line-alignment": "error",
"jsdoc/check-syntax": "error",
"jsdoc/convert-to-jsdoc-comments": "error",
diff --git a/jest_setup.js b/jest_setup.js
index 8951400f..00a18686 100644
--- a/jest_setup.js
+++ b/jest_setup.js
@@ -7,6 +7,12 @@ faker.seed(global.faker_seed);
global.IS_REACT_ACT_ENVIRONMENT = true;
+global.ResizeObserver = jest.fn().mockImplementation(() => ({
+ observe: jest.fn(),
+ unobserve: jest.fn(),
+ disconnect: jest.fn(),
+}));
+
// Define the fetch method because without this we get the error 'fetch is not
// defined' in the tests. TODO: figure out why node's native fetch doesn't
// show as defined in the tests.
diff --git a/js/components/dashboard_content/DashboardContent.js b/js/components/dashboard_content/DashboardContent.js
index 0034bb82..251d4ec7 100644
--- a/js/components/dashboard_content/DashboardContent.js
+++ b/js/components/dashboard_content/DashboardContent.js
@@ -3,6 +3,7 @@ import PropTypes from "prop-types";
import Config from "../../lib/config";
import DeviceDemographics from "./DeviceDemographics";
+import DevicesPieChart from "./DevicesPieChart";
import Engagement from "./Engagement";
import LocationsAndLanguages from "./LocationsAndLanguages";
import RealtimeVisitors from "./RealtimeVisitors";
@@ -10,6 +11,7 @@ import Sessions30Days from "./Sessions30Days";
import SidebarContent from "./SidebarContent";
import TrafficSources from "./TrafficSources";
import Visitors30Days from "./Visitors30Days";
+import OperatingSystemsPieChart from "./OperatingSystemsPieChart";
/**
* Contains charts and other data visualizations for the main page of the site.
@@ -47,6 +49,24 @@ function DashboardContent({ dataURL, dataPrefix, agency }) {
+
+
+
+
+
Device
+
+
+
+
+
+
+
Operating System
+
+
+
+
+
+
Historical Data and Trends
diff --git a/js/components/dashboard_content/DevicesPieChart.js b/js/components/dashboard_content/DevicesPieChart.js
new file mode 100644
index 00000000..20cd6f79
--- /dev/null
+++ b/js/components/dashboard_content/DevicesPieChart.js
@@ -0,0 +1,69 @@
+import React, { useRef, useEffect, useState } from "react";
+import PropTypes from "prop-types";
+import colorbrewer from "colorbrewer";
+
+import DataLoader from "../../lib/data_loader";
+import renderPieChart from "../../lib/chart_helpers/pie_chart";
+import transformers from "../../lib/chart_helpers/transformers";
+
+/**
+ * Retrieves the devices report from the passed data URL and creates a
+ * visualization for the breakdown of devices of users visiting sites for the
+ * current agency.
+ *
+ * @param {object} props the properties for the component
+ * @param {string} props.dataHrefBase the URL of the base location of the data
+ * to be downloaded including the agency path. In production this is proxied and
+ * redirected to the S3 bucket URL.
+ * @returns {import('react').ReactElement} The rendered element
+ */
+function DevicesPieChart({ dataHrefBase }) {
+ const dataURL = `${dataHrefBase}/devices.json`;
+ const ref = useRef(null);
+ const [deviceData, setDeviceData] = useState(null);
+ const [pieSvgWidth, setPieSvgWidth] = useState(null);
+
+ useEffect(() => {
+ const initDevicesChart = async () => {
+ if (!pieSvgWidth) {
+ const resizeObserver = new ResizeObserver((entries) => {
+ console.log(entries[0].target.getBoundingClientRect().width);
+ const element = entries[0].target;
+ setPieSvgWidth(element.getBoundingClientRect().width);
+ });
+ resizeObserver.observe(ref.current);
+ }
+ if (!deviceData) {
+ const data = await DataLoader.loadJSON(dataURL);
+ await setDeviceData(data);
+ } else {
+ const devices = transformers.listify(deviceData.totals.by_device);
+ devices.forEach((device) => {
+ device.key = device.key === "smart tv" ? "Smart TV" : device.key;
+ });
+ const dataWithProportions =
+ transformers.findProportionsOfMetricFromValue(devices);
+
+ await renderPieChart({
+ ref: ref.current,
+ data: dataWithProportions,
+ width: pieSvgWidth,
+ colorSet: colorbrewer[colorbrewer.schemeGroups.qualitative[5]][8],
+ });
+ }
+ };
+ initDevicesChart().catch(console.error);
+ }, [deviceData, pieSvgWidth]);
+
+ return (
+ <>
+
+ >
+ );
+}
+
+DevicesPieChart.propTypes = {
+ dataHrefBase: PropTypes.string.isRequired,
+};
+
+export default DevicesPieChart;
diff --git a/js/components/dashboard_content/OperatingSystemsPieChart.js b/js/components/dashboard_content/OperatingSystemsPieChart.js
new file mode 100644
index 00000000..a509b83f
--- /dev/null
+++ b/js/components/dashboard_content/OperatingSystemsPieChart.js
@@ -0,0 +1,66 @@
+import React, { useRef, useEffect, useState } from "react";
+import PropTypes from "prop-types";
+import colorbrewer from "colorbrewer";
+
+import DataLoader from "../../lib/data_loader";
+import renderPieChart from "../../lib/chart_helpers/pie_chart";
+import transformers from "../../lib/chart_helpers/transformers";
+
+/**
+ * Retrieves the OperatingSystems report from the passed data URL and creates a
+ * visualization for the breakdown of OperatingSystems of users visiting sites for the
+ * current agency.
+ *
+ * @param {object} props the properties for the component
+ * @param {string} props.dataHrefBase the URL of the base location of the data
+ * to be downloaded including the agency path. In production this is proxied and
+ * redirected to the S3 bucket URL.
+ * @returns {import('react').ReactElement} The rendered element
+ */
+function OperatingSystemsPieChart({ dataHrefBase }) {
+ const dataURL = `${dataHrefBase}/os.json`;
+ const ref = useRef(null);
+ const [osData, setOsData] = useState(null);
+ const [pieSvgWidth, setPieSvgWidth] = useState(null);
+
+ useEffect(() => {
+ const initOsChart = async () => {
+ if (!pieSvgWidth) {
+ const resizeObserver = new ResizeObserver((entries) => {
+ console.log(entries[0].target.getBoundingClientRect().width);
+ const element = entries[0].target;
+ setPieSvgWidth(element.getBoundingClientRect().width);
+ });
+ resizeObserver.observe(ref.current);
+ }
+ if (!osData) {
+ const data = await DataLoader.loadJSON(dataURL);
+ await setOsData(data);
+ } else {
+ const operatingSystems = transformers.listify(osData.totals.by_os);
+ const dataWithProportions =
+ transformers.findProportionsOfMetricFromValue(operatingSystems);
+
+ await renderPieChart({
+ ref: ref.current,
+ data: dataWithProportions,
+ width: pieSvgWidth,
+ colorSet: colorbrewer[colorbrewer.schemeGroups.qualitative[2]][8],
+ });
+ }
+ };
+ initOsChart().catch(console.error);
+ }, [osData, pieSvgWidth]);
+
+ return (
+ <>
+
+ >
+ );
+}
+
+OperatingSystemsPieChart.propTypes = {
+ dataHrefBase: PropTypes.string.isRequired,
+};
+
+export default OperatingSystemsPieChart;
diff --git a/js/components/dashboard_content/__tests__/__snapshots__/BrowsersChart.spec.js.snap b/js/components/dashboard_content/__tests__/__snapshots__/BrowsersChart.spec.js.snap
index 0444711d..ef963b97 100644
--- a/js/components/dashboard_content/__tests__/__snapshots__/BrowsersChart.spec.js.snap
+++ b/js/components/dashboard_content/__tests__/__snapshots__/BrowsersChart.spec.js.snap
@@ -19,11 +19,11 @@ exports[`BrowsersChart when data is loaded renders a component with data loaded
- 48.7%
+ 49%
- 35.8%
+ 36%
- 8.8%
+ 9%
- 1.9%
+ 2%
- 1.9%
+ 2%
- 1.4%
+ 1%
- 0.2%
+ 0%
- 0.1%
+ 0%
- 0.1%
+ 0%
diff --git a/js/components/dashboard_content/__tests__/__snapshots__/DevicesChart.spec.js.snap b/js/components/dashboard_content/__tests__/__snapshots__/DevicesChart.spec.js.snap
index fce4d17d..062d4362 100644
--- a/js/components/dashboard_content/__tests__/__snapshots__/DevicesChart.spec.js.snap
+++ b/js/components/dashboard_content/__tests__/__snapshots__/DevicesChart.spec.js.snap
@@ -19,11 +19,11 @@ exports[`DevicesChart when data is loaded renders a component with data loaded 1
- 54.4%
+ 54%
- 1.3%
+ 1%
- 0.2%
+ 0%
- 0.1%
+ 0%
diff --git a/js/components/dashboard_content/__tests__/__snapshots__/OperatingSystemsChart.spec.js.snap b/js/components/dashboard_content/__tests__/__snapshots__/OperatingSystemsChart.spec.js.snap
index 96978ebb..5caa31f5 100644
--- a/js/components/dashboard_content/__tests__/__snapshots__/OperatingSystemsChart.spec.js.snap
+++ b/js/components/dashboard_content/__tests__/__snapshots__/OperatingSystemsChart.spec.js.snap
@@ -20,11 +20,11 @@ exports[`OperatingSystemsChart when data is loaded renders a component with data
- 36.6%
+ 37%
- 29.9%
+ 30%
@@ -183,11 +183,11 @@ exports[`OperatingSystemsChart when data is loaded renders a component with data
- 19.3%
+ 19%
- 2.2%
+ 2%
- 1.9%
+ 2%
diff --git a/js/components/dashboard_content/__tests__/__snapshots__/TopChannels.spec.js.snap b/js/components/dashboard_content/__tests__/__snapshots__/TopChannels.spec.js.snap
index 03ec9de0..cca8c794 100644
--- a/js/components/dashboard_content/__tests__/__snapshots__/TopChannels.spec.js.snap
+++ b/js/components/dashboard_content/__tests__/__snapshots__/TopChannels.spec.js.snap
@@ -19,11 +19,11 @@ exports[`TopChannels when data is loaded renders a component with data loaded 1`
- 51.6%
+ 52%
- 43.8%
+ 44%
- 1.6%
+ 2%
- 1.5%
+ 2%
- 1.2%
+ 1%
- 0.1%
+ 0%
- 0.1%
+ 0%
- 0.1%
+ 0%
diff --git a/js/components/dashboard_content/__tests__/__snapshots__/TopCitiesRealtime.spec.js.snap b/js/components/dashboard_content/__tests__/__snapshots__/TopCitiesRealtime.spec.js.snap
index c983a7bd..9d84e21c 100644
--- a/js/components/dashboard_content/__tests__/__snapshots__/TopCitiesRealtime.spec.js.snap
+++ b/js/components/dashboard_content/__tests__/__snapshots__/TopCitiesRealtime.spec.js.snap
@@ -19,11 +19,11 @@ exports[`TopCitiesRealtime when data is loaded renders a component with data loa
- 2.1%
+ 2%
- 6.2%
+ 6%
- 1.6%
+ 2%
- 7.8%
+ 8%
- 0.5%
+ 0%
- 8.1%
+ 8%
- 4.7%
+ 5%
- 4.8%
+ 5%
- 2.6%
+ 3%
- 5.9%
+ 6%
diff --git a/js/components/dashboard_content/__tests__/__snapshots__/TopCountriesRealtime.spec.js.snap b/js/components/dashboard_content/__tests__/__snapshots__/TopCountriesRealtime.spec.js.snap
index c2a9e777..ebec99ee 100644
--- a/js/components/dashboard_content/__tests__/__snapshots__/TopCountriesRealtime.spec.js.snap
+++ b/js/components/dashboard_content/__tests__/__snapshots__/TopCountriesRealtime.spec.js.snap
@@ -20,11 +20,11 @@ exports[`TopCountriesRealtime when data is loaded renders a component with data
- 93.5%
+ 94%
@@ -335,11 +335,11 @@ exports[`TopCountriesRealtime when data is loaded renders a component with data
- 6.5%
+ 6%
diff --git a/js/components/dashboard_content/__tests__/__snapshots__/TopLanguagesHistorical.spec.js.snap b/js/components/dashboard_content/__tests__/__snapshots__/TopLanguagesHistorical.spec.js.snap
index f12cc9a7..e2e5618c 100644
--- a/js/components/dashboard_content/__tests__/__snapshots__/TopLanguagesHistorical.spec.js.snap
+++ b/js/components/dashboard_content/__tests__/__snapshots__/TopLanguagesHistorical.spec.js.snap
@@ -20,11 +20,11 @@ exports[`TopLanguagesHistorical when data is loaded renders a component with dat
- 91.2%
+ 91%
- 5.3%
+ 5%
- 0.9%
+ 1%
- 0.5%
+ 0%
- 0.2%
+ 0%
- 0.3%
+ 0%
- 0.3%
+ 0%
- 0.2%
+ 0%
- 0.2%
+ 0%
- 0.2%
+ 0%
diff --git a/js/components/dashboard_content/__tests__/__snapshots__/TopSourceMedia.spec.js.snap b/js/components/dashboard_content/__tests__/__snapshots__/TopSourceMedia.spec.js.snap
index 87e765ef..8bc329e5 100644
--- a/js/components/dashboard_content/__tests__/__snapshots__/TopSourceMedia.spec.js.snap
+++ b/js/components/dashboard_content/__tests__/__snapshots__/TopSourceMedia.spec.js.snap
@@ -19,11 +19,11 @@ exports[`TopSourceMedia when data is loaded renders a component with data loaded
- 47.7%
+ 48%
- 44.8%
+ 45%
- 3.3%
+ 3%
- 1.5%
+ 2%
- 0.7%
+ 1%
- 0.7%
+ 1%
- 0.4%
+ 0%
- 0.3%
+ 0%
- 0.3%
+ 0%
- 0.2%
+ 0%
- 0.1%
+ 0%
diff --git a/js/lib/chart_helpers/barchart.js b/js/lib/chart_helpers/barchart.js
index f00e7f36..0a04eacc 100644
--- a/js/lib/chart_helpers/barchart.js
+++ b/js/lib/chart_helpers/barchart.js
@@ -1,5 +1,6 @@
import d3 from "d3";
import Config from "../config";
+import formatters from "./formatters";
/**
* @returns {*} a D3 block which charts data
@@ -21,15 +22,23 @@ export default function barChart() {
let scale = null;
- const size = function (n) {
- return `${Math.round(n || 0)}%`;
- };
-
const chart = function (selection) {
+ // Remove all existing entries for the chart.
const bin = selection.selectAll(":scope > .bin").data(bars);
-
bin.exit().remove();
+ /**
+ * Create the bar HTML structure for each data entry
+ *
+ * <>
+ *
+ *
+ *
foobar
+ *
123%
+ *
+ *
+ * >
+ */
const enter = bin.enter().append("div").attr("class", "bin");
enter.append("div").attr("class", "label");
enter.append("div").attr("class", "value");
@@ -45,8 +54,8 @@ export default function barChart() {
.style(
"width",
componentScale
- ? (d) => size(componentScale(value(d)))
- : (d) => size(value(d)),
+ ? (d) => formatters.floatToPercent(componentScale(value(d)))
+ : (d) => formatters.floatToPercent(value(d)),
);
bin.select(".label").html(label);
diff --git a/js/lib/chart_helpers/formatters.js b/js/lib/chart_helpers/formatters.js
index 670229b5..7ccc6795 100644
--- a/js/lib/chart_helpers/formatters.js
+++ b/js/lib/chart_helpers/formatters.js
@@ -2,6 +2,7 @@ import d3 from "d3";
// common parsing and formatting functions
/**
+ * TODO: remove this as not needed now that floatToPercent does this itself.
* @param {string} str stringified number to format
* @returns {string} the string with additional zeros removed
*/
@@ -41,8 +42,12 @@ function readableBigNumber(total) {
return formatter(total);
}
-function floatToPercent(p) {
- return p >= 0.1 ? `${trimZeroes(p.toFixed(1))}%` : "< 0.1%";
+function floatToPercent(proportion, precision = 1) {
+ if (proportion < 0.1) {
+ return "< 0.1%";
+ } else {
+ return `${parseFloat((proportion || 0).toFixed(precision))}%`;
+ }
}
function formatHour(hour) {
diff --git a/js/lib/chart_helpers/pie_chart.js b/js/lib/chart_helpers/pie_chart.js
new file mode 100644
index 00000000..753ebdbb
--- /dev/null
+++ b/js/lib/chart_helpers/pie_chart.js
@@ -0,0 +1,200 @@
+import d3 from "d3";
+import colorbrewer from "colorbrewer";
+import formatters from "./formatters";
+
+/**
+ * @param root0
+ * @param root0.ref
+ * @param root0.data
+ * @param root0.width
+ */
+function renderPieChart({ ref, data, width, colorSet }) {
+ // Setup pie chart data
+ const dataWithPieLabels = data
+ .filter((item) => {
+ return item.proportion > 0.1;
+ })
+ .map((item) => {
+ item.key = `${item.key} ( ${formatters.floatToPercent(item.proportion)} )`;
+ return item;
+ });
+ const pie = d3.layout.pie().value((d) => {
+ return d.value;
+ });
+ const pieData = [pie(dataWithPieLabels)];
+
+ // Set pie chart dimensions
+ const chartDimensions = {
+ width,
+ height: width * 0.65,
+ innerRadius: width / 7,
+ outerRadius: width / 4,
+ labelRadius: width / 3.25,
+ };
+
+ // Setup pie chart colors
+ const color = d3.scale.ordinal().range(colorSet);
+
+ // Create pie chart
+ return d3.select(ref).call((selection) => {
+ /**
+ * Create the svg structure for the pie chart
+ *
+ *