diff --git a/package-lock.json b/package-lock.json index a2495613..9055a5b6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -39,7 +39,7 @@ }, "devDependencies": { "@embeddable.com/core": "^2.8.1", - "@embeddable.com/react": "^2.9.1", + "@embeddable.com/react": "^2.9.2", "@embeddable.com/sdk-core": "^3.12.4", "@embeddable.com/sdk-react": "^3.10.4", "@trivago/prettier-plugin-sort-imports": "^4.3.0", @@ -595,23 +595,21 @@ "license": "MIT" }, "node_modules/@embeddable.com/core": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/@embeddable.com/core/-/core-2.8.1.tgz", - "integrity": "sha512-px0Z9B1p2Wr6f/5eeOnXcEtv5/WUweznupBIYC7YfkvADApAWxg7XzHjwUhLCCL0DhZJcOT5UtNF9e9cgNPBGQ==", + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/@embeddable.com/core/-/core-2.8.2.tgz", + "integrity": "sha512-5fGgPtbvIq5K+VemRDzRp2FEjVcfh7YqLrpqcMU7VQFsxR2A3tYOlt/ywZTN0gLjUOi3h+izbQK+7fjIujGk8g==", "dev": true, - "license": "MIT", "dependencies": { "jsdom": "^24.1.1" } }, "node_modules/@embeddable.com/react": { - "version": "2.9.1", - "resolved": "https://registry.npmjs.org/@embeddable.com/react/-/react-2.9.1.tgz", - "integrity": "sha512-P8IenI4AbRC9j9P//qZjtbruEv0ZfloScDHOX4HJF7eMrry2XobYO5vohhFo1DMxW77ipG8K7nF8k/3VgcfLCA==", + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/@embeddable.com/react/-/react-2.9.2.tgz", + "integrity": "sha512-H/fMUjXmNZljB9ZjWvvoSIipSBjCxtmWOfFxSHJLmnjpNgxnd249uBdaSQj769OfDl5h3oXmCbG954Qd51lk1w==", "dev": true, - "license": "MIT", "dependencies": { - "@embeddable.com/core": "2.8.1" + "@embeddable.com/core": "2.8.2" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0" diff --git a/package.json b/package.json index aa6621a0..a8274527 100644 --- a/package.json +++ b/package.json @@ -63,7 +63,7 @@ }, "devDependencies": { "@embeddable.com/core": "^2.8.1", - "@embeddable.com/react": "^2.9.1", + "@embeddable.com/react": "^2.9.2", "@embeddable.com/sdk-core": "^3.12.4", "@embeddable.com/sdk-react": "^3.10.4", "@trivago/prettier-plugin-sort-imports": "^4.3.0", diff --git a/src/components/ailo-components/BasicPieComponent/BasicPieComponent.emb.ts b/src/components/ailo-components/BasicPieComponent/BasicPieComponent.emb.ts new file mode 100644 index 00000000..6d158a3d --- /dev/null +++ b/src/components/ailo-components/BasicPieComponent/BasicPieComponent.emb.ts @@ -0,0 +1,55 @@ +import {defineComponent, EmbeddedComponentMeta, Inputs} from '@embeddable.com/react'; +import { loadData } from '@embeddable.com/core'; + +import Component from './BasicPieComponent'; + +export const meta = { + name: 'BasicPieComponent', // unique name for this component (must match file name) + label: 'Basic Pie', + category: 'Ailo: components', + inputs: [ // the inputs the no-code builder user will be asked to enter + { + name: "ds", // unique name for this input + type: "dataset", // tells Embeddable to render a dropdown containing available datasets + label: "Dataset to display", // human readable name for this input (shown in UI above) + }, + { + name: "slice", + type: "dimension", // renders a dropdown containing available dimensions + label: "Slice", + config: { + dataset: "ds", // only show dimensions from dataset "ds" (defined above) + }, + }, + { + name: "metric", + type: "measure", // renders a dropdown containing available measures + label: "Metric", + config: { + dataset: "ds", //only show measures from dataset "ds" (defined above) + }, + }, + { + name: 'showLegend', + type: 'boolean', + label: 'Turn on the legend', + defaultValue: true, + category: 'Chart settings', + }, + ] +} as const satisfies EmbeddedComponentMeta; + +export default defineComponent(Component, meta, { + props: (inputs: Inputs) => { + return { + // pass ds, slice and metric directly to the React component + ...inputs, + // request data to populate our chart + results: loadData({ + from: inputs.ds, + dimensions: [inputs.slice], + measures: [inputs.metric], + }) + }; + } +}); \ No newline at end of file diff --git a/src/components/ailo-components/BasicPieComponent/BasicPieComponent.tsx b/src/components/ailo-components/BasicPieComponent/BasicPieComponent.tsx new file mode 100644 index 00000000..043dd0ef --- /dev/null +++ b/src/components/ailo-components/BasicPieComponent/BasicPieComponent.tsx @@ -0,0 +1,77 @@ +import React from 'react'; +import { Pie } from 'react-chartjs-2'; +import {Dimension, Measure, Dataset, DataResponse} from "@embeddable.com/core"; +import Loading from "../../util/Loading"; +import Error from "../../util/Error"; + + +type Props = { + ds: Dataset; + slice: Dimension; // { name, title } + metric: Measure; // [{ name, title }] + results: DataResponse; // { isLoading, error, data: [{ : , ... }] } + showLegend: boolean; +}; + +const COLORS = [ + '#A9DBB0', + '#F59E54', + '#F77A5F', + '#8FCBCF', + '#C3B0EA', +]; + +const chartOptions = (showLegend: boolean) => ({ + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + display: showLegend + } + }, +}); + +const chartData = (labels: string[] | undefined, counts: number[] | undefined) => { + return { + labels, + datasets: [ + { + data: counts, + backgroundColor: COLORS, + borderColor: COLORS, + } + ] + }; +} + +export default (props: Props) => { + const { slice, metric, results, showLegend } = props; + const { isLoading, data, error } = results; + + if(isLoading) { + return + } + if(error) { + return ; + } + + /* + E.g: + data = [ + { country: "US", count: 23 }, + { country: "UK", count: 10 }, + { country: "Germany", count: 5 }, + ] + slice = { name: 'country' } + metric = { name: 'count' } + */ + + // Chart.js pie expects labels like so: ['US', 'UK', 'Germany'] + const labels: string[] |undefined = data?.map(d => d[slice.name] as string); + + // Chart.js pie expects counts like so: [23, 10, 5] + const counts = data?.map(d => d[metric.name] as number); + + return +}; \ No newline at end of file diff --git a/src/components/ailo-components/BasicPieComponent/index.ts b/src/components/ailo-components/BasicPieComponent/index.ts new file mode 100644 index 00000000..c48dd55b --- /dev/null +++ b/src/components/ailo-components/BasicPieComponent/index.ts @@ -0,0 +1 @@ +export * as BasicPieComponent from "./BasicPieComponent.emb"; \ No newline at end of file diff --git a/src/components/ailo-components/BasicStackedBarChart/BasicStackedBarChart.emb.ts b/src/components/ailo-components/BasicStackedBarChart/BasicStackedBarChart.emb.ts new file mode 100644 index 00000000..62f6f354 --- /dev/null +++ b/src/components/ailo-components/BasicStackedBarChart/BasicStackedBarChart.emb.ts @@ -0,0 +1,190 @@ +import { OrderBy, loadData } from '@embeddable.com/core'; +import { EmbeddedComponentMeta, Inputs, defineComponent } from '@embeddable.com/react'; + +import Component from './BasicStackedBarChart'; + +export const meta = { + name: 'BasicStackedBarChart', + label: 'Basica stacked bar chart', + classNames: ['inside-card'], + category: 'Ailo: components', + inputs: [ + { + name: 'ds', + type: 'dataset', + label: 'Dataset to display', + category: 'Chart data', + }, + { + name: 'xAxis', + type: 'dimension', + label: 'X-Axis', + config: { + dataset: 'ds', + }, + category: 'Chart data', + }, + { + name: 'segment', + type: 'dimension', + label: 'Grouping', + config: { + dataset: 'ds', + }, + category: 'Chart data', + }, + { + name: 'metric', + type: 'measure', + label: 'Metric', + config: { + dataset: 'ds', + }, + category: 'Chart data', + }, + { + name: 'sortBy', + type: 'dimensionOrMeasure', + label: 'Sort by (optional)', + config: { + dataset: 'ds', + }, + category: 'Chart data', + }, + { + name: 'title', + type: 'string', + label: 'Title', + description: 'The title for the chart', + category: 'Chart settings', + }, + { + name: 'description', + type: 'string', + label: 'Description', + description: 'The description for the chart', + category: 'Chart settings', + }, + { + name: 'stackBars', + type: 'boolean', + label: 'Stack bars', + defaultValue: true, + category: 'Chart settings', + }, + { + name: 'showLegend', + type: 'boolean', + label: 'Show legend', + defaultValue: true, + category: 'Chart settings', + }, + { + name: 'maxSegments', + type: 'number', + label: 'Max Legend Items', + defaultValue: 8, + category: 'Chart settings', + }, + { + name: 'otherSegmentsName', + type: 'string', + label: 'Other segments grouped name', + defaultValue: 'Other', + category: 'Chart settings', + }, + { + name: 'maxLabelsToShow', + type: 'number', + label: 'Max number of labels to show', + defaultValue: 8, + category: 'Chart settings', + }, + { + name: 'otherLabelsName', + type: 'string', + label: 'Other labels grouped name', + defaultValue: 'Other', + category: 'Chart settings', + }, + { + name: 'showLabels', + type: 'boolean', + label: 'Show Labels', + defaultValue: false, + category: 'Chart settings', + }, + { + name: 'showTotals', + type: 'boolean', + label: 'Show Totals Above Stacked Bars', + defaultValue: false, + category: 'Chart settings', + }, + { + name: 'displayHorizontally', + type: 'boolean', + label: 'Display Horizontally', + defaultValue: false, + category: 'Chart settings', + }, + { + name: 'reverseXAxis', + type: 'boolean', + label: 'Reverse X Axis', + category: 'Chart settings', + defaultValue: false, + }, + { + name: 'displayAsPercentage', + type: 'boolean', + label: 'Display as Percentages', + defaultValue: false, + category: 'Chart settings', + }, + { + name: 'dps', + type: 'number', + label: 'Decimal Places', + category: 'Formatting', + }, + { + name: 'enableDownloadAsCSV', + type: 'boolean', + label: 'Show download as CSV', + category: 'Export options', + defaultValue: true, + }, + { + name: 'enableDownloadAsPNG', + type: 'boolean', + label: 'Show download as PNG', + category: 'Export options', + defaultValue: true, + }, + ], +} as const satisfies EmbeddedComponentMeta; + +export default defineComponent(Component, meta, { + props: (inputs: Inputs) => { + const orderProp: OrderBy[] = []; + + if (inputs.sortBy) { + orderProp.push({ + property: inputs.sortBy, + direction: inputs.sortBy.nativeType == 'string' ? 'asc' : 'desc', + }); + } + + return { + ...inputs, + isGroupedBar: true, + results: loadData({ + from: inputs.ds, + dimensions: [inputs.xAxis, inputs.segment], + measures: [inputs.metric], + orderBy: orderProp, + }), + }; + }, +}); diff --git a/src/components/ailo-components/BasicStackedBarChart/BasicStackedBarChart.tsx b/src/components/ailo-components/BasicStackedBarChart/BasicStackedBarChart.tsx new file mode 100644 index 00000000..674c197e --- /dev/null +++ b/src/components/ailo-components/BasicStackedBarChart/BasicStackedBarChart.tsx @@ -0,0 +1,96 @@ +import { + Chart as ChartJS, + CategoryScale, + LinearScale, + BarElement, + Title, + Tooltip, + Legend, ChartData, +} from 'chart.js'; +import { Bar } from 'react-chartjs-2'; +import getStackedChartData, {Props} from "../../util/getStackedChartData"; +import Container from "../../vanilla/Container"; +import getBarChartOptions from "../../util/getBarChartOptions"; +import React from "react"; +import {AILO_BAR_CHART_SEGMENT_COLORS} from "../colors"; + +ChartJS.register( + CategoryScale, + LinearScale, + BarElement, + Title, + Tooltip, + Legend +); + +type Totals = { + [xAxis: string]: { + total: number; + lastSegment: number | null; + }; +}; + + +export const options = { + plugins: { + title: { + display: true, + text: 'Ailo Bar Chart - Stacked', + }, + }, + responsive: true, + scales: { + x: { + stacked: true, + }, + y: { + stacked: true, + }, + }, +}; + +export default (props: Props) => { + const datasetsMeta = { + barPercentage: 0.8, + categoryPercentage: 0.8, + barThickness: 'flex', + minBarLength: 0, + borderRadius: 5, + }; + + props.customisedSegmentColors = AILO_BAR_CHART_SEGMENT_COLORS; + + if (props.showTotals) { + const totals: Totals = {}; + const { data } = props.results; + const { metric, xAxis, segment } = props; + if (data && data.length > 0) { + data?.forEach((d: { [key: string]: any }) => { + const x = d[xAxis.name]; + const y = parseFloat(d[metric.name]); + if (totals[x]) { + totals[x].total += y; + totals[x].lastSegment = null; + } else { + totals[x] = { + total: y, + lastSegment: null, // we'll fill this in later + }; + } + }); + props.totals = totals; + } + } + + return ( + + + } + /> + +); +} \ No newline at end of file diff --git a/src/components/ailo-components/BasicStackedBarChart/index.ts b/src/components/ailo-components/BasicStackedBarChart/index.ts new file mode 100644 index 00000000..dd99d3ec --- /dev/null +++ b/src/components/ailo-components/BasicStackedBarChart/index.ts @@ -0,0 +1 @@ +export * as BasicStackedBarChart from "./BasicStackedBarChart.emb"; \ No newline at end of file diff --git a/src/components/ailo-components/colors.ts b/src/components/ailo-components/colors.ts new file mode 100644 index 00000000..4a96cb02 --- /dev/null +++ b/src/components/ailo-components/colors.ts @@ -0,0 +1,8 @@ +export const AILO_BAR_CHART_SEGMENT_COLORS = [ + '#1B4480', + '#696EE0', + '#F5A623', + '#EB6E47', + '#9E0E4E', + '#1C1E26' +] \ No newline at end of file diff --git a/src/components/util/getBarChartOptions.ts b/src/components/util/getBarChartOptions.ts index a2f3d433..e08a4285 100644 --- a/src/components/util/getBarChartOptions.ts +++ b/src/components/util/getBarChartOptions.ts @@ -4,6 +4,7 @@ import { ChartDataset, ChartOptions } from 'chart.js'; import formatValue from '../util/format'; import { setYAxisStepSize } from './chartjs/common'; import { Props } from './getStackedChartData'; +import { LayoutPosition } from "chart.js/dist/types/layout"; // We're adding a few properties to use when showing totals on the chart type ExtendedChartDataset = ChartDataset<'bar' | 'line'> & { @@ -51,6 +52,7 @@ export default function getBarChartOptions({ segment, showLabels = false, showLegend = false, + legendPosition = 'bottom', showSecondYAxis = false, showTotals = false, stackMetrics = false, @@ -69,6 +71,7 @@ export default function getBarChartOptions({ reverseXAxis?: boolean; secondAxisTitle?: string; showSecondYAxis?: boolean; + legendPosition?: LayoutPosition; stackMetrics?: boolean; stacked?: boolean; xAxisTitle?: string; @@ -180,7 +183,7 @@ export default function getBarChartOptions({ plugins: { legend: { display: showLegend, - position: 'bottom', + position: legendPosition, labels: { usePointStyle: true, boxHeight: 8, diff --git a/src/components/util/getStackedChartData.ts b/src/components/util/getStackedChartData.ts index ea4ed985..2b2691c5 100644 --- a/src/components/util/getStackedChartData.ts +++ b/src/components/util/getStackedChartData.ts @@ -3,6 +3,7 @@ import { ChartData } from 'chart.js'; import { COLORS, DATE_DISPLAY_FORMATS } from '../constants'; import formatValue from '../util/format'; +import { LayoutPosition } from "chart.js/dist/types/layout"; type DatasetsMeta = { [key: string]: boolean | string | number; @@ -16,11 +17,13 @@ export type Props = { granularity?: Granularity; isTSGroupedBarChart?: boolean; maxSegments?: number; + otherSegmentsName?: string; metric: Measure; results: DataResponse; segment: Dimension; showLabels?: boolean; showLegend?: boolean; + legendPosition?: LayoutPosition; showTotals?: boolean; title?: string; totals?: { [key: string]: { total: number; lastSegment: number | null } }; @@ -31,6 +34,9 @@ export type Props = { yAxisTitle?: string; isGroupedBar?: boolean; stackBars?: boolean; + maxLabelsToShow?: number; + otherLabelsName?: string; + customisedSegmentColors?: string[]; }; type Options = { @@ -50,15 +56,22 @@ export default function getStackedChartData( displayAsPercentage, granularity, maxSegments, + otherSegmentsName, metric, results, segment, showTotals, totals, useCustomDateFormat, - xAxis + xAxis, + maxLabelsToShow, + otherLabelsName, + customisedSegmentColors } = props; - const labels = [...new Set(results?.data?.map((d: Record) => d[xAxis?.name || '']))] as string[]; + // const labels = [...new Set(results?.data?.map((d: Record) => d[xAxis?.name || '']))] as string[]; + const otherSegmentsGroupedName = otherSegmentsName ?? 'Other' + const otherLabelsGroupedName = otherLabelsName ?? 'Other' + const labels = labelsToInclude(); const segments = segmentsToInclude(); const resultMap: { [key: string]: LabelRef } = {}; @@ -82,26 +95,37 @@ export default function getStackedChartData( results?.data?.forEach((d) => { const seg = d[segment?.name || '']; - const axis = d[xAxis?.name || '']; + const axis = d[xAxis?.name || ''] ?? 'Uncategorised'; const met = d[metric?.name || '']; - if (segments.includes(seg)) { - resultMap[axis][seg] = parseFloat(met); + if(labels.includes(axis)){ + if (segments.includes(seg)) { + resultMap[axis][seg] = parseFloat(met); + } else { + resultMap[axis][otherSegmentsGroupedName] = (resultMap[axis][otherSegmentsGroupedName] || 0) + parseFloat(met); + } } else { - resultMap[axis]['Other'] = (resultMap[axis]['Other'] || 0) + parseFloat(met); + if (segments.includes(seg)) { + resultMap[otherLabelsGroupedName][seg] = parseFloat(met); + } else { + resultMap[otherLabelsGroupedName][otherSegmentsGroupedName] = (resultMap[otherLabelsGroupedName][otherSegmentsGroupedName] || 0) + parseFloat(met); + } } + }); const dateFormat = useCustomDateFormat && granularity ? DATE_DISPLAY_FORMATS[granularity] : undefined; + const segmentColors = customisedSegmentColors ?? COLORS; + return { labels: labels.map((l) => formatValue(l, { meta: xAxis?.meta, dateFormat: dateFormat })), datasets: segments.map((s, i) => { const dataset = { ...datasetsMeta, - backgroundColor: COLORS[i % COLORS.length], - borderColor: COLORS[i % COLORS.length], + backgroundColor: segmentColors[i % segmentColors.length], + borderColor: segmentColors[i % segmentColors.length], label: s, // this is actually segment name, not label, but chart.js wants "label" here data: labels.map((label) => { const segmentValue = resultMap[label][s]; @@ -160,8 +184,18 @@ export default function getStackedChartData( const segmentsToInclude = summedSegments.slice(0, maxSegments).map((s) => s.name); - segmentsToInclude.push('Other'); + segmentsToInclude.push(otherSegmentsGroupedName); return segmentsToInclude; } + + function labelsToInclude(): string[] { + const uniqueLabels = [...new Set(results?.data?.map((d: Record) => d[xAxis?.name || ''] ?? 'Uncategorised' ))] as string[] + if(!maxLabelsToShow || maxLabelsToShow < 1){ + return uniqueLabels; + } + const labelsToInclude = uniqueLabels.slice(0, maxLabelsToShow); + labelsToInclude.push(otherLabelsGroupedName); + return labelsToInclude; + } } diff --git a/yarn.lock b/yarn.lock index 17cf064b..07883362 100644 --- a/yarn.lock +++ b/yarn.lock @@ -323,19 +323,19 @@ resolved "https://registry.npmjs.org/@date-fns/tz/-/tz-1.2.0.tgz" integrity sha512-LBrd7MiJZ9McsOgxqWX7AaxrDjcFVjWH/tIKJd7pnR7McaslGYOP1QmmiBXdJH/H/yLCT+rcQ7FaPBUxRGUtrg== -"@embeddable.com/core@^2.8.1", "@embeddable.com/core@2.8.1": - version "2.8.1" - resolved "https://registry.npmjs.org/@embeddable.com/core/-/core-2.8.1.tgz" - integrity sha512-px0Z9B1p2Wr6f/5eeOnXcEtv5/WUweznupBIYC7YfkvADApAWxg7XzHjwUhLCCL0DhZJcOT5UtNF9e9cgNPBGQ== +"@embeddable.com/core@^2.8.1", "@embeddable.com/core@2.8.2": + version "2.8.2" + resolved "https://registry.npmjs.org/@embeddable.com/core/-/core-2.8.2.tgz" + integrity sha512-5fGgPtbvIq5K+VemRDzRp2FEjVcfh7YqLrpqcMU7VQFsxR2A3tYOlt/ywZTN0gLjUOi3h+izbQK+7fjIujGk8g== dependencies: jsdom "^24.1.1" -"@embeddable.com/react@^2.9.1": - version "2.9.1" - resolved "https://registry.npmjs.org/@embeddable.com/react/-/react-2.9.1.tgz" - integrity sha512-P8IenI4AbRC9j9P//qZjtbruEv0ZfloScDHOX4HJF7eMrry2XobYO5vohhFo1DMxW77ipG8K7nF8k/3VgcfLCA== +"@embeddable.com/react@^2.9.2": + version "2.9.2" + resolved "https://registry.npmjs.org/@embeddable.com/react/-/react-2.9.2.tgz" + integrity sha512-H/fMUjXmNZljB9ZjWvvoSIipSBjCxtmWOfFxSHJLmnjpNgxnd249uBdaSQj769OfDl5h3oXmCbG954Qd51lk1w== dependencies: - "@embeddable.com/core" "2.8.1" + "@embeddable.com/core" "2.8.2" "@embeddable.com/sdk-core@^3.12.4", "@embeddable.com/sdk-core@3.12.4": version "3.12.4"