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 + * + *
+ * + * + * + * + * + * + *
+ */ + const containerQuery = selection + .selectAll(":scope > .pie-chart-container") + .data(pieData); + containerQuery.exit().remove(); + const container = containerQuery + .enter() + .append("div") + .attr("class", "pie-chart-container"); + container.append("svg").attr({ + class: "pie-chart", + height: chartDimensions.height, + width: chartDimensions.width, + viewBox: `0 0 ${chartDimensions.height} ${chartDimensions.width}`, + preserveAspectRatio: "xMidYMid meet", + }); + const svg = container.select("svg.pie-chart"); + svg.append("g").attr({ + class: "chart", + transform: `translate(${chartDimensions.height / 2}, ${chartDimensions.width / 2})`, + }); + const chart = svg.select("g.chart"); + chart.append("g").attr("class", "slices"); + const slices = chart.select("g.slices"); + chart.append("g").attr("class", "labels"); + const labels = chart.select("g.labels"); + + // Apply dimensions to the chart components + //chart.attr('transform', 'translate(-50%, -50%)'); + + // Create the pie chart slices + const pieArc = d3.svg + .arc() + .innerRadius(chartDimensions.innerRadius) + .outerRadius(chartDimensions.outerRadius); + const enteringArcs = slices.selectAll(".slice").data(pieData[0]).enter(); + + enteringArcs + .append("path") + .attr("class", "slice") + .attr("d", pieArc) + .style("fill", function (d, i) { + return color(i); + }); + + // Create the pie chart labels and connecting lines + const enteringLabels = labels.selectAll(".label").data(pieData[0]).enter(); + const labelGroups = enteringLabels.append("g").attr("class", "label"); + const lines = labelGroups.append("line").attr({ + x1: function (d, i) { + return pieArc.centroid(d)[0]; + }, + y1: function (d) { + return pieArc.centroid(d)[1]; + }, + x2: function (d) { + const centroid = pieArc.centroid(d); + const midAngle = Math.atan2(centroid[1], centroid[0]); + return Math.cos(midAngle) * chartDimensions.labelRadius; + }, + y2: function (d) { + const centroid = pieArc.centroid(d); + const midAngle = Math.atan2(centroid[1], centroid[0]); + return Math.sin(midAngle) * chartDimensions.labelRadius; + }, + class: "label-line", + stroke: function (d, i) { + return color(i); + }, + }); + const textLabels = labelGroups + .append("text") + .attr({ + x: function (d, i) { + const centroid = pieArc.centroid(d), + midAngle = Math.atan2(centroid[1], centroid[0]), + x = Math.cos(midAngle) * chartDimensions.labelRadius, + sign = x > 0 ? 1 : -1; + return x + 5 * sign; + }, + y: function (d, i) { + const centroid = pieArc.centroid(d), + midAngle = Math.atan2(centroid[1], centroid[0]), + y = Math.sin(midAngle) * chartDimensions.labelRadius; + return y; + }, + "text-anchor": function (d, i) { + const centroid = pieArc.centroid(d), + midAngle = Math.atan2(centroid[1], centroid[0]), + x = Math.cos(midAngle) * chartDimensions.labelRadius; + return x > 0 ? "start" : "end"; + }, + class: "label-text", + "alignment-baseline": "middle", + }) + .text(function (d) { + return d.data.key; + }); + + // relax the label! + const alpha = 0.5, + spacing = 30; + + function relax() { + let again = false; + textLabels.each(function (d, i) { + const a = this, + da = d3.select(a), + y1 = da.attr("y"); + textLabels.each(function (d, j) { + const b = this; + if (a === b) { + return; + } + + const db = d3.select(b); + if (da.attr("text-anchor") !== db.attr("text-anchor")) { + return; + } + + const y2 = db.attr("y"); + const deltaY = y1 - y2; + + if (Math.abs(deltaY) > spacing) { + return; + } + + again = true; + const sign = deltaY > 0 ? 1 : -1; + const adjust = sign * alpha; + da.attr("y", +y1 + adjust); + db.attr("y", +y2 - adjust); + }); + }); + + if (again) { + const labelElements = textLabels[0]; + lines.attr("y2", function (d, i) { + const labelForLine = d3.select(labelElements[i]); + return labelForLine.attr("y"); + }); + setTimeout(relax, 20); + } + } + + relax(); + }); +} + +export default renderPieChart; diff --git a/package-lock.json b/package-lock.json index a5ebe14e..623e9565 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "SEE LICENSE IN License.md", "dependencies": { "@uswds/uswds": "^3.8.0", + "colorbrewer": "^1.5.6", "d3": "^3.5.17", "date-fns": "^3.6.0", "export-to-csv": "^1.3.0", @@ -5859,6 +5860,11 @@ "color-support": "bin.js" } }, + "node_modules/colorbrewer": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/colorbrewer/-/colorbrewer-1.5.6.tgz", + "integrity": "sha512-fONg2pGXyID8zNgKHBlagW8sb/AMShGzj4rRJfz5biZ7iuHQZYquSCLE/Co1oSQFmt/vvwjyezJCejQl7FG/tg==" + }, "node_modules/colord": { "version": "2.9.3", "resolved": "https://registry.npmjs.org/colord/-/colord-2.9.3.tgz", diff --git a/package.json b/package.json index 03c88d79..eced6110 100644 --- a/package.json +++ b/package.json @@ -75,6 +75,7 @@ }, "dependencies": { "@uswds/uswds": "^3.8.0", + "colorbrewer": "^1.5.6", "d3": "^3.5.17", "date-fns": "^3.6.0", "export-to-csv": "^1.3.0", diff --git a/sass/pages/root/_index.scss b/sass/pages/root/_index.scss index 7f778956..b20ec48d 100644 --- a/sass/pages/root/_index.scss +++ b/sass/pages/root/_index.scss @@ -1,4 +1,5 @@ @forward "barchart"; @forward "barchart-component"; +@forward "pie-chart"; @forward "main-data"; @forward "secondary-data"; diff --git a/sass/pages/root/_pie-chart.scss b/sass/pages/root/_pie-chart.scss new file mode 100644 index 00000000..422775c6 --- /dev/null +++ b/sass/pages/root/_pie-chart.scss @@ -0,0 +1,31 @@ +.pie-chart-container { + //height: 100%; + //width: 100%; + //display: block; +} + +.pie-chart { + //height: 100%; + //width: 100%; + //display: block; + + .chart { + //display: block; + //position: relative; + //height: 100%; + //width: 100%; + //top: 50%; + //left: 50%; + //transform: scale(1) translate(50%, 50%); + } + + .label-line { + stroke-width: 1; + } + + text.label-text { + //alignment-baseline: middle; + text-transform: capitalize; + font-size: 1.75rem; + } +}