From a92bf15475df6cbfbeddffcaae90e1fca474d237 Mon Sep 17 00:00:00 2001 From: Tero Tikkanen Date: Thu, 7 Nov 2024 16:56:54 +0200 Subject: [PATCH] Use outcome pie charts --- .../paths/contentblocks/PathsOutcomeBlock.tsx | 21 +- .../paths/graphs/DimensionalPieGraph.tsx | 226 ++++++++++++++++++ .../paths/outcome/OutcomeNodeContent.tsx | 6 +- queries/paths/get-paths-page.ts | 12 + utils/paths/metric.ts | 31 ++- 5 files changed, 270 insertions(+), 26 deletions(-) create mode 100644 components/paths/graphs/DimensionalPieGraph.tsx diff --git a/components/paths/contentblocks/PathsOutcomeBlock.tsx b/components/paths/contentblocks/PathsOutcomeBlock.tsx index 6ba64ce1..5a9cae79 100644 --- a/components/paths/contentblocks/PathsOutcomeBlock.tsx +++ b/components/paths/contentblocks/PathsOutcomeBlock.tsx @@ -6,15 +6,15 @@ import { usePathname, useRouter, useSearchParams } from 'next/navigation'; import { Card, CardBody, Col, Container, Row } from 'reactstrap'; import styled from 'styled-components'; -import { - GetPageQuery, - GetPageQueryVariables, -} from '@/common/__generated__/paths/graphql'; import ContentLoader from '@/components/common/ContentLoader'; import OutcomeCardSet from '@/components/paths/outcome/OutcomeCardSet'; -import { activeScenarioVar, yearRangeVar } from '@/context/paths/cache'; +import { + activeGoalVar, + activeScenarioVar, + yearRangeVar, +} from '@/context/paths/cache'; import { usePaths } from '@/context/paths/paths'; -import GET_PAGE from '@/queries/paths/get-paths-page'; +import { GET_OUTCOME_NODE } from '@/queries/paths/get-paths-page'; import { getHttpHeaders } from '@/utils/paths/paths.utils'; import { NetworkStatus, useQuery, useReactiveVar } from '@apollo/client'; @@ -74,6 +74,7 @@ export default function PathsOutcomeBlock(props) { const t = useTranslations(); const pathsInstance = usePaths(); const yearRange = useReactiveVar(yearRangeVar); + const activeGoal = useReactiveVar(activeGoalVar); const activeScenario = useReactiveVar(activeScenarioVar); const path = ''; const searchParams = useSearchParams(); @@ -107,8 +108,8 @@ export default function PathsOutcomeBlock(props) { }, [lastActiveNodeId, queryNodeId]); // router.push(pathname + '?' + createQueryString('sort', 'asc')) - const queryResp = useQuery(GET_PAGE, { - variables: { path, goal: null }, + const queryResp = useQuery(GET_OUTCOME_NODE, { + variables: { node: 'net_emissions', goal: activeGoal?.id ?? null }, fetchPolicy: 'cache-and-network', notifyOnNetworkStatusChange: true, context: { @@ -128,8 +129,8 @@ export default function PathsOutcomeBlock(props) { return ; } - if (data?.page) { - const outcomeNode = data.page?.outcomeNode ?? null; + if (data?.node) { + const outcomeNode = data.node ?? null; const upstreamNodes = outcomeNode?.upstreamNodes ?? []; const allNodes = new Map(upstreamNodes.map((node) => [node.id, node])); diff --git a/components/paths/graphs/DimensionalPieGraph.tsx b/components/paths/graphs/DimensionalPieGraph.tsx new file mode 100644 index 00000000..56083028 --- /dev/null +++ b/components/paths/graphs/DimensionalPieGraph.tsx @@ -0,0 +1,226 @@ +import { useEffect, useMemo, useState } from 'react'; + +import type { DimensionalNodeMetricFragment } from 'common/__generated__/paths/graphql'; +import { isEqual } from 'lodash'; +import { useTranslations } from 'next-intl'; +import dynamic from 'next/dynamic'; +import darken from 'polished/lib/color/darken'; +import styled, { useTheme } from 'styled-components'; + +import { activeGoalVar } from '@/context/paths/cache'; +//import type { InstanceGoal } from 'common/instance'; +import { DimensionalMetric, type SliceConfig } from '@/utils/paths/metric'; +import { useReactiveVar } from '@apollo/client'; + +const PlotsContainer = styled.div` + display: flex; +`; + +const Subplot = styled.div` + font-size: ${({ theme }) => theme.fontSizeSm}; + color: ${({ theme }) => theme.textColor.tertiary}; +`; + +const Plot = dynamic(() => import('components/graphs/Plot'), { ssr: false }); + +function getDefaultSliceConfig( + cube: DimensionalMetric, + activeGoal: InstanceGoal | null +) { + /** + * By default, we group by the first dimension `metric` has, whatever it is. + * @todo Is there a better way to select the default? + * + * If the currently selected goal has category selections for this metric, + * we might choose another dimension. + * + * NOTE: This is just the default -- the actually active filtering and + * grouping is controlled by the `sliceConfig` state below. + */ + const defaultConfig: SliceConfig = { + dimensionId: cube.dimensions[0]?.id, + categories: {}, + }; + + if (!activeGoal) return defaultConfig; + + const cubeDefault = cube.getChoicesForGoal(activeGoal); + if (!cubeDefault) return defaultConfig; + defaultConfig.categories = cubeDefault; + /** + * Check if our default dimension to slice by is affected by the + * goal-based default filters. If so, we should choose another + * dimension. + */ + if ( + defaultConfig.dimensionId && + cubeDefault.hasOwnProperty(defaultConfig.dimensionId) + ) { + const firstPossible = cube.dimensions.find( + (dim) => !cubeDefault.hasOwnProperty(dim.id) + ); + defaultConfig.dimensionId = firstPossible?.id; + } + return defaultConfig; +} + +type DimensionalPieGraphProps = { + metric: NonNullable; + endYear: number; + color?: string | null; +}; + +const DimensionalPieGraph = ({ metric, endYear }: DimensionalPieGraphProps) => { + const t = useTranslations(); + const theme = useTheme(); + const activeGoal = useReactiveVar(activeGoalVar); + const cube = useMemo(() => new DimensionalMetric(metric), [metric]); + const defaultConfig = getDefaultSliceConfig(cube, activeGoal); + const [sliceConfig, setSliceConfig] = useState(defaultConfig); + + useEffect(() => { + /** + * If the active goal changes, we will reset the grouping + filtering + * to be compatible with the new choices (if the new goal has common + * dimensions with our metric). + */ + if (!activeGoal) return; + const newDefault = getDefaultSliceConfig(cube, activeGoal); + if (!newDefault || isEqual(sliceConfig, newDefault)) return; + setSliceConfig(newDefault); + }, [activeGoal, cube]); + + const yearData = cube.getSingleYear(endYear, sliceConfig.categories); + + let longUnit = metric.unit.htmlShort; + // FIXME: Nasty hack to show 'CO2e' where it might be applicable until + // the backend gets proper support for unit specifiers. + if ( + cube.hasDimension('emission_scope') && + !cube.hasDimension('greenhouse_gases') + ) { + if (metric.unit.short === 't/Einw./a') { + longUnit = t.raw('tco2-e-inhabitant'); + } else if (metric.unit.short === 'kt/a') { + longUnit = t.raw('ktco2-e'); + } + } + + const plotData: Partial[] = []; + + let maxTotal = 0; + + // Pie per scope + yearData.categoryTypes[1].options.forEach((colId, cIdx) => { + const colTotals = yearData.rows.reduce((acc, row) => { + return row[cIdx] + acc; + }, 0); + // Remember the largest total for scaling the y-axis + if (Math.abs(colTotals) > maxTotal) { + maxTotal = Math.abs(colTotals); + } + // Pie segment per sector + const pieSegmentLabels: string[] = []; + const pieSegmentValues: number[] = []; + const pieSegmentColors: string[] = []; + yearData.categoryTypes[0].options.forEach((rowId, rIdx) => { + const datum = yearData.rows[rIdx][cIdx]; + const portion = datum / colTotals; + /* + const displayPortions = + portion >= 0.01 ? Math.round((datum / colTotals) * 100) : '<1'; + const textTemplate = + portion && portion !== 1 && portion !== 0 ? '%{meta[0]}%' : ''; + const dimDetails = yearData.allLabels.find((l) => l.id === rowId); + */ + if (datum != 0) { + pieSegmentLabels.push( + yearData.allLabels.find((l) => l.id === rowId)?.label || '' + ); + pieSegmentValues.push(Math.abs(datum)); + pieSegmentColors.push( + yearData.allLabels.find((l) => l.id === rowId)?.color || '' + ); + } + }); + plotData.push({ + type: 'pie', + hole: 0.5, + labels: pieSegmentLabels, + values: pieSegmentValues, + marker: { + colors: pieSegmentColors, + pattern: { + shape: '/', + bgcolor: pieSegmentColors, + fgcolor: pieSegmentColors.map((color) => darken(0.2, color)), + size: 4, + }, + }, + name: yearData.allLabels.find((l) => l.id === colId)?.label || '', + }); + }); + + const range = [0, maxTotal * 1.25]; + + const layout: Partial = { + height: 400, + hovermode: false, + modebar: { + remove: [ + 'zoom2d', + 'zoomIn2d', + 'zoomOut2d', + 'pan2d', + 'select2d', + 'lasso2d', + 'autoScale2d', + 'resetScale2d', + ], + color: theme.graphColors.grey090, + bgcolor: theme.graphColors.grey010, + activecolor: theme.brandDark, + }, + dragmode: false, + showlegend: true, + legend: { + orientation: 'h', + yanchor: 'top', + y: -0.2, + xanchor: 'right', + x: 1, + itemclick: false, + itemdoubleclick: false, + }, + }; + + const plotConfig = { + displaylogo: false, + responsive: true, + }; + + return ( + <> + + {plotData.map((plot) => ( + +
+ {plot.name} {endYear} +
+ + +
+ ))} +
+ + ); +}; + +export default DimensionalPieGraph; diff --git a/components/paths/outcome/OutcomeNodeContent.tsx b/components/paths/outcome/OutcomeNodeContent.tsx index 990b3ec0..5bd24282 100644 --- a/components/paths/outcome/OutcomeNodeContent.tsx +++ b/components/paths/outcome/OutcomeNodeContent.tsx @@ -14,6 +14,7 @@ import { import Icon from '@/components/common/Icon'; import DataTable from '@/components/paths/graphs/DataTable'; import DimensionalNodePlot from '@/components/paths/graphs/DimensionalNodePlot'; +import DimensionalPieGraph from '@/components/paths/graphs/DimensionalPieGraph'; import HighlightValue from '@/components/paths/HighlightValue'; import OutcomeNodeDetails from '@/components/paths/outcome/OutcomeNodeDetails'; import ScenarioBadge from '@/components/paths/ScenarioBadge'; @@ -135,7 +136,7 @@ const OutcomeNodeContent = ({ const paths = usePaths(); const instance = paths?.instance; if (!instance) return null; - const showDistribution = instance.id === 'zuerich' && subNodes.length > 1; + const showDistribution = subNodes.length > 1; const nodesTotal = getMetricValue(node, endYear); const nodesBase = getMetricValue(node, startYear); const lastMeasuredYear = @@ -174,8 +175,7 @@ const OutcomeNodeContent = ({ const singleYearGraph = useMemo( () => (
-

COMING SOON

- {/**/} +
), [node, endYear, color] diff --git a/queries/paths/get-paths-page.ts b/queries/paths/get-paths-page.ts index e2bb49e8..54333e0d 100644 --- a/queries/paths/get-paths-page.ts +++ b/queries/paths/get-paths-page.ts @@ -138,6 +138,18 @@ const OUTCOME_NODE_FIELDS = gql` ${DimensionalMetricFragment} `; +export const GET_OUTCOME_NODE = gql` + ${OUTCOME_NODE_FIELDS} + query GetOutcomeNodeContent($node: ID!, $goal: ID) { + node(id: $node) { + ...OutcomeNodeFields + upstreamNodes(sameQuantity: true, sameUnit: true, includeActions: false) { + ...OutcomeNodeFields + } + } + } +`; + const GET_PAGE = gql` ${OUTCOME_NODE_FIELDS} query GetPage($path: String!, $goal: ID) { diff --git a/utils/paths/metric.ts b/utils/paths/metric.ts index 2953cecd..15a218b4 100644 --- a/utils/paths/metric.ts +++ b/utils/paths/metric.ts @@ -512,19 +512,24 @@ export class DimensionalMetric { const rows = categoryTypes[0].options.map((rowId) => categoryTypes[1]?.options.map((columnId) => { - return ( - yearRows.find( - (yearRow) => - ((categoryTypes[0].type === 'group' && - yearRow.dimCats[categoryTypes[0].id].group === rowId) || - (categoryTypes[0].type === 'category' && - yearRow.dimCats[categoryTypes[0].id].id === rowId)) && - ((categoryTypes[1].type === 'group' && - yearRow.dimCats[categoryTypes[1].id].group === columnId) || - (categoryTypes[1].type === 'category' && - yearRow.dimCats[categoryTypes[1].id].id === columnId)) - )?.value ?? null - ); + // We collect multiple rows as for groups we need values of all their categories + const matchingRows = yearRows.filter((yearRow) => { + const rowCategory0 = yearRow.dimCats[categoryTypes[0].id]; + const rowCategory1 = yearRow.dimCats[categoryTypes[1].id]; + const matchRow = + (categoryTypes[0].type === 'group' && + rowCategory0.group === rowId) || + (categoryTypes[0].type === 'category' && rowCategory0.id === rowId); + const matchCol = + (categoryTypes[1].type === 'group' && + rowCategory1.group === columnId) || + (categoryTypes[1].type === 'category' && + rowCategory1.id === columnId); + return matchRow && matchCol; + }); + return matchingRows.length + ? matchingRows.reduce((a, b) => (b.value ? a + b.value : 0), 0) + : null; }) );