From 1bfa2e270a66a1e562672156147bcf80bcd644f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joa=CC=83o=20Sobral?= <joao.tiago.sobral@gmail.com> Date: Tue, 26 Nov 2024 14:19:51 +0000 Subject: [PATCH 01/54] =?UTF-8?q?chore=20=F0=9F=A7=B9:=20add=20.vercel=20t?= =?UTF-8?q?o=20gitignore?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index f5d5b316b..f2a8d647f 100644 --- a/.gitignore +++ b/.gitignore @@ -23,4 +23,5 @@ playwright-report app/public/storybook -certificates \ No newline at end of file +certificates +.vercel From b1ea69a112b6c1a262a4c9b87b9a9557a03c557a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joa=CC=83o=20Sobral?= <joao.tiago.sobral@gmail.com> Date: Tue, 26 Nov 2024 14:21:24 +0000 Subject: [PATCH 02/54] =?UTF-8?q?feat=20=E2=9A=A1=EF=B8=8F:=20replicate=20?= =?UTF-8?q?column=20chart=20as=20bar=20chart?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/charts/bar/bars-grouped-state-props.ts | 157 ++++++ app/charts/bar/bars-grouped-state.tsx | 471 ++++++++++++++++++ app/charts/bar/bars-grouped.tsx | 155 ++++++ app/charts/bar/bars-stacked-state-props.ts | 168 +++++++ app/charts/bar/bars-stacked-state.tsx | 540 +++++++++++++++++++++ app/charts/bar/bars-stacked.tsx | 67 +++ app/charts/bar/bars-state-props.ts | 136 ++++++ app/charts/bar/bars-state.tsx | 309 ++++++++++++ app/charts/bar/bars.tsx | 147 ++++++ app/charts/bar/chart-bar.tsx | 140 ++++++ app/charts/bar/constants.ts | 3 + app/charts/bar/overlay-bars.tsx | 44 ++ app/charts/bar/rendering-utils.ts | 63 +++ app/charts/chart-config-ui-options.ts | 158 +++++- app/charts/index.ts | 172 ++++++- app/charts/shared/chart-state.ts | 10 +- app/components/chart-with-filters.tsx | 10 + app/config-types.ts | 96 +++- app/icons/index.tsx | 2 + 19 files changed, 2830 insertions(+), 18 deletions(-) create mode 100644 app/charts/bar/bars-grouped-state-props.ts create mode 100644 app/charts/bar/bars-grouped-state.tsx create mode 100644 app/charts/bar/bars-grouped.tsx create mode 100644 app/charts/bar/bars-stacked-state-props.ts create mode 100644 app/charts/bar/bars-stacked-state.tsx create mode 100644 app/charts/bar/bars-stacked.tsx create mode 100644 app/charts/bar/bars-state-props.ts create mode 100644 app/charts/bar/bars-state.tsx create mode 100644 app/charts/bar/bars.tsx create mode 100644 app/charts/bar/chart-bar.tsx create mode 100644 app/charts/bar/constants.ts create mode 100644 app/charts/bar/overlay-bars.tsx create mode 100644 app/charts/bar/rendering-utils.ts diff --git a/app/charts/bar/bars-grouped-state-props.ts b/app/charts/bar/bars-grouped-state-props.ts new file mode 100644 index 000000000..a53dc865a --- /dev/null +++ b/app/charts/bar/bars-grouped-state-props.ts @@ -0,0 +1,157 @@ +import { ascending, rollup, sum } from "d3-array"; +import orderBy from "lodash/orderBy"; +import { useCallback, useMemo } from "react"; + +import { usePlottableData } from "@/charts/shared/chart-helpers"; +import { + BandXVariables, + BaseVariables, + ChartStateData, + InteractiveFiltersVariables, + NumericalYErrorVariables, + NumericalYVariables, + RenderingVariables, + SegmentVariables, + SortingVariables, + useBandXVariables, + useBaseVariables, + useChartData, + useInteractiveFiltersVariables, + useNumericalYErrorVariables, + useNumericalYVariables, + useSegmentVariables, +} from "@/charts/shared/chart-state"; +import { useRenderingKeyVariable } from "@/charts/shared/rendering-utils"; +import { BarConfig, useChartConfigFilters } from "@/configurator"; +import { Observation, isTemporalEntityDimension } from "@/domain/data"; +import { sortByIndex } from "@/utils/array"; + +import { ChartProps } from "../shared/ChartProps"; + +export type BarsGroupedStateVariables = BaseVariables & + SortingVariables & + BandXVariables & + NumericalYVariables & + NumericalYErrorVariables & + SegmentVariables & + RenderingVariables & + InteractiveFiltersVariables; + +export const useBarsGroupedStateVariables = ( + props: ChartProps<BarConfig> +): BarsGroupedStateVariables => { + const { + chartConfig, + observations, + dimensions, + dimensionsById, + measures, + measuresById, + } = props; + const { fields, interactiveFiltersConfig } = chartConfig; + const { x, y, segment, animation } = fields; + const xDimension = dimensionsById[x.componentId]; + const filters = useChartConfigFilters(chartConfig); + + const baseVariables = useBaseVariables(chartConfig); + const bandXVariables = useBandXVariables(x, { + dimensionsById, + observations, + }); + const numericalYVariables = useNumericalYVariables("bar", y, { + measuresById, + }); + const numericalYErrorVariables = useNumericalYErrorVariables(y, { + numericalYVariables, + dimensions, + measures, + }); + const segmentVariables = useSegmentVariables(segment, { + dimensionsById, + observations, + }); + const interactiveFiltersVariables = useInteractiveFiltersVariables( + interactiveFiltersConfig, + { dimensionsById } + ); + + const { getX, getXAsDate } = bandXVariables; + const { getY } = numericalYVariables; + const sortData: BarsGroupedStateVariables["sortData"] = useCallback( + (data) => { + const { sortingOrder, sortingType } = x.sorting ?? {}; + const xGetter = isTemporalEntityDimension(xDimension) + ? (d: Observation) => getXAsDate(d).getTime().toString() + : getX; + const order = [ + ...rollup( + data, + (v) => sum(v, (d) => getY(d)), + (d) => xGetter(d) + ), + ] + .sort((a, b) => ascending(a[1], b[1])) + .map((d) => d[0]); + + if (sortingType === "byDimensionLabel") { + return orderBy(data, xGetter, sortingOrder); + } else if (sortingType === "byMeasure") { + return sortByIndex({ data, order, getCategory: xGetter, sortingOrder }); + } else { + return orderBy(data, xGetter, "asc"); + } + }, + [getX, getXAsDate, getY, x.sorting, xDimension] + ); + + const getRenderingKey = useRenderingKeyVariable( + dimensions, + filters, + interactiveFiltersConfig, + animation + ); + + return { + ...baseVariables, + sortData, + ...bandXVariables, + ...numericalYVariables, + ...numericalYErrorVariables, + ...segmentVariables, + ...interactiveFiltersVariables, + getRenderingKey, + }; +}; + +export const useBarsGroupedStateData = ( + chartProps: ChartProps<BarConfig>, + variables: BarsGroupedStateVariables +): ChartStateData => { + const { chartConfig, observations } = chartProps; + const { + sortData, + xDimension, + getXAsDate, + getY, + getSegmentAbbreviationOrLabel, + getTimeRangeDate, + } = variables; + const plottableData = usePlottableData(observations, { + getY, + }); + const sortedPlottableData = useMemo(() => { + return sortData(plottableData); + }, [sortData, plottableData]); + const data = useChartData(sortedPlottableData, { + chartConfig, + timeRangeDimensionId: xDimension.id, + getXAsDate, + getSegmentAbbreviationOrLabel, + getTimeRangeDate, + }); + + return { + ...data, + allData: sortedPlottableData, + }; +}; diff --git a/app/charts/bar/bars-grouped-state.tsx b/app/charts/bar/bars-grouped-state.tsx new file mode 100644 index 000000000..302346410 --- /dev/null +++ b/app/charts/bar/bars-grouped-state.tsx @@ -0,0 +1,471 @@ +import { extent, group, max, rollup, sum } from "d3-array"; +import { + ScaleBand, + scaleBand, + ScaleLinear, + scaleLinear, + ScaleOrdinal, + scaleOrdinal, + scaleTime, +} from "d3-scale"; +import { schemeCategory10 } from "d3-scale-chromatic"; +import orderBy from "lodash/orderBy"; +import { useMemo } from "react"; + +import { + BarsGroupedStateVariables, + useBarsGroupedStateData, + useBarsGroupedStateVariables, +} from "@/charts/bar/bars-grouped-state-props"; +import { + PADDING_INNER, + PADDING_OUTER, + PADDING_WITHIN, +} from "@/charts/bar/constants"; +import { + useAxisLabelHeightOffset, + useChartBounds, + useChartPadding, +} from "@/charts/shared/chart-dimensions"; +import { + ChartContext, + ChartStateData, + CommonChartState, + InteractiveXTimeRangeState, +} from "@/charts/shared/chart-state"; +import { TooltipInfo } from "@/charts/shared/interaction/tooltip"; +import { + getCenteredTooltipPlacement, + MOBILE_TOOLTIP_PLACEMENT, +} from "@/charts/shared/interaction/tooltip-box"; +import useChartFormatters from "@/charts/shared/use-chart-formatters"; +import { InteractionProvider } from "@/charts/shared/use-interaction"; +import { useSize } from "@/charts/shared/use-size"; +import { BarConfig } from "@/configurator"; +import { Observation } from "@/domain/data"; +import { formatNumberWithUnit, useFormatNumber } from "@/formatters"; +import { getPalette } from "@/palettes"; +import { sortByIndex } from "@/utils/array"; +import { + getSortingOrders, + makeDimensionValueSorters, +} from "@/utils/sorting-values"; +import { useIsMobile } from "@/utils/use-is-mobile"; + +import { ChartProps } from "../shared/ChartProps"; + +export type GroupedBarsState = CommonChartState & + BarsGroupedStateVariables & + InteractiveXTimeRangeState & { + chartType: "bar"; + xScale: ScaleBand<string>; + xScaleInteraction: ScaleBand<string>; + xScaleIn: ScaleBand<string>; + yScale: ScaleLinear<number, number>; + segments: string[]; + colors: ScaleOrdinal<string, string>; + getColorLabel: (segment: string) => string; + grouped: [string, Observation[]][]; + getAnnotationInfo: (d: Observation) => TooltipInfo; + }; + +const useBarsGroupedState = ( + chartProps: ChartProps<BarConfig>, + variables: BarsGroupedStateVariables, + data: ChartStateData +): GroupedBarsState => { + const { chartConfig } = chartProps; + const { + xDimension, + getX, + getXAsDate, + getXAbbreviationOrLabel, + getXLabel, + yMeasure, + getY, + getMinY, + showYStandardError, + yErrorMeasure, + getYError, + getYErrorRange, + segmentDimension, + segmentsByAbbreviationOrLabel, + getSegment, + getSegmentAbbreviationOrLabel, + getSegmentLabel, + } = variables; + const { + chartData, + scalesData, + segmentData, + timeRangeData, + paddingData, + allData, + } = data; + const { fields, interactiveFiltersConfig } = chartConfig; + + const { width, height } = useSize(); + const formatNumber = useFormatNumber({ decimals: "auto" }); + const formatters = useChartFormatters(chartProps); + + const segmentsByValue = useMemo(() => { + const values = segmentDimension?.values || []; + + return new Map(values.map((d) => [d.value, d])); + }, [segmentDimension?.values]); + + // segments + const segmentSortingOrder = fields.segment?.sorting?.sortingOrder; + + const sumsBySegment = useMemo(() => { + return Object.fromEntries( + rollup( + segmentData, + (v) => sum(v, (x) => getY(x)), + (x) => getSegment(x) + ) + ); + }, [segmentData, getY, getSegment]); + + const segmentFilter = segmentDimension?.id + ? chartConfig.cubes.find((d) => d.iri === segmentDimension.cubeIri) + ?.filters[segmentDimension.id] + : undefined; + const { allSegments, segments } = useMemo(() => { + const allUniqueSegments = Array.from( + new Set(segmentData.map((d) => getSegment(d))) + ); + const uniqueSegments = Array.from( + new Set(scalesData.map((d) => getSegment(d))) + ); + const sorting = fields?.segment?.sorting; + const sorters = makeDimensionValueSorters(segmentDimension, { + sorting, + sumsBySegment, + useAbbreviations: fields.segment?.useAbbreviations, + dimensionFilter: segmentFilter, + }); + const allSegments = orderBy( + allUniqueSegments, + sorters, + getSortingOrders(sorters, sorting) + ); + + return { + allSegments, + segments: allSegments.filter((d) => uniqueSegments.includes(d)), + }; + }, [ + scalesData, + segmentData, + segmentDimension, + fields.segment?.sorting, + fields.segment?.useAbbreviations, + sumsBySegment, + segmentFilter, + getSegment, + ]); + + /* Scales */ + const xFilter = chartConfig.cubes.find((d) => d.iri === xDimension.cubeIri) + ?.filters[xDimension.id]; + const sumsByX = useMemo(() => { + return Object.fromEntries( + rollup( + chartData, + (v) => sum(v, (x) => getY(x)), + (x) => getX(x) + ) + ); + }, [chartData, getX, getY]); + + const { + xTimeRangeDomainLabels, + colors, + yScale, + paddingYScale, + xScaleTimeRange, + xScale, + xScaleIn, + xScaleInteraction, + } = useMemo(() => { + const colors = scaleOrdinal<string, string>(); + + if (fields.segment && segmentDimension && fields.segment.colorMapping) { + const orderedSegmentLabelsAndColors = allSegments.map((segment) => { + const dvIri = + segmentsByAbbreviationOrLabel.get(segment)?.value || + segmentsByValue.get(segment)?.value || + ""; + + return { + label: segment, + color: fields.segment?.colorMapping![dvIri] ?? schemeCategory10[0], + }; + }); + + colors.domain(orderedSegmentLabelsAndColors.map((s) => s.label)); + colors.range(orderedSegmentLabelsAndColors.map((s) => s.color)); + } else { + colors.domain(allSegments); + colors.range(getPalette(fields.segment?.palette)); + } + + colors.unknown(() => undefined); + + const xValues = [...new Set(scalesData.map(getX))]; + const xTimeRangeValues = [...new Set(timeRangeData.map(getX))]; + const xSorting = fields.x?.sorting; + const xSorters = makeDimensionValueSorters(xDimension, { + sorting: xSorting, + useAbbreviations: fields.x?.useAbbreviations, + measureBySegment: sumsByX, + dimensionFilter: xFilter, + }); + const xDomain = orderBy( + xValues, + xSorters, + getSortingOrders(xSorters, xSorting) + ); + const xTimeRangeDomainLabels = xTimeRangeValues.map(getXLabel); + const xScale = scaleBand() + .domain(xDomain) + .paddingInner(PADDING_INNER) + .paddingOuter(PADDING_OUTER); + const xScaleInteraction = scaleBand() + .domain(xDomain) + .paddingInner(0) + .paddingOuter(0); + const xScaleIn = scaleBand().domain(segments).padding(PADDING_WITHIN); + + const xScaleTimeRangeDomain = extent(timeRangeData, (d) => + getXAsDate(d) + ) as [Date, Date]; + const xScaleTimeRange = scaleTime().domain(xScaleTimeRangeDomain); + + // y + const minValue = getMinY(scalesData, (d) => + getYErrorRange ? getYErrorRange(d)[0] : getY(d) + ); + const maxValue = Math.max( + max(scalesData, (d) => + getYErrorRange ? getYErrorRange(d)[1] : getY(d) + ) ?? 0, + 0 + ); + const yScale = scaleLinear().domain([minValue, maxValue]).nice(); + + const minPaddingValue = getMinY(paddingData, (d) => + getYErrorRange ? getYErrorRange(d)[0] : getY(d) + ); + const maxPaddingValue = Math.max( + max(paddingData, (d) => + getYErrorRange ? getYErrorRange(d)[1] : getY(d) + ) ?? 0, + 0 + ); + const paddingYScale = scaleLinear() + .domain([minPaddingValue, maxPaddingValue]) + .nice(); + + return { + colors, + yScale, + paddingYScale, + xScaleTimeRange, + xScale, + xScaleIn, + xScaleInteraction, + xTimeRangeDomainLabels, + }; + }, [ + fields.segment, + fields.x?.sorting, + fields.x?.useAbbreviations, + segmentDimension, + scalesData, + getX, + xDimension, + sumsByX, + xFilter, + getXLabel, + segments, + timeRangeData, + paddingData, + allSegments, + segmentsByAbbreviationOrLabel, + segmentsByValue, + getXAsDate, + getYErrorRange, + getY, + getMinY, + ]); + + // Group + const grouped: [string, Observation[]][] = useMemo(() => { + const xKeys = xScale.domain(); + const groupedMap = group(chartData, getX); + const grouped: [string, Observation[]][] = + groupedMap.size < xKeys.length + ? xKeys.map((d) => { + if (groupedMap.has(d)) { + return [d, groupedMap.get(d) as Observation[]]; + } else { + return [d, []]; + } + }) + : [...groupedMap]; + + return grouped.map(([key, data]) => { + return [ + key, + sortByIndex({ + data, + order: segments, + getCategory: getSegment, + sortingOrder: segmentSortingOrder, + }), + ]; + }); + }, [getSegment, getX, chartData, segmentSortingOrder, segments, xScale]); + + const { left, bottom } = useChartPadding({ + yScale: paddingYScale, + width, + height, + interactiveFiltersConfig, + animationPresent: !!fields.animation, + formatNumber, + bandDomain: xTimeRangeDomainLabels.every((d) => d === undefined) + ? xScale.domain() + : xTimeRangeDomainLabels, + }); + const right = 40; + const { offset: yAxisLabelMargin } = useAxisLabelHeightOffset({ + label: yMeasure.label, + width, + marginLeft: left, + marginRight: right, + }); + const margins = { + top: 50 + yAxisLabelMargin, + right, + bottom, + left, + }; + const bounds = useChartBounds(width, margins, height); + const { chartWidth, chartHeight } = bounds; + + // Adjust of scales based on chart dimensions + xScale.range([0, chartWidth]); + xScaleInteraction.range([0, chartWidth]); + xScaleIn.range([0, xScale.bandwidth()]); + xScaleTimeRange.range([0, chartWidth]); + yScale.range([chartHeight, 0]); + + const isMobile = useIsMobile(); + + // Tooltip + const getAnnotationInfo = (datum: Observation): TooltipInfo => { + const bw = xScale.bandwidth(); + const x = getX(datum); + + const tooltipValues = chartData.filter((d) => getX(d) === x); + const yValues = tooltipValues.map(getY); + const sortedTooltipValues = sortByIndex({ + data: tooltipValues, + order: segments, + getCategory: getSegment, + // Always ascending to match visual order of colors of the stack + sortingOrder: "asc", + }); + const yValueFormatter = (value: number | null) => { + return formatNumberWithUnit( + value, + formatters[yMeasure.id] ?? formatNumber, + yMeasure.unit + ); + }; + + const xAnchorRaw = (xScale(x) as number) + bw * 0.5; + const [yMin, yMax] = extent(yValues, (d) => d ?? 0) as [number, number]; + const yAnchor = isMobile ? chartHeight : yScale((yMin + yMax) * 0.5); + const placement = isMobile + ? MOBILE_TOOLTIP_PLACEMENT + : getCenteredTooltipPlacement({ + chartWidth, + xAnchor: xAnchorRaw, + topAnchor: !fields.segment, + }); + + const getError = (d: Observation) => { + if (!showYStandardError || !getYError || getYError(d) == null) { + return; + } + + return `${getYError(d)}${yErrorMeasure?.unit ?? ""}`; + }; + + return { + xAnchor: xAnchorRaw + (placement.x === "right" ? 0.5 : -0.5) * bw, + yAnchor, + placement, + xValue: getXAbbreviationOrLabel(datum), + datum: { + label: fields.segment && getSegmentAbbreviationOrLabel(datum), + value: yValueFormatter(getY(datum)), + error: getError(datum), + color: colors(getSegment(datum)) as string, + }, + values: sortedTooltipValues.map((td) => ({ + label: getSegmentAbbreviationOrLabel(td), + value: yMeasure.unit + ? `${formatNumber(getY(td))} ${yMeasure.unit}` + : formatNumber(getY(td)), + error: getError(td), + color: colors(getSegment(td)) as string, + })), + }; + }; + + return { + chartType: "bar", + bounds, + chartData, + allData, + xScale, + xScaleInteraction, + xScaleIn, + xScaleTimeRange, + yScale, + segments, + colors, + getColorLabel: getSegmentLabel, + grouped, + getAnnotationInfo, + ...variables, + }; +}; + +const GroupedBarChartProvider = ( + props: React.PropsWithChildren<ChartProps<BarConfig>> +) => { + const { children, ...chartProps } = props; + const variables = useBarsGroupedStateVariables(chartProps); + const data = useBarsGroupedStateData(chartProps, variables); + const state = useBarsGroupedState(chartProps, variables, data); + + return ( + <ChartContext.Provider value={state}>{children}</ChartContext.Provider> + ); +}; + +export const GroupedBarChart = ( + props: React.PropsWithChildren<ChartProps<BarConfig>> +) => { + return ( + <InteractionProvider> + <GroupedBarChartProvider {...props} /> + </InteractionProvider> + ); +}; diff --git a/app/charts/bar/bars-grouped.tsx b/app/charts/bar/bars-grouped.tsx new file mode 100644 index 000000000..c293fd57d --- /dev/null +++ b/app/charts/bar/bars-grouped.tsx @@ -0,0 +1,155 @@ +import { useEffect, useMemo, useRef } from "react"; + +import { GroupedBarsState } from "@/charts/bar/bars-grouped-state"; +import { RenderBarDatum, renderBars } from "@/charts/bar/rendering-utils"; +import { useChartState } from "@/charts/shared/chart-state"; +import { + RenderWhiskerDatum, + filterWithoutErrors, + renderContainer, + renderWhiskers, +} from "@/charts/shared/rendering-utils"; +import { useTransitionStore } from "@/stores/transition"; + +export const ErrorWhiskers = () => { + const { + bounds, + xScale, + xScaleIn, + getYErrorRange, + getYError, + yScale, + getSegment, + grouped, + showYStandardError, + } = useChartState() as GroupedBarsState; + const { margins, width, height } = bounds; + const ref = useRef<SVGGElement>(null); + const enableTransition = useTransitionStore((state) => state.enable); + const transitionDuration = useTransitionStore((state) => state.duration); + const renderData: RenderWhiskerDatum[] = useMemo(() => { + if (!getYErrorRange || !showYStandardError) { + return []; + } + + const bandwidth = xScaleIn.bandwidth(); + return grouped + .filter((d) => d[1].some(filterWithoutErrors(getYError))) + .flatMap(([segment, observations]) => + observations.map((d) => { + const x0 = xScaleIn(getSegment(d)) as number; + const barWidth = Math.min(bandwidth, 15); + const [y1, y2] = getYErrorRange(d); + return { + key: `${segment}-${getSegment(d)}`, + x: (xScale(segment) as number) + x0 + bandwidth / 2 - barWidth / 2, + y1: yScale(y1), + y2: yScale(y2), + width: barWidth, + } as RenderWhiskerDatum; + }) + ); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + getSegment, + getYErrorRange, + getYError, + grouped, + showYStandardError, + xScale, + xScaleIn, + yScale, + width, + height, + ]); + + useEffect(() => { + if (ref.current) { + renderContainer(ref.current, { + id: "bars-grouped-error-whiskers", + transform: `translate(${margins.left} ${margins.top})`, + transition: { enable: enableTransition, duration: transitionDuration }, + render: (g, opts) => renderWhiskers(g, renderData, opts), + }); + } + }, [ + enableTransition, + margins.left, + margins.top, + renderData, + transitionDuration, + ]); + + return <g ref={ref} />; +}; + +export const BarsGrouped = () => { + const { + bounds, + xScale, + xScaleIn, + getY, + yScale, + getSegment, + colors, + grouped, + getRenderingKey, + } = useChartState() as GroupedBarsState; + const ref = useRef<SVGGElement>(null); + const enableTransition = useTransitionStore((state) => state.enable); + const transitionDuration = useTransitionStore((state) => state.duration); + const { margins, height } = bounds; + const bandwidth = xScaleIn.bandwidth(); + const y0 = yScale(0); + const renderData: RenderBarDatum[] = useMemo(() => { + return grouped.flatMap(([segment, observations]) => { + return observations.map((d) => { + const key = getRenderingKey(d, getSegment(d)); + const x = getSegment(d); + const y = getY(d) ?? NaN; + + return { + key, + x: (xScale(segment) as number) + (xScaleIn(x) as number), + y: yScale(Math.max(y, 0)), + width: bandwidth, + height: Math.max(0, Math.abs(yScale(y) - y0)), + color: colors(x), + }; + }); + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + colors, + getSegment, + bandwidth, + getY, + grouped, + xScaleIn, + xScale, + yScale, + y0, + getRenderingKey, + height, + ]); + + useEffect(() => { + if (ref.current) { + renderContainer(ref.current, { + id: "bars-grouped", + transform: `translate(${margins.left} ${margins.top})`, + transition: { enable: enableTransition, duration: transitionDuration }, + render: (g, opts) => renderBars(g, renderData, { ...opts, y0 }), + }); + } + }, [ + enableTransition, + margins.left, + margins.top, + renderData, + transitionDuration, + y0, + ]); + + return <g ref={ref} />; +}; diff --git a/app/charts/bar/bars-stacked-state-props.ts b/app/charts/bar/bars-stacked-state-props.ts new file mode 100644 index 000000000..f8584cc91 --- /dev/null +++ b/app/charts/bar/bars-stacked-state-props.ts @@ -0,0 +1,168 @@ +import { ascending, descending, group } from "d3-array"; +import { useCallback, useMemo } from "react"; + +import { getWideData, usePlottableData } from "@/charts/shared/chart-helpers"; +import { + BandXVariables, + BaseVariables, + ChartStateData, + InteractiveFiltersVariables, + NumericalYVariables, + RenderingVariables, + SegmentVariables, + SortingVariables, + useBandXVariables, + useBaseVariables, + useChartData, + useInteractiveFiltersVariables, + useNumericalYVariables, + useSegmentVariables, +} from "@/charts/shared/chart-state"; +import { useRenderingKeyVariable } from "@/charts/shared/rendering-utils"; +import { BarConfig, useChartConfigFilters } from "@/configurator"; +import { Observation, isTemporalEntityDimension } from "@/domain/data"; +import { sortByIndex } from "@/utils/array"; + +import { ChartProps } from "../shared/ChartProps"; + +export type BarsStackedStateVariables = BaseVariables & + SortingVariables<{ plottableDataWide: Observation[] }> & + BandXVariables & + NumericalYVariables & + SegmentVariables & + RenderingVariables & + InteractiveFiltersVariables; + +export const useBarsStackedStateVariables = ( + props: ChartProps<BarConfig> +): BarsStackedStateVariables => { + const { + chartConfig, + observations, + dimensions, + dimensionsById, + measuresById, + } = props; + const { fields, interactiveFiltersConfig } = chartConfig; + const { x, y, segment, animation } = fields; + const xDimension = dimensionsById[x.componentId]; + const filters = useChartConfigFilters(chartConfig); + + const baseVariables = useBaseVariables(chartConfig); + const bandXVariables = useBandXVariables(x, { + dimensionsById, + observations, + }); + const numericalYVariables = useNumericalYVariables("bar", y, { + measuresById, + }); + const segmentVariables = useSegmentVariables(segment, { + dimensionsById, + observations, + }); + const interactiveFiltersVariables = useInteractiveFiltersVariables( + interactiveFiltersConfig, + { dimensionsById } + ); + + const { getX, getXAsDate } = bandXVariables; + const sortData: BarsStackedStateVariables["sortData"] = useCallback( + (data, { plottableDataWide }) => { + const { sortingOrder, sortingType } = x.sorting ?? {}; + const xGetter = isTemporalEntityDimension(xDimension) + ? (d: Observation) => getXAsDate(d).getTime().toString() + : getX; + const xOrder = plottableDataWide + .sort((a, b) => ascending(a.total ?? undefined, b.total ?? undefined)) + .map(xGetter); + + if (sortingOrder === "desc" && sortingType === "byDimensionLabel") { + return [...data].sort((a, b) => descending(xGetter(a), xGetter(b))); + } else if (sortingOrder === "asc" && sortingType === "byDimensionLabel") { + return [...data].sort((a, b) => ascending(xGetter(a), xGetter(b))); + } else if (sortingType === "byMeasure") { + return sortByIndex({ + data, + order: xOrder, + getCategory: xGetter, + sortingOrder, + }); + } else { + return [...data].sort((a, b) => ascending(xGetter(a), xGetter(b))); + } + }, + [getX, getXAsDate, x.sorting, xDimension] + ); + + const getRenderingKey = useRenderingKeyVariable( + dimensions, + filters, + interactiveFiltersConfig, + animation + ); + + return { + ...baseVariables, + sortData, + ...bandXVariables, + ...numericalYVariables, + ...segmentVariables, + ...interactiveFiltersVariables, + getRenderingKey, + }; +}; + +export type BarsStackedStateData = ChartStateData & { + plottableDataWide: Observation[]; +}; + +export const useBarsStackedStateData = ( + chartProps: ChartProps<BarConfig>, + variables: BarsStackedStateVariables +): BarsStackedStateData => { + const { chartConfig, observations } = chartProps; + const { fields } = chartConfig; + const { x } = fields; + const { + sortData, + xDimension, + getX, + getXAsDate, + getY, + getSegment, + getSegmentAbbreviationOrLabel, + getTimeRangeDate, + } = variables; + const plottableData = usePlottableData(observations, { + getY, + }); + const { sortedPlottableData, plottableDataWide } = useMemo(() => { + const plottableDataByX = group(plottableData, getX); + const plottableDataWide = getWideData({ + dataGroupedByX: plottableDataByX, + xKey: x.componentId, + getY, + getSegment, + }); + + return { + sortedPlottableData: sortData(plottableData, { + plottableDataWide, + }), + plottableDataWide, + }; + }, [plottableData, getX, x.componentId, getY, getSegment, sortData]); + const data = useChartData(sortedPlottableData, { + chartConfig, + timeRangeDimensionId: xDimension.id, + getXAsDate, + getSegmentAbbreviationOrLabel, + getTimeRangeDate, + }); + + return { + ...data, + allData: sortedPlottableData, + plottableDataWide, + }; +}; diff --git a/app/charts/bar/bars-stacked-state.tsx b/app/charts/bar/bars-stacked-state.tsx new file mode 100644 index 000000000..d6d58ce24 --- /dev/null +++ b/app/charts/bar/bars-stacked-state.tsx @@ -0,0 +1,540 @@ +import { extent, group, rollup, sum } from "d3-array"; +import { + ScaleBand, + scaleBand, + ScaleLinear, + scaleLinear, + ScaleOrdinal, + scaleOrdinal, + scaleTime, +} from "d3-scale"; +import { schemeCategory10 } from "d3-scale-chromatic"; +import { + stack, + stackOffsetDiverging, + stackOrderAscending, + stackOrderDescending, + stackOrderReverse, +} from "d3-shape"; +import orderBy from "lodash/orderBy"; +import React, { useCallback, useMemo } from "react"; + +import { + BarsStackedStateData, + BarsStackedStateVariables, + useBarsStackedStateData, + useBarsStackedStateVariables, +} from "@/charts/bar/bars-stacked-state-props"; +import { PADDING_INNER, PADDING_OUTER } from "@/charts/bar/constants"; +import { + useAxisLabelHeightOffset, + useChartBounds, + useChartPadding, +} from "@/charts/shared/chart-dimensions"; +import { + getWideData, + normalizeData, + useGetIdentityY, +} from "@/charts/shared/chart-helpers"; +import { + ChartContext, + CommonChartState, + InteractiveXTimeRangeState, +} from "@/charts/shared/chart-state"; +import { TooltipInfo } from "@/charts/shared/interaction/tooltip"; +import { + getCenteredTooltipPlacement, + MOBILE_TOOLTIP_PLACEMENT, +} from "@/charts/shared/interaction/tooltip-box"; +import { + getStackedTooltipValueFormatter, + getStackedYScale, +} from "@/charts/shared/stacked-helpers"; +import useChartFormatters from "@/charts/shared/use-chart-formatters"; +import { InteractionProvider } from "@/charts/shared/use-interaction"; +import { useSize } from "@/charts/shared/use-size"; +import { BarConfig } from "@/configurator"; +import { Observation } from "@/domain/data"; +import { useFormatNumber } from "@/formatters"; +import { getPalette } from "@/palettes"; +import { useChartInteractiveFilters } from "@/stores/interactive-filters"; +import { sortByIndex } from "@/utils/array"; +import { + getSortingOrders, + makeDimensionValueSorters, +} from "@/utils/sorting-values"; +import { useIsMobile } from "@/utils/use-is-mobile"; + +import { ChartProps } from "../shared/ChartProps"; + +export type StackedBarsState = CommonChartState & + BarsStackedStateVariables & + InteractiveXTimeRangeState & { + chartType: "bar"; + xScale: ScaleBand<string>; + xScaleInteraction: ScaleBand<string>; + yScale: ScaleLinear<number, number>; + segments: string[]; + colors: ScaleOrdinal<string, string>; + getColorLabel: (segment: string) => string; + chartWideData: ArrayLike<Observation>; + series: $FixMe[]; + getAnnotationInfo: ( + d: Observation, + orderedSegments: string[] + ) => TooltipInfo; + }; + +const useBarsStackedState = ( + chartProps: ChartProps<BarConfig>, + variables: BarsStackedStateVariables, + data: BarsStackedStateData +): StackedBarsState => { + const { chartConfig } = chartProps; + const { + xDimension, + getX, + getXAsDate, + getXAbbreviationOrLabel, + getXLabel, + yMeasure, + getY, + segmentDimension, + segmentsByAbbreviationOrLabel, + getSegment, + getSegmentAbbreviationOrLabel, + getSegmentLabel, + } = variables; + const getIdentityY = useGetIdentityY(yMeasure.id); + const { + chartData, + scalesData, + segmentData, + timeRangeData, + paddingData, + allData, + } = data; + const { fields, interactiveFiltersConfig } = chartConfig; + + const { width, height } = useSize(); + const formatNumber = useFormatNumber({ decimals: "auto" }); + const formatters = useChartFormatters(chartProps); + const calculationType = useChartInteractiveFilters((d) => d.calculation.type); + + const xKey = fields.x.componentId; + + const segmentsByValue = useMemo(() => { + const values = segmentDimension?.values || []; + + return new Map(values.map((d) => [d.value, d])); + }, [segmentDimension?.values]); + + const sumsBySegment = useMemo(() => { + return Object.fromEntries( + rollup( + scalesData, + (v) => sum(v, (x) => getY(x)), + (x) => getSegment(x) + ) + ); + }, [getSegment, getY, scalesData]); + + const segmentFilter = segmentDimension?.id + ? chartConfig.cubes.find((d) => d.iri === segmentDimension.cubeIri) + ?.filters[segmentDimension.id] + : undefined; + const { allSegments, segments } = useMemo(() => { + const allUniqueSegments = Array.from(new Set(segmentData.map(getSegment))); + const uniqueSegments = Array.from(new Set(scalesData.map(getSegment))); + const sorting = fields?.segment?.sorting; + const sorters = makeDimensionValueSorters(segmentDimension, { + sorting, + sumsBySegment, + useAbbreviations: fields.segment?.useAbbreviations, + dimensionFilter: segmentFilter, + }); + const allSegments = orderBy( + allUniqueSegments, + sorters, + getSortingOrders(sorters, sorting) + ); + + return { + allSegments, + segments: allSegments.filter((d) => uniqueSegments.includes(d)), + }; + }, [ + scalesData, + segmentData, + segmentDimension, + fields.segment?.sorting, + fields.segment?.useAbbreviations, + sumsBySegment, + segmentFilter, + getSegment, + ]); + + const sumsByX = useMemo(() => { + return Object.fromEntries( + rollup( + chartData, + (v) => sum(v, (x) => getY(x)), + (x) => getX(x) + ) + ); + }, [chartData, getX, getY]); + + const normalize = calculationType === "percent"; + const chartDataGroupedByX = useMemo(() => { + if (normalize) { + return group( + normalizeData(chartData, { + yKey: yMeasure.id, + getY, + getTotalGroupValue: (d) => sumsByX[getX(d)], + }), + getX + ); + } + + return group(chartData, getX); + }, [chartData, getX, sumsByX, getY, yMeasure.id, normalize]); + + const chartWideData = useMemo(() => { + return getWideData({ + dataGroupedByX: chartDataGroupedByX, + xKey, + getY, + getSegment, + allSegments: segments, + imputationType: "zeros", + }); + }, [getSegment, getY, chartDataGroupedByX, segments, xKey]); + + const xFilter = chartConfig.cubes.find((d) => d.iri === xDimension.cubeIri) + ?.filters[xDimension.id]; + + // Map ordered segments labels to colors + const { + colors, + xScale, + xTimeRangeDomainLabels, + xScaleInteraction, + xScaleTimeRange, + } = useMemo(() => { + const colors = scaleOrdinal<string, string>(); + + if ( + fields.segment && + segmentsByAbbreviationOrLabel && + fields.segment.colorMapping + ) { + const orderedSegmentLabelsAndColors = allSegments.map((segment) => { + // FIXME: Labels in observations can differ from dimension values because the latter can be concatenated to only appear once per value + // See https://github.com/visualize-admin/visualization-tool/issues/97 + const dvIri = + segmentsByAbbreviationOrLabel.get(segment)?.value || + segmentsByValue.get(segment)?.value || + ""; + + // There is no way to gracefully recover here :( + if (!dvIri) { + console.warn(`Can't find color for '${segment}'.`); + } + + return { + label: segment, + color: fields.segment?.colorMapping![dvIri] ?? schemeCategory10[0], + }; + }); + + colors.domain(orderedSegmentLabelsAndColors.map((s) => s.label)); + colors.range(orderedSegmentLabelsAndColors.map((s) => s.color)); + } else { + colors.domain(allSegments); + colors.range(getPalette(fields.segment?.palette)); + } + + colors.unknown(() => undefined); + + const xValues = [...new Set(scalesData.map(getX))]; + const xTimeRangeValues = [...new Set(timeRangeData.map(getX))]; + const xSorting = fields.x?.sorting; + const xSorters = makeDimensionValueSorters(xDimension, { + sorting: xSorting, + useAbbreviations: fields.x?.useAbbreviations, + measureBySegment: sumsByX, + dimensionFilter: xFilter, + }); + const xDomain = orderBy( + xValues, + xSorters, + getSortingOrders(xSorters, xSorting) + ); + const xTimeRangeDomainLabels = xTimeRangeValues.map(getXLabel); + const xScale = scaleBand() + .domain(xDomain) + .paddingInner(PADDING_INNER) + .paddingOuter(PADDING_OUTER); + const xScaleInteraction = scaleBand() + .domain(xDomain) + .paddingInner(0) + .paddingOuter(0); + + const xScaleTimeRangeDomain = extent(timeRangeData, (d) => + getXAsDate(d) + ) as [Date, Date]; + const xScaleTimeRange = scaleTime().domain(xScaleTimeRangeDomain); + + return { + colors, + xScale, + xTimeRangeDomainLabels, + xScaleTimeRange, + xScaleInteraction, + }; + }, [ + fields.segment, + fields.x.sorting, + fields.x.useAbbreviations, + xDimension, + xFilter, + sumsByX, + getX, + getXLabel, + getXAsDate, + scalesData, + timeRangeData, + segmentsByAbbreviationOrLabel, + segmentsByValue, + allSegments, + ]); + + const animationIri = fields.animation?.componentId; + const getAnimation = useCallback( + (d: Observation) => { + return animationIri ? (d[animationIri] as string) : ""; + }, + [animationIri] + ); + + const yScale = useMemo(() => { + return getStackedYScale(scalesData, { + normalize, + getX, + getY, + getTime: getAnimation, + }); + }, [scalesData, normalize, getX, getY, getAnimation]); + + const paddingYScale = useMemo(() => { + // When the user can toggle between absolute and relative values, we use the + // absolute values to calculate the yScale domain, so that the yScale doesn't + // change when the user toggles between absolute and relative values. + if (interactiveFiltersConfig?.calculation.active) { + const scale = getStackedYScale(paddingData, { + normalize: false, + getX, + getY, + getTime: getAnimation, + }); + + if (scale.domain()[1] < 100 && scale.domain()[0] > -100) { + return scaleLinear().domain([0, 100]); + } + + return scale; + } + + return getStackedYScale(paddingData, { + normalize, + getX, + getY, + getTime: getAnimation, + }); + }, [ + interactiveFiltersConfig?.calculation.active, + paddingData, + normalize, + getX, + getY, + getAnimation, + ]); + + // stack order + const series = useMemo(() => { + const sorting = fields.segment?.sorting; + const sortingType = sorting?.sortingType; + const sortingOrder = sorting?.sortingOrder; + const stackOrder = + sortingType === "byTotalSize" + ? sortingOrder === "asc" + ? stackOrderAscending + : stackOrderDescending + : // Reverse segments here, so they're sorted from top to bottom + stackOrderReverse; + + const stacked = stack() + .order(stackOrder) + .offset(stackOffsetDiverging) + .keys(segments); + + return stacked( + chartWideData as { + [key: string]: number; + }[] + ); + }, [chartWideData, fields.segment?.sorting, segments]); + + /** Chart dimensions */ + const { left, bottom } = useChartPadding({ + yScale: paddingYScale, + width, + height, + interactiveFiltersConfig, + animationPresent: !!fields.animation, + formatNumber, + bandDomain: xTimeRangeDomainLabels.every((d) => d === undefined) + ? xScale.domain() + : xTimeRangeDomainLabels, + normalize, + }); + const right = 40; + const { offset: yAxisLabelMargin } = useAxisLabelHeightOffset({ + label: yMeasure.label, + width, + marginLeft: left, + marginRight: right, + }); + const margins = { + top: 50 + yAxisLabelMargin, + right, + bottom, + left, + }; + const bounds = useChartBounds(width, margins, height); + const { chartWidth, chartHeight } = bounds; + + xScale.range([0, chartWidth]); + xScaleInteraction.range([0, chartWidth]); + xScaleTimeRange.range([0, chartWidth]); + yScale.range([chartHeight, 0]); + + const isMobile = useIsMobile(); + + // Tooltips + const getAnnotationInfo = useCallback( + (datum: Observation): TooltipInfo => { + const bw = xScale.bandwidth(); + const x = getX(datum); + + const tooltipValues = chartDataGroupedByX.get(x) as Observation[]; + const yValues = tooltipValues.map(getY); + const sortedTooltipValues = sortByIndex({ + data: tooltipValues, + order: segments, + getCategory: getSegment, + sortingOrder: "asc", + }); + const yValueFormatter = getStackedTooltipValueFormatter({ + normalize, + yMeasureId: yMeasure.id, + yMeasureUnit: yMeasure.unit, + formatters, + formatNumber, + }); + + const xAnchorRaw = (xScale(x) as number) + bw * 0.5; + const yAnchor = isMobile + ? chartHeight + : yScale(sum(yValues.map((d) => d ?? 0)) * 0.5); + const placement = isMobile + ? MOBILE_TOOLTIP_PLACEMENT + : getCenteredTooltipPlacement({ + chartWidth, + xAnchor: xAnchorRaw, + topAnchor: !fields.segment, + }); + + return { + xAnchor: xAnchorRaw + (placement.x === "right" ? 0.5 : -0.5) * bw, + yAnchor, + placement, + xValue: getXAbbreviationOrLabel(datum), + datum: { + label: fields.segment && getSegmentAbbreviationOrLabel(datum), + value: yValueFormatter(getY(datum), getIdentityY(datum)), + color: colors(getSegment(datum)) as string, + }, + values: sortedTooltipValues.map((td) => ({ + label: getSegmentAbbreviationOrLabel(td), + value: yValueFormatter(getY(td), getIdentityY(td)), + color: colors(getSegment(td)) as string, + })), + }; + }, + [ + getX, + xScale, + chartDataGroupedByX, + segments, + getSegment, + yMeasure.id, + yMeasure.unit, + formatters, + formatNumber, + getXAbbreviationOrLabel, + fields.segment, + getSegmentAbbreviationOrLabel, + getY, + getIdentityY, + colors, + chartWidth, + chartHeight, + isMobile, + normalize, + yScale, + ] + ); + + return { + chartType: "bar", + bounds, + chartData, + allData, + xScale, + xScaleInteraction, + xScaleTimeRange, + yScale, + segments, + colors, + getColorLabel: getSegmentLabel, + chartWideData, + series, + getAnnotationInfo, + ...variables, + }; +}; + +const StackedBarsChartProvider = ( + props: React.PropsWithChildren<ChartProps<BarConfig>> +) => { + const { children, ...chartProps } = props; + const variables = useBarsStackedStateVariables(chartProps); + const data = useBarsStackedStateData(chartProps, variables); + const state = useBarsStackedState(chartProps, variables, data); + + return ( + <ChartContext.Provider value={state}>{children}</ChartContext.Provider> + ); +}; + +export const StackedBarsChart = ( + props: React.PropsWithChildren<ChartProps<BarConfig>> +) => { + return ( + <InteractionProvider> + <StackedBarsChartProvider {...props} /> + </InteractionProvider> + ); +}; diff --git a/app/charts/bar/bars-stacked.tsx b/app/charts/bar/bars-stacked.tsx new file mode 100644 index 000000000..d7d5382c4 --- /dev/null +++ b/app/charts/bar/bars-stacked.tsx @@ -0,0 +1,67 @@ +import { useEffect, useMemo, useRef } from "react"; + +import { StackedBarsState } from "@/charts/bar/bars-stacked-state"; +import { RenderBarDatum, renderBars } from "@/charts/bar/rendering-utils"; +import { useChartState } from "@/charts/shared/chart-state"; +import { renderContainer } from "@/charts/shared/rendering-utils"; +import { useTransitionStore } from "@/stores/transition"; + +export const BarsStacked = () => { + const ref = useRef<SVGGElement>(null); + const enableTransition = useTransitionStore((state) => state.enable); + const transitionDuration = useTransitionStore((state) => state.duration); + const { bounds, getX, xScale, yScale, colors, series, getRenderingKey } = + useChartState() as StackedBarsState; + const { margins, height } = bounds; + const bandwidth = xScale.bandwidth(); + const y0 = yScale(0); + const renderData: RenderBarDatum[] = useMemo(() => { + return series.flatMap((d) => { + const color = colors(d.key); + + return d.map((segment: $FixMe) => { + const observation = segment.data; + + return { + key: getRenderingKey(observation, d.key), + x: xScale(getX(observation)) as number, + y: yScale(segment[1]), + width: bandwidth, + height: Math.max(0, yScale(segment[0]) - yScale(segment[1])), + color, + }; + }); + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + bandwidth, + colors, + getX, + series, + xScale, + yScale, + getRenderingKey, + // Need to reset the yRange on height change + height, + ]); + + useEffect(() => { + if (ref.current) { + renderContainer(ref.current, { + id: "bars-stacked", + transform: `translate(${margins.left} ${margins.top})`, + transition: { enable: enableTransition, duration: transitionDuration }, + render: (g, opts) => renderBars(g, renderData, { ...opts, y0 }), + }); + } + }, [ + enableTransition, + margins.left, + margins.top, + renderData, + transitionDuration, + y0, + ]); + + return <g ref={ref} />; +}; diff --git a/app/charts/bar/bars-state-props.ts b/app/charts/bar/bars-state-props.ts new file mode 100644 index 000000000..5859052b9 --- /dev/null +++ b/app/charts/bar/bars-state-props.ts @@ -0,0 +1,136 @@ +import { ascending, descending } from "d3-array"; +import { useCallback, useMemo } from "react"; + +import { usePlottableData } from "@/charts/shared/chart-helpers"; +import { + BandXVariables, + BaseVariables, + ChartStateData, + InteractiveFiltersVariables, + NumericalYErrorVariables, + NumericalYVariables, + RenderingVariables, + SortingVariables, + useBandXVariables, + useBaseVariables, + useChartData, + useInteractiveFiltersVariables, + useNumericalYErrorVariables, + useNumericalYVariables, +} from "@/charts/shared/chart-state"; +import { useRenderingKeyVariable } from "@/charts/shared/rendering-utils"; +import { BarConfig, useChartConfigFilters } from "@/configurator"; +import { isTemporalEntityDimension } from "@/domain/data"; + +import { ChartProps } from "../shared/ChartProps"; + +export type BarsStateVariables = BaseVariables & + SortingVariables & + BandXVariables & + NumericalYVariables & + NumericalYErrorVariables & + RenderingVariables & + InteractiveFiltersVariables; + +export const useBarsStateVariables = ( + props: ChartProps<BarConfig> +): BarsStateVariables => { + const { + chartConfig, + observations, + dimensions, + dimensionsById, + measures, + measuresById, + } = props; + const { fields, interactiveFiltersConfig } = chartConfig; + const { x, y, animation } = fields; + const xDimension = dimensionsById[x.componentId]; + const filters = useChartConfigFilters(chartConfig); + + const baseVariables = useBaseVariables(chartConfig); + const bandXVariables = useBandXVariables(x, { + dimensionsById, + observations, + }); + const numericalYVariables = useNumericalYVariables("bar", y, { + measuresById, + }); + const numericalYErrorVariables = useNumericalYErrorVariables(y, { + numericalYVariables, + dimensions, + measures, + }); + const interactiveFiltersVariables = useInteractiveFiltersVariables( + interactiveFiltersConfig, + { dimensionsById } + ); + + const { getX, getXAsDate } = bandXVariables; + const { getY } = numericalYVariables; + const sortData: BarsStateVariables["sortData"] = useCallback( + (data) => { + const { sortingOrder, sortingType } = x.sorting ?? {}; + const xGetter = isTemporalEntityDimension(xDimension) ? getXAsDate : getX; + if (sortingOrder === "desc" && sortingType === "byDimensionLabel") { + return [...data].sort((a, b) => descending(xGetter(a), xGetter(b))); + } else if (sortingOrder === "asc" && sortingType === "byDimensionLabel") { + return [...data].sort((a, b) => ascending(xGetter(a), xGetter(b))); + } else if (sortingOrder === "desc" && sortingType === "byMeasure") { + return [...data].sort((a, b) => + descending(getY(a) ?? -1, getY(b) ?? -1) + ); + } else if (sortingOrder === "asc" && sortingType === "byMeasure") { + return [...data].sort((a, b) => + ascending(getY(a) ?? -1, getY(b) ?? -1) + ); + } else { + return [...data].sort((a, b) => ascending(xGetter(a), xGetter(b))); + } + }, + [getX, getXAsDate, getY, x.sorting, xDimension] + ); + + const getRenderingKey = useRenderingKeyVariable( + dimensions, + filters, + interactiveFiltersConfig, + animation + ); + + return { + ...baseVariables, + sortData, + ...bandXVariables, + ...numericalYVariables, + ...numericalYErrorVariables, + ...interactiveFiltersVariables, + getRenderingKey, + }; +}; + +export const useBarsStateData = ( + chartProps: ChartProps<BarConfig>, + variables: BarsStateVariables +): ChartStateData => { + const { chartConfig, observations } = chartProps; + const { sortData, xDimension, getXAsDate, getY, getTimeRangeDate } = + variables; + const plottableData = usePlottableData(observations, { + getY, + }); + const sortedPlottableData = useMemo(() => { + return sortData(plottableData); + }, [sortData, plottableData]); + const data = useChartData(sortedPlottableData, { + chartConfig, + timeRangeDimensionId: xDimension.id, + getXAsDate, + getTimeRangeDate, + }); + + return { + ...data, + allData: sortedPlottableData, + }; +}; diff --git a/app/charts/bar/bars-state.tsx b/app/charts/bar/bars-state.tsx new file mode 100644 index 000000000..efd3dfb2e --- /dev/null +++ b/app/charts/bar/bars-state.tsx @@ -0,0 +1,309 @@ +import { extent, max, rollup, sum } from "d3-array"; +import { + ScaleBand, + scaleBand, + ScaleLinear, + scaleLinear, + scaleTime, +} from "d3-scale"; +import orderBy from "lodash/orderBy"; +import { useMemo } from "react"; + +import { + BarsStateVariables, + useBarsStateData, + useBarsStateVariables, +} from "@/charts/bar/bars-state-props"; +import { PADDING_INNER, PADDING_OUTER } from "@/charts/bar/constants"; +import { + useAxisLabelHeightOffset, + useChartBounds, + useChartPadding, +} from "@/charts/shared/chart-dimensions"; +import { + ChartContext, + ChartStateData, + CommonChartState, + InteractiveXTimeRangeState, +} from "@/charts/shared/chart-state"; +import { TooltipInfo } from "@/charts/shared/interaction/tooltip"; +import { + getCenteredTooltipPlacement, + MOBILE_TOOLTIP_PLACEMENT, +} from "@/charts/shared/interaction/tooltip-box"; +import useChartFormatters from "@/charts/shared/use-chart-formatters"; +import { InteractionProvider } from "@/charts/shared/use-interaction"; +import { useSize } from "@/charts/shared/use-size"; +import { BarConfig } from "@/configurator"; +import { Observation } from "@/domain/data"; +import { + formatNumberWithUnit, + useFormatNumber, + useTimeFormatUnit, +} from "@/formatters"; +import { + getSortingOrders, + makeDimensionValueSorters, +} from "@/utils/sorting-values"; +import { useIsMobile } from "@/utils/use-is-mobile"; + +import { ChartProps } from "../shared/ChartProps"; + +export type BarsState = CommonChartState & + BarsStateVariables & + InteractiveXTimeRangeState & { + chartType: "bar"; + xScale: ScaleBand<string>; + xScaleInteraction: ScaleBand<string>; + yScale: ScaleLinear<number, number>; + getAnnotationInfo: (d: Observation) => TooltipInfo; + }; + +const useBarsState = ( + chartProps: ChartProps<BarConfig>, + variables: BarsStateVariables, + data: ChartStateData +): BarsState => { + const { chartConfig } = chartProps; + const { + xDimension, + getX, + getXAsDate, + getXAbbreviationOrLabel, + getXLabel, + xTimeUnit, + yMeasure, + getY, + getMinY, + showYStandardError, + yErrorMeasure, + getYError, + getYErrorRange, + } = variables; + const { chartData, scalesData, timeRangeData, paddingData, allData } = data; + const { fields, interactiveFiltersConfig } = chartConfig; + + const { width, height } = useSize(); + const formatNumber = useFormatNumber({ decimals: "auto" }); + const formatters = useChartFormatters(chartProps); + const timeFormatUnit = useTimeFormatUnit(); + + const sumsByX = useMemo(() => { + return Object.fromEntries( + rollup( + chartData, + (v) => sum(v, (x) => getY(x)), + (x) => getX(x) + ) + ); + }, [chartData, getX, getY]); + + const { + xScale, + yScale, + paddingYScale, + xScaleTimeRange, + xScaleInteraction, + xTimeRangeDomainLabels, + } = useMemo(() => { + const sorters = makeDimensionValueSorters(xDimension, { + sorting: fields.x.sorting, + measureBySegment: sumsByX, + useAbbreviations: fields.x.useAbbreviations, + dimensionFilter: xDimension?.id + ? chartConfig.cubes.find((d) => d.iri === xDimension.cubeIri)?.filters[ + xDimension.id + ] + : undefined, + }); + const sortingOrders = getSortingOrders(sorters, fields.x.sorting); + const bandDomain = orderBy( + [...new Set(scalesData.map(getX))], + sorters, + sortingOrders + ); + const xTimeRangeValues = [...new Set(timeRangeData.map(getX))]; + const xTimeRangeDomainLabels = xTimeRangeValues.map(getXLabel); + const xScale = scaleBand() + .domain(bandDomain) + .paddingInner(PADDING_INNER) + .paddingOuter(PADDING_OUTER); + const xScaleInteraction = scaleBand() + .domain(bandDomain) + .paddingInner(0) + .paddingOuter(0); + + const xScaleTimeRangeDomain = extent(timeRangeData, (d) => + getXAsDate(d) + ) as [Date, Date]; + + const xScaleTimeRange = scaleTime().domain(xScaleTimeRangeDomain); + + const minValue = getMinY(scalesData, (d) => + getYErrorRange ? getYErrorRange(d)[0] : getY(d) + ); + const maxValue = Math.max( + max(scalesData, (d) => + getYErrorRange ? getYErrorRange(d)[1] : getY(d) + ) ?? 0, + 0 + ); + const yScale = scaleLinear().domain([minValue, maxValue]).nice(); + + const paddingMinValue = getMinY(paddingData, (d) => + getYErrorRange ? getYErrorRange(d)[0] : getY(d) + ); + const paddingMaxValue = Math.max( + max(paddingData, (d) => + getYErrorRange ? getYErrorRange(d)[1] : getY(d) + ) ?? 0, + 0 + ); + const paddingYScale = scaleLinear() + .domain([paddingMinValue, paddingMaxValue]) + .nice(); + + return { + xScale, + yScale, + paddingYScale, + xScaleTimeRange, + xScaleInteraction, + xTimeRangeDomainLabels, + }; + }, [ + getX, + getXLabel, + getXAsDate, + getY, + getYErrorRange, + scalesData, + paddingData, + timeRangeData, + fields.x.sorting, + fields.x.useAbbreviations, + xDimension, + chartConfig.cubes, + sumsByX, + getMinY, + ]); + + const { left, bottom } = useChartPadding({ + yScale: paddingYScale, + width, + height, + interactiveFiltersConfig, + animationPresent: !!fields.animation, + formatNumber, + bandDomain: xTimeRangeDomainLabels.every((d) => d === undefined) + ? xScale.domain() + : xTimeRangeDomainLabels, + }); + const right = 40; + const { offset: yAxisLabelMargin } = useAxisLabelHeightOffset({ + label: yMeasure.label, + width, + marginLeft: left, + marginRight: right, + }); + const margins = { + top: 50 + yAxisLabelMargin, + right, + bottom, + left, + }; + + const bounds = useChartBounds(width, margins, height); + const { chartWidth, chartHeight } = bounds; + + xScale.range([0, chartWidth]); + xScaleInteraction.range([0, chartWidth]); + xScaleTimeRange.range([0, chartWidth]); + yScale.range([chartHeight, 0]); + + const isMobile = useIsMobile(); + + // Tooltip + const getAnnotationInfo = (d: Observation): TooltipInfo => { + const xAnchor = (xScale(getX(d)) as number) + xScale.bandwidth() * 0.5; + const yAnchor = isMobile + ? chartHeight + : yScale(Math.max(getY(d) ?? NaN, 0)); + const placement = isMobile + ? MOBILE_TOOLTIP_PLACEMENT + : getCenteredTooltipPlacement({ + chartWidth, + xAnchor, + topAnchor: !fields.segment, + }); + + const xLabel = getXAbbreviationOrLabel(d); + + const yValueFormatter = (value: number | null) => + formatNumberWithUnit( + value, + formatters[yMeasure.id] ?? formatNumber, + yMeasure.unit + ); + + const getError = (d: Observation) => { + if (!showYStandardError || !getYError || getYError(d) === null) { + return; + } + + return `${getYError(d)}${yErrorMeasure?.unit ?? ""}`; + }; + + const y = getY(d); + + return { + xAnchor, + yAnchor, + placement, + xValue: xTimeUnit ? timeFormatUnit(xLabel, xTimeUnit) : xLabel, + datum: { + label: undefined, + value: y !== null && isNaN(y) ? "-" : `${yValueFormatter(getY(d))}`, + error: getError(d), + color: "", + }, + values: undefined, + }; + }; + + return { + chartType: "bar", + bounds, + chartData, + allData, + xScale, + xScaleTimeRange, + xScaleInteraction, + yScale, + getAnnotationInfo, + ...variables, + }; +}; + +const BarChartProvider = ( + props: React.PropsWithChildren<ChartProps<BarConfig>> +) => { + const { children, ...chartProps } = props; + const variables = useBarsStateVariables(chartProps); + const data = useBarsStateData(chartProps, variables); + const state = useBarsState(chartProps, variables, data); + + return ( + <ChartContext.Provider value={state}>{children}</ChartContext.Provider> + ); +}; + +export const BarChart = ( + props: React.PropsWithChildren<ChartProps<BarConfig>> +) => { + return ( + <InteractionProvider> + <BarChartProvider {...props} /> + </InteractionProvider> + ); +}; diff --git a/app/charts/bar/bars.tsx b/app/charts/bar/bars.tsx new file mode 100644 index 000000000..810b30026 --- /dev/null +++ b/app/charts/bar/bars.tsx @@ -0,0 +1,147 @@ +import { schemeCategory10 } from "d3-scale-chromatic"; +import { useEffect, useMemo, useRef } from "react"; + +import { BarsState } from "@/charts/bar/bars-state"; +import { RenderBarDatum, renderBars } from "@/charts/bar/rendering-utils"; +import { useChartState } from "@/charts/shared/chart-state"; +import { + RenderWhiskerDatum, + filterWithoutErrors, + renderContainer, + renderWhiskers, +} from "@/charts/shared/rendering-utils"; +import { useTransitionStore } from "@/stores/transition"; +import { useTheme } from "@/themes"; + +export const ErrorWhiskers = () => { + const { + getX, + getYError, + getYErrorRange, + chartData, + yScale, + xScale, + showYStandardError, + bounds, + } = useChartState() as BarsState; + const { margins, width, height } = bounds; + const ref = useRef<SVGGElement>(null); + const enableTransition = useTransitionStore((state) => state.enable); + const transitionDuration = useTransitionStore((state) => state.duration); + const renderData: RenderWhiskerDatum[] = useMemo(() => { + if (!getYErrorRange || !showYStandardError) { + return []; + } + + const bandwidth = xScale.bandwidth(); + return chartData.filter(filterWithoutErrors(getYError)).map((d, i) => { + const x0 = xScale(getX(d)) as number; + const barWidth = Math.min(bandwidth, 15); + const [y1, y2] = getYErrorRange(d); + return { + key: `${i}`, + x: x0 + bandwidth / 2 - barWidth / 2, + y1: yScale(y1), + y2: yScale(y2), + width: barWidth, + } as RenderWhiskerDatum; + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + chartData, + getX, + getYError, + getYErrorRange, + showYStandardError, + xScale, + yScale, + width, + height, + ]); + + useEffect(() => { + if (ref.current) { + renderContainer(ref.current, { + id: "bars-error-whiskers", + transform: `translate(${margins.left} ${margins.top})`, + transition: { enable: enableTransition, duration: transitionDuration }, + render: (g, opts) => renderWhiskers(g, renderData, opts), + }); + } + }, [ + enableTransition, + margins.left, + margins.top, + renderData, + transitionDuration, + ]); + + return <g ref={ref} />; +}; + +export const Bars = () => { + const { chartData, bounds, getX, xScale, getY, yScale, getRenderingKey } = + useChartState() as BarsState; + const theme = useTheme(); + const { margins } = bounds; + const ref = useRef<SVGGElement>(null); + const enableTransition = useTransitionStore((state) => state.enable); + const transitionDuration = useTransitionStore((state) => state.duration); + const bandwidth = xScale.bandwidth(); + const y0 = yScale(0); + const renderData: RenderBarDatum[] = useMemo(() => { + const getColor = (d: number) => { + return d <= 0 ? theme.palette.secondary.main : schemeCategory10[0]; + }; + + return chartData.map((d) => { + const key = getRenderingKey(d); + const xScaled = xScale(getX(d)) as number; + const yRaw = getY(d); + const y = yRaw === null || isNaN(yRaw) ? 0 : yRaw; + const yScaled = yScale(y); + const yRender = yScale(Math.max(y, 0)); + const height = Math.max(0, Math.abs(yScaled - y0)); + const color = getColor(y); + + return { + key, + x: xScaled, + y: yRender, + width: bandwidth, + height, + color, + }; + }); + }, [ + chartData, + bandwidth, + getX, + getY, + xScale, + yScale, + y0, + theme.palette.secondary.main, + getRenderingKey, + ]); + + useEffect(() => { + if (ref.current) { + renderContainer(ref.current, { + id: "bars", + transform: `translate(${margins.left} ${margins.top})`, + transition: { enable: enableTransition, duration: transitionDuration }, + render: (g, opts) => renderBars(g, renderData, { ...opts, y0 }), + }); + } + }, [ + enableTransition, + margins.left, + margins.top, + renderData, + transitionDuration, + y0, + ]); + + return <g ref={ref} />; +}; diff --git a/app/charts/bar/chart-bar.tsx b/app/charts/bar/chart-bar.tsx new file mode 100644 index 000000000..215b657c2 --- /dev/null +++ b/app/charts/bar/chart-bar.tsx @@ -0,0 +1,140 @@ +import { memo } from "react"; + +import { Bars, ErrorWhiskers } from "@/charts/bar/bars"; +import { + BarsGrouped, + ErrorWhiskers as ErrorWhiskersGrouped, +} from "@/charts/bar/bars-grouped"; +import { GroupedBarChart } from "@/charts/bar/bars-grouped-state"; +import { BarsStacked } from "@/charts/bar/bars-stacked"; +import { StackedBarsChart } from "@/charts/bar/bars-stacked-state"; +import { BarChart } from "@/charts/bar/bars-state"; +import { InteractionBars } from "@/charts/bar/overlay-bars"; +import { ChartDataWrapper } from "@/charts/chart-data-wrapper"; +import { AxisHeightLinear } from "@/charts/shared/axis-height-linear"; +import { + AxisWidthBand, + AxisWidthBandDomain, +} from "@/charts/shared/axis-width-band"; +import { BrushTime, shouldShowBrush } from "@/charts/shared/brush"; +import { + ChartContainer, + ChartControlsContainer, + ChartSvg, +} from "@/charts/shared/containers"; +import { Tooltip } from "@/charts/shared/interaction/tooltip"; +import { LegendColor } from "@/charts/shared/legend-color"; +import { BarConfig, useChartConfigFilters } from "@/config-types"; +import { hasChartConfigs } from "@/configurator"; +import { TimeSlider } from "@/configurator/interactive-filters/time-slider"; +import { useConfiguratorState } from "@/src"; + +import { ChartProps, VisualizationProps } from "../shared/ChartProps"; + +export const ChartBarsVisualization = ( + props: VisualizationProps<BarConfig> +) => { + return <ChartDataWrapper {...props} Component={ChartBars} />; +}; + +const ChartBars = memo((props: ChartProps<BarConfig>) => { + const { chartConfig, dimensions } = props; + const { fields, interactiveFiltersConfig } = chartConfig; + const filters = useChartConfigFilters(chartConfig); + const [{ dashboardFilters }] = useConfiguratorState(hasChartConfigs); + const showTimeBrush = shouldShowBrush( + interactiveFiltersConfig, + dashboardFilters?.timeRange + ); + + return ( + <> + {fields.segment?.componentId && fields.segment.type === "stacked" ? ( + <StackedBarsChart {...props}> + <ChartContainer> + <ChartSvg> + <AxisHeightLinear /> + <AxisWidthBand /> + <BarsStacked /> + <AxisWidthBandDomain /> + <InteractionBars /> + {showTimeBrush && <BrushTime />} + </ChartSvg> + <Tooltip type="multiple" /> + </ChartContainer> + <ChartControlsContainer> + {fields.animation && ( + <TimeSlider + filters={filters} + dimensions={dimensions} + {...fields.animation} + /> + )} + <LegendColor + chartConfig={chartConfig} + symbol="square" + interactive={ + fields.segment && interactiveFiltersConfig?.legend.active + } + /> + </ChartControlsContainer> + </StackedBarsChart> + ) : fields.segment?.componentId && fields.segment.type === "grouped" ? ( + <GroupedBarChart {...props}> + <ChartContainer> + <ChartSvg> + <AxisHeightLinear /> + <AxisWidthBand /> + <BarsGrouped /> + <ErrorWhiskersGrouped /> + <AxisWidthBandDomain /> + <InteractionBars /> + {showTimeBrush && <BrushTime />} + </ChartSvg> + <Tooltip type="multiple" /> + </ChartContainer> + <ChartControlsContainer> + {fields.animation && ( + <TimeSlider + filters={filters} + dimensions={dimensions} + {...fields.animation} + /> + )} + <LegendColor + chartConfig={chartConfig} + symbol="square" + interactive={ + fields.segment && interactiveFiltersConfig?.legend.active + } + /> + </ChartControlsContainer> + </GroupedBarChart> + ) : ( + <BarChart {...props}> + <ChartContainer> + <ChartSvg> + <AxisHeightLinear /> + <AxisWidthBand /> + <Bars /> + <ErrorWhiskers /> + <AxisWidthBandDomain /> + <InteractionBars /> + {showTimeBrush && <BrushTime />} + </ChartSvg> + <Tooltip type="single" /> + </ChartContainer> + {fields.animation && ( + <ChartControlsContainer> + <TimeSlider + filters={filters} + dimensions={dimensions} + {...fields.animation} + /> + </ChartControlsContainer> + )} + </BarChart> + )} + </> + ); +}); diff --git a/app/charts/bar/constants.ts b/app/charts/bar/constants.ts new file mode 100644 index 000000000..ecad19d48 --- /dev/null +++ b/app/charts/bar/constants.ts @@ -0,0 +1,3 @@ +export const PADDING_OUTER = 0; +export const PADDING_INNER = 0.1; +export const PADDING_WITHIN = 0.1; diff --git a/app/charts/bar/overlay-bars.tsx b/app/charts/bar/overlay-bars.tsx new file mode 100644 index 000000000..c919f2a41 --- /dev/null +++ b/app/charts/bar/overlay-bars.tsx @@ -0,0 +1,44 @@ +import { ColumnsState } from "@/charts/column/columns-state"; +import { ComboLineColumnState } from "@/charts/combo/combo-line-column-state"; +import { useChartState } from "@/charts/shared/chart-state"; +import { useInteraction } from "@/charts/shared/use-interaction"; +import { Observation } from "@/domain/data"; + +export const InteractionBars = () => { + const [, dispatch] = useInteraction(); + + const { chartData, bounds, getX, xScaleInteraction } = useChartState() as + | ColumnsState + | ComboLineColumnState; + const { margins, chartHeight } = bounds; + + const showTooltip = (d: Observation) => { + dispatch({ + type: "INTERACTION_UPDATE", + value: { interaction: { visible: true, d } }, + }); + }; + const hideTooltip = () => { + dispatch({ + type: "INTERACTION_HIDE", + }); + }; + return ( + <g transform={`translate(${margins.left} ${margins.top})`}> + {chartData.map((d, i) => ( + <rect + key={i} + x={xScaleInteraction(getX(d)) as number} + y={0} + width={xScaleInteraction.bandwidth()} + height={Math.max(0, chartHeight)} + fill="hotpink" + fillOpacity={0} + stroke="none" + onMouseOut={hideTooltip} + onMouseOver={() => showTooltip(d)} + /> + ))} + </g> + ); +}; diff --git a/app/charts/bar/rendering-utils.ts b/app/charts/bar/rendering-utils.ts new file mode 100644 index 000000000..67123ec61 --- /dev/null +++ b/app/charts/bar/rendering-utils.ts @@ -0,0 +1,63 @@ +import { Selection } from "d3-selection"; + +import { + RenderOptions, + maybeTransition, +} from "@/charts/shared/rendering-utils"; + +export type RenderBarDatum = { + key: string; + x: number; + y: number; + width: number; + height: number; + color: string; +}; + +type RenderBarOptions = RenderOptions & { + y0: number; +}; + +export const renderBars = ( + g: Selection<SVGGElement, null, SVGGElement, unknown>, + data: RenderBarDatum[], + options: RenderBarOptions +) => { + const { transition, y0 } = options; + + g.selectAll<SVGRectElement, RenderBarDatum>("rect") + .data(data, (d) => d.key) + .join( + (enter) => + enter + .append("rect") + .attr("data-index", (_, i) => i) + .attr("x", (d) => d.x) + .attr("y", y0) + .attr("width", (d) => d.width) + .attr("height", 0) + .attr("fill", (d) => d.color) + .call((enter) => + maybeTransition(enter, { + transition, + s: (g) => g.attr("y", (d) => d.y).attr("height", (d) => d.height), + }) + ), + (update) => + maybeTransition(update, { + s: (g) => + g + .attr("x", (d) => d.x) + .attr("y", (d) => d.y) + .attr("width", (d) => d.width) + .attr("height", (d) => d.height) + .attr("fill", (d) => d.color), + transition, + }), + (exit) => + maybeTransition(exit, { + transition, + s: (g) => g.attr("y", y0).attr("height", 0).remove(), + }) + ); +}; diff --git a/app/charts/chart-config-ui-options.ts b/app/charts/chart-config-ui-options.ts index 3122b1f3c..b4d544c1b 100644 --- a/app/charts/chart-config-ui-options.ts +++ b/app/charts/chart-config-ui-options.ts @@ -16,6 +16,7 @@ import { } from "@/charts/shared/chart-helpers"; import { AreaConfig, + BarConfig, ChartConfig, ChartSubType, ChartType, @@ -294,6 +295,7 @@ export interface ChartSpec<T extends ChartConfig = ChartConfig> { interface ChartSpecs { area: ChartSpec<AreaConfig>; column: ChartSpec<ColumnConfig>; + bar: ChartSpec<BarConfig>; line: ChartSpec<LineConfig>; map: ChartSpec<MapConfig>; pie: ChartSpec<PieConfig>; @@ -351,6 +353,7 @@ const LINE_SEGMENT_SORTING: EncodingSortingOption<LineConfig>[] = [ ]; export const COLUMN_SEGMENT_SORTING = getDefaultSegmentSorting<ColumnConfig>(); +export const BAR_SEGMENT_SORTING = getDefaultSegmentSorting<BarConfig>(); export const PIE_SEGMENT_SORTING: EncodingSortingOption<PieConfig>[] = [ { sortingType: "byAuto", sortingOrder: ["asc", "desc"] }, @@ -359,7 +362,7 @@ export const PIE_SEGMENT_SORTING: EncodingSortingOption<PieConfig>[] = [ ]; export const ANIMATION_FIELD_SPEC: EncodingSpec< - ColumnConfig | MapConfig | ScatterPlotConfig | PieConfig + ColumnConfig | BarConfig | MapConfig | ScatterPlotConfig | PieConfig > = { field: "animation", optional: true, @@ -473,6 +476,7 @@ export const disableStacked = (d?: Component): boolean => { export const defaultSegmentOnChange: OnEncodingChange< | AreaConfig | ColumnConfig + | BarConfig | LineConfig | ScatterPlotConfig | PieConfig @@ -765,6 +769,158 @@ const chartConfigOptionsUISpec: ChartSpecs = { ], interactiveFilters: ["legend", "timeRange", "animation"], }, + bar: { + chartType: "bar", + encodings: [ + { + field: "y", + optional: false, + idAttributes: ["componentId"], + componentTypes: ["NumericalMeasure"], + filters: false, + onChange: (id, { chartConfig, measures }) => { + if (chartConfig.fields.segment?.type === "stacked") { + const yMeasure = measures.find((d) => d.id === id); + + if (disableStacked(yMeasure)) { + setWith(chartConfig, "fields.segment.type", "grouped", Object); + + if (chartConfig.interactiveFiltersConfig?.calculation) { + setWith( + chartConfig, + "interactiveFiltersConfig.calculation", + { active: false, type: "identity" }, + Object + ); + } + } + } + }, + options: { + showStandardError: {}, + }, + }, + { + field: "x", + optional: false, + idAttributes: ["componentId"], + componentTypes: [ + "TemporalDimension", + "TemporalEntityDimension", + "TemporalOrdinalDimension", + "NominalDimension", + "OrdinalDimension", + "GeoCoordinatesDimension", + "GeoShapesDimension", + ], + filters: true, + sorting: [ + { sortingType: "byAuto", sortingOrder: ["asc", "desc"] }, + { sortingType: "byMeasure", sortingOrder: ["asc", "desc"] }, + { sortingType: "byDimensionLabel", sortingOrder: ["asc", "desc"] }, + ], + onChange: (id, { chartConfig, dimensions }) => { + const component = dimensions.find((d) => d.id === id); + + if (!isTemporalDimension(component)) { + setWith( + chartConfig, + "interactiveFiltersConfig.timeRange.active", + false, + Object + ); + } + }, + options: { + useAbbreviations: {}, + }, + }, + { + field: "segment", + optional: true, + idAttributes: ["componentId"], + componentTypes: SEGMENT_ENABLED_COMPONENTS, + filters: true, + sorting: BAR_SEGMENT_SORTING, + onChange: (id, options) => { + const { chartConfig, dimensions, measures } = options; + defaultSegmentOnChange(id, options); + + const components = [...dimensions, ...measures]; + const segment: ColumnSegmentField = get( + chartConfig, + "fields.segment" + ); + const yComponent = components.find( + (d) => d.id === chartConfig.fields.y.componentId + ); + setWith( + chartConfig, + "fields.segment", + { + ...segment, + type: disableStacked(yComponent) ? "grouped" : "stacked", + }, + Object + ); + }, + options: { + calculation: { + getDisabledState: (chartConfig) => { + const grouped = chartConfig.fields.segment?.type === "grouped"; + + return { + disabled: grouped, + warnMessage: grouped + ? t({ + id: "controls.calculation.disabled-by-grouped", + message: + "100% mode cannot be used with a grouped layout.", + }) + : undefined, + }; + }, + }, + chartSubType: { + getValues: (chartConfig, dimensions) => { + const yId = chartConfig.fields.y.componentId; + const yDimension = dimensions.find((d) => d.id === yId); + const disabledStacked = disableStacked(yDimension); + + return [ + { + value: "stacked", + disabled: disabledStacked, + warnMessage: disabledStacked + ? t({ + id: "controls.segment.stacked.disabled-by-scale-type", + message: + "Stacked layout can only be enabled if the vertical axis dimension has a ratio scale.", + }) + : undefined, + }, + { + value: "grouped", + disabled: false, + }, + ]; + }, + onChange: (d, { chartConfig }) => { + if (chartConfig.interactiveFiltersConfig && d === "grouped") { + const path = "interactiveFiltersConfig.calculation"; + setWith(chartConfig, path, { active: false, type: "identity" }); + } + }, + }, + colorPalette: {}, + useAbbreviations: {}, + }, + }, + ANIMATION_FIELD_SPEC, + ], + interactiveFilters: ["legend", "timeRange", "animation"], + }, + line: { chartType: "line", encodings: [ diff --git a/app/charts/index.ts b/app/charts/index.ts index 4439b187d..9e667d844 100644 --- a/app/charts/index.ts +++ b/app/charts/index.ts @@ -92,6 +92,7 @@ import { unreachableError } from "@/utils/unreachable"; const chartTypes: ChartType[] = [ "column", + "bar", "line", "area", "scatterplot", @@ -105,6 +106,7 @@ const chartTypes: ChartType[] = [ export const regularChartTypes: RegularChartType[] = [ "column", + "bar", "line", "area", "scatterplot", @@ -130,15 +132,16 @@ function getChartTypeOrder({ cubeCount }: { cubeCount: number }): ChartOrder { const multiCubeBoost = cubeCount > 1 ? -100 : 0; return { column: 0, - line: 1, - area: 2, - scatterplot: 3, - pie: 4, - map: 5, - table: 6, - comboLineSingle: 7 + multiCubeBoost, - comboLineDual: 8 + multiCubeBoost, - comboLineColumn: 9 + multiCubeBoost, + bar: 1, + line: 2, + area: 3, + scatterplot: 4, + pie: 5, + map: 6, + table: 7, + comboLineSingle: 8 + multiCubeBoost, + comboLineDual: 9 + multiCubeBoost, + comboLineColumn: 10 + multiCubeBoost, }; } @@ -423,6 +426,31 @@ export const getInitialConfig = ( y: { componentId: numericalMeasures[0].id }, }, }; + + case "bar": + const barXComponentId = findPreferredDimension( + sortBy(dimensions, (d) => (isGeoDimension(d) ? 1 : -1)), + [ + "TemporalDimension", + "TemporalEntityDimension", + "TemporalOrdinalDimension", + ] + ).id; + + return { + ...getGenericConfigProps(), + chartType, + interactiveFiltersConfig: getInitialInteractiveFiltersConfig({ + timeRangeComponentId: barXComponentId, + }), + fields: { + x: { + componentId: barXComponentId, + sorting: DEFAULT_SORTING, + }, + y: { componentId: numericalMeasures[0].id }, + }, + }; case "line": const lineXComponentId = temporalDimensions[0].id; @@ -983,6 +1011,97 @@ const chartConfigsAdjusters: ChartConfigsAdjusters = { }, interactiveFiltersConfig: interactiveFiltersAdjusters, }, + bar: { + cubes: ({ oldValue, newChartConfig }) => { + return produce(newChartConfig, (draft) => { + draft.cubes = oldValue; + }); + }, + fields: { + x: { + componentId: ({ oldValue, newChartConfig, dimensions }) => { + // When switching from a scatterplot, x is a measure. + if (dimensions.find((d) => d.id === oldValue)) { + return produce(newChartConfig, (draft) => { + draft.fields.x.componentId = oldValue; + }); + } + + return newChartConfig; + }, + }, + y: { + componentId: ({ oldValue, newChartConfig }) => { + return produce(newChartConfig, (draft) => { + draft.fields.y.componentId = oldValue; + }); + }, + }, + segment: ({ + oldValue, + oldChartConfig, + newChartConfig, + dimensions, + measures, + }) => { + let newSegment: ColumnSegmentField | undefined; + const yMeasure = measures.find( + (d) => d.id === newChartConfig.fields.y.componentId + ); + + // When switching from a table chart, a whole fields object is passed as oldValue. + if (oldChartConfig.chartType === "table") { + const tableSegment = convertTableFieldsToSegmentField({ + fields: oldValue as TableFields, + dimensions, + measures, + }); + + if (tableSegment) { + newSegment = { + ...tableSegment, + sorting: DEFAULT_SORTING, + type: disableStacked(yMeasure) ? "grouped" : "stacked", + }; + } + // Otherwise we are dealing with a segment field. We shouldn't take + // the segment from oldValue if the component has already been used as + // x axis. + } else if ( + newChartConfig.fields.x.componentId !== oldValue.componentId + ) { + const oldSegment = oldValue as Exclude<typeof oldValue, TableFields>; + newSegment = { + ...oldSegment, + // We could encounter byMeasure sorting type (Pie chart); we should + // switch to byTotalSize sorting then. + sorting: adjustSegmentSorting({ + segment: oldSegment, + acceptedValues: COLUMN_SEGMENT_SORTING.map((d) => d.sortingType), + defaultValue: "byTotalSize", + }), + type: disableStacked(yMeasure) ? "grouped" : "stacked", + }; + } + + return produce(newChartConfig, (draft) => { + if (newSegment) { + draft.fields.segment = newSegment; + } + }); + }, + animation: ({ oldValue, newChartConfig }) => { + return produce(newChartConfig, (draft) => { + // Temporal dimension could be used as X axis, in this case we need to + // remove the animation. + if (newChartConfig.fields.x.componentId !== oldValue?.componentId) { + draft.fields.animation = oldValue; + } + }); + }, + }, + interactiveFiltersConfig: interactiveFiltersAdjusters, + }, line: { cubes: ({ oldValue, newChartConfig }) => { return produce(newChartConfig, (draft) => { @@ -1652,6 +1771,34 @@ const chartConfigsPathOverrides: { }, }, }, + bar: { + map: { + "fields.areaLayer.componentId": { path: "fields.x.componentId" }, + "fields.areaLayer.color.componentId": { path: "fields.y.componentId" }, + }, + table: { + fields: { path: "fields.segment" }, + }, + comboLineSingle: { + "fields.y.componentIds": { + path: "fields.y.componentId", + oldValue: (d: ComboLineSingleFields["y"]["componentIds"]) => d[0], + }, + }, + comboLineDual: { + "fields.y.leftAxisComponentId": { path: "fields.y.componentId" }, + }, + comboLineColumn: { + "fields.y": { + path: "fields.y.componentId", + oldValue: (d: ComboLineColumnFields["y"]) => { + return d.lineAxisOrientation === "left" + ? d.lineComponentId + : d.columnComponentId; + }, + }, + }, + }, line: { map: { "fields.areaLayer.color.componentId": { path: "fields.y.componentId" }, @@ -1944,10 +2091,10 @@ export const getPossibleChartTypes = ({ (d) => isTemporalDimension(d) || isTemporalEntityDimension(d) ); - const categoricalEnabled: RegularChartType[] = ["column", "pie"]; - const geoEnabled: RegularChartType[] = ["column", "map", "pie"]; + const categoricalEnabled: RegularChartType[] = ["column", "bar", "pie"]; + const geoEnabled: RegularChartType[] = ["column", "bar", "map", "pie"]; const multipleNumericalMeasuresEnabled: RegularChartType[] = ["scatterplot"]; - const timeEnabled: RegularChartType[] = ["area", "column", "line"]; + const timeEnabled: RegularChartType[] = ["area", "column", "bar", "line"]; const possibles: ChartType[] = ["table"]; if (numericalMeasures.length > 0) { @@ -2076,6 +2223,7 @@ export const getChartSymbol = ( switch (chartType) { case "area": case "column": + case "bar": case "comboLineColumn": case "pie": case "map": diff --git a/app/charts/shared/chart-state.ts b/app/charts/shared/chart-state.ts index c116e74ed..871a9f369 100644 --- a/app/charts/shared/chart-state.ts +++ b/app/charts/shared/chart-state.ts @@ -5,6 +5,9 @@ import overEvery from "lodash/overEvery"; import { createContext, useCallback, useContext, useMemo } from "react"; import { AreasState } from "@/charts/area/areas-state"; +import { GroupedBarsState } from "@/charts/bar/bars-grouped-state"; +import { StackedBarsState } from "@/charts/bar/bars-stacked-state"; +import { BarsState } from "@/charts/bar/bars-state"; import { GroupedColumnsState } from "@/charts/column/columns-grouped-state"; import { StackedColumnsState } from "@/charts/column/columns-stacked-state"; import { ColumnsState } from "@/charts/column/columns-state"; @@ -69,6 +72,9 @@ export type ChartState = | ColumnsState | StackedColumnsState | GroupedColumnsState + | BarsState + | StackedBarsState + | GroupedBarsState | ComboLineSingleState | ComboLineColumnState | ComboLineDualState @@ -103,6 +109,7 @@ export const useChartState = () => { export type ChartWithInteractiveXTimeRangeState = | AreasState | ColumnsState + | BarsState | LinesState; export type NumericalValueGetter = (d: Observation) => number | null; @@ -296,7 +303,7 @@ export type NumericalYVariables = { export const useNumericalYVariables = ( // Combo charts have their own logic for y scales. - chartType: "area" | "column" | "line" | "pie" | "scatterplot", + chartType: "area" | "column" | "bar" | "line" | "pie" | "scatterplot", y: GenericField, { measuresById }: { measuresById: MeasuresById } ): NumericalYVariables => { @@ -318,6 +325,7 @@ export const useNumericalYVariables = ( switch (chartType) { case "area": case "column": + case "bar": case "pie": return Math.min(0, min(data, _getY) ?? 0); case "line": diff --git a/app/components/chart-with-filters.tsx b/app/components/chart-with-filters.tsx index 37240eb09..2740bf173 100644 --- a/app/components/chart-with-filters.tsx +++ b/app/components/chart-with-filters.tsx @@ -23,6 +23,12 @@ const ChartColumnsVisualization = dynamic( () => null as never ) ); +const ChartBarsVisualization = dynamic( + import("@/charts/bar/chart-bar").then( + (mod) => mod.ChartBarsVisualization, + () => null as never + ) +); const ChartComboLineSingleVisualization = dynamic( import("@/charts/combo/chart-combo-line-single").then( (mod) => mod.ChartComboLineSingleVisualization, @@ -98,6 +104,10 @@ const GenericChart = (props: GenericChartProps) => { return ( <ChartColumnsVisualization {...commonProps} chartConfig={chartConfig} /> ); + case "bar": + return ( + <ChartBarsVisualization {...commonProps} chartConfig={chartConfig} /> + ); case "line": return ( <ChartLinesVisualization {...commonProps} chartConfig={chartConfig} /> diff --git a/app/config-types.ts b/app/config-types.ts index e32cc88a1..635a77e40 100644 --- a/app/config-types.ts +++ b/app/config-types.ts @@ -307,6 +307,37 @@ const ColumnConfig = t.intersection([ export type ColumnFields = t.TypeOf<typeof ColumnFields>; export type ColumnConfig = t.TypeOf<typeof ColumnConfig>; +const BarSegmentField = t.intersection([ + GenericSegmentField, + SortingField, + t.type({ type: ChartSubType }), +]); +export type BarSegmentField = t.TypeOf<typeof BarSegmentField>; + +const BarFields = t.intersection([ + t.type({ + x: t.intersection([GenericField, SortingField]), + y: GenericField, + }), + t.partial({ + segment: BarSegmentField, + animation: AnimationField, + }), +]); +const BarConfig = t.intersection([ + GenericChartConfig, + t.type( + { + chartType: t.literal("bar"), + interactiveFiltersConfig: InteractiveFiltersConfig, + fields: BarFields, + }, + "BarConfig" + ), +]); +export type BarFields = t.TypeOf<typeof BarFields>; +export type BarConfig = t.TypeOf<typeof BarConfig>; + const LineSegmentField = t.intersection([GenericSegmentField, SortingField]); export type LineSegmentField = t.TypeOf<typeof LineSegmentField>; @@ -726,9 +757,36 @@ const ComboLineColumnConfig = t.intersection([ ]); export type ComboLineColumnConfig = t.TypeOf<typeof ComboLineColumnConfig>; +const ComboLineBarFields = t.type({ + x: GenericField, + y: t.type({ + lineComponentId: t.string, + lineAxisOrientation: t.union([t.literal("left"), t.literal("right")]), + barComponentId: t.string, + palette: t.string, + colorMapping: ColorMapping, + }), +}); + +export type ComboLineBarFields = t.TypeOf<typeof ComboLineBarFields>; + +const ComboLineBarConfig = t.intersection([ + GenericChartConfig, + t.type( + { + chartType: t.literal("comboLineBar"), + fields: ComboLineBarFields, + interactiveFiltersConfig: InteractiveFiltersConfig, + }, + "ComboLineBarConfig" + ), +]); +export type ComboLineBarConfig = t.TypeOf<typeof ComboLineBarConfig>; + export type ChartSegmentField = | AreaSegmentField | ColumnSegmentField + | BarSegmentField | LineSegmentField | PieSegmentField | ScatterPlotSegmentField; @@ -736,6 +794,7 @@ export type ChartSegmentField = const RegularChartConfig = t.union([ AreaConfig, ColumnConfig, + BarConfig, LineConfig, MapConfig, PieConfig, @@ -795,6 +854,12 @@ export const isColumnConfig = ( return chartConfig.chartType === "column"; }; +export const isBarConfig = ( + chartConfig: ChartConfig +): chartConfig is BarConfig => { + return chartConfig.chartType === "bar"; +}; + export const isComboLineSingleConfig = ( chartConfig: ChartConfig ): chartConfig is ComboLineSingleConfig => { @@ -845,10 +910,13 @@ export const isMapConfig = ( export const canBeNormalized = ( chartConfig: ChartConfig -): chartConfig is AreaConfig | ColumnConfig => { +): chartConfig is AreaConfig | ColumnConfig | BarConfig => { return ( chartConfig.chartType === "area" || (chartConfig.chartType === "column" && + chartConfig.fields.segment !== undefined && + chartConfig.fields.segment.type === "stacked") || + (chartConfig.chartType === "bar" && chartConfig.fields.segment !== undefined && chartConfig.fields.segment.type === "stacked") ); @@ -859,10 +927,11 @@ export const isSegmentInConfig = ( ): chartConfig is | AreaConfig | ColumnConfig + | BarConfig | LineConfig | PieConfig | ScatterPlotConfig => { - return ["area", "column", "line", "pie", "scatterplot"].includes( + return ["area", "column", "bar", "line", "pie", "scatterplot"].includes( chartConfig.chartType ); }; @@ -870,13 +939,15 @@ export const isSegmentInConfig = ( export const isSortingInConfig = ( chartConfig: ChartConfig ): chartConfig is AreaConfig | ColumnConfig | LineConfig | PieConfig => { - return ["area", "column", "line", "pie"].includes(chartConfig.chartType); + return ["area", "column", "bar", "line", "pie"].includes( + chartConfig.chartType + ); }; export const isAnimationInConfig = ( chartConfig: ChartConfig ): chartConfig is ColumnConfig | MapConfig | PieConfig | ScatterPlotConfig => { - return ["column", "map", "pie", "scatterplot"].includes( + return ["column", "bar", "map", "pie", "scatterplot"].includes( chartConfig.chartType ); }; @@ -952,6 +1023,22 @@ type ColumnAdjusters = BaseAdjusters<ColumnConfig> & { }; }; +type BarAdjusters = BaseAdjusters<BarConfig> & { + fields: { + x: { componentId: FieldAdjuster<BarConfig, string> }; + y: { componentId: FieldAdjuster<BarConfig, string> }; + segment: FieldAdjuster< + BarConfig, + | LineSegmentField + | AreaSegmentField + | ScatterPlotSegmentField + | PieSegmentField + | TableFields + >; + animation: FieldAdjuster<BarConfig, AnimationField | undefined>; + }; +}; + type LineAdjusters = BaseAdjusters<LineConfig> & { fields: { x: { componentId: FieldAdjuster<LineConfig, string> }; @@ -1081,6 +1168,7 @@ type ComboLineColumnAdjusters = BaseAdjusters<ComboLineColumnConfig> & { export type ChartConfigsAdjusters = { column: ColumnAdjusters; + bar: BarAdjusters; line: LineAdjusters; area: AreaAdjusters; scatterplot: ScatterPlotAdjusters; diff --git a/app/icons/index.tsx b/app/icons/index.tsx index 1896f9f0d..a54f690a0 100644 --- a/app/icons/index.tsx +++ b/app/icons/index.tsx @@ -41,6 +41,8 @@ export const getChartIcon = (chartType: ChartType): IconName => { return "chartArea"; case "column": return "chartColumn"; + case "bar": + return "chartBar"; case "line": return "chartLine"; case "map": From c85ea4093bd4e119e8eacf4deb2b6322b12e4655 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joa=CC=83o=20Sobral?= <joao.tiago.sobral@gmail.com> Date: Fri, 29 Nov 2024 11:24:53 +0000 Subject: [PATCH 03/54] =?UTF-8?q?feat=20=E2=9A=A1=EF=B8=8F:=20.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/charts/bar/bars-grouped-state-props.ts | 72 ++--- app/charts/bar/bars-grouped-state.tsx | 234 +++++++-------- app/charts/bar/bars-grouped.tsx | 74 ++--- app/charts/bar/bars-stacked-state-props.ts | 74 ++--- app/charts/bar/bars-stacked-state.tsx | 230 +++++++-------- app/charts/bar/bars-stacked.tsx | 20 +- app/charts/bar/bars-state-props.ts | 70 ++--- app/charts/bar/bars-state.tsx | 168 +++++------ app/charts/bar/bars.tsx | 74 ++--- app/charts/bar/chart-bar.tsx | 10 +- app/charts/bar/overlay-bars.tsx | 18 +- app/charts/bar/rendering-utils.ts | 12 +- app/charts/chart-config-ui-options.ts | 4 +- app/charts/index.ts | 10 +- .../shared/axis-width-band-vertical.tsx | 134 +++++++++ app/charts/shared/axis-width-linear.tsx | 6 +- app/charts/shared/chart-helpers.tsx | 162 +++++++++++ app/charts/shared/chart-state.ts | 273 +++++++++++++++++- app/charts/shared/imputation.tsx | 53 ++++ .../shared/interaction/tooltip-content.tsx | 68 +++++ app/charts/shared/interaction/tooltip.tsx | 68 ++++- app/charts/shared/rendering-utils.ts | 122 ++++++++ app/charts/shared/stacked-helpers.ts | 71 +++++ app/config-types.ts | 4 +- 24 files changed, 1491 insertions(+), 540 deletions(-) create mode 100644 app/charts/shared/axis-width-band-vertical.tsx diff --git a/app/charts/bar/bars-grouped-state-props.ts b/app/charts/bar/bars-grouped-state-props.ts index a53dc865a..4877d4fed 100644 --- a/app/charts/bar/bars-grouped-state-props.ts +++ b/app/charts/bar/bars-grouped-state-props.ts @@ -4,21 +4,21 @@ import { useCallback, useMemo } from "react"; import { usePlottableData } from "@/charts/shared/chart-helpers"; import { - BandXVariables, + BandYVariables, BaseVariables, ChartStateData, InteractiveFiltersVariables, - NumericalYErrorVariables, - NumericalYVariables, + NumericalXErrorVariables, + NumericalXVariables, RenderingVariables, SegmentVariables, SortingVariables, - useBandXVariables, + useBandYVariables, useBaseVariables, useChartData, useInteractiveFiltersVariables, - useNumericalYErrorVariables, - useNumericalYVariables, + useNumericalXErrorVariables, + useNumericalXVariables, useSegmentVariables, } from "@/charts/shared/chart-state"; import { useRenderingKeyVariable } from "@/charts/shared/rendering-utils"; @@ -30,9 +30,9 @@ import { ChartProps } from "../shared/ChartProps"; export type BarsGroupedStateVariables = BaseVariables & SortingVariables & - BandXVariables & - NumericalYVariables & - NumericalYErrorVariables & + BandYVariables & + NumericalXVariables & + NumericalXErrorVariables & SegmentVariables & RenderingVariables & InteractiveFiltersVariables; @@ -50,19 +50,19 @@ export const useBarsGroupedStateVariables = ( } = props; const { fields, interactiveFiltersConfig } = chartConfig; const { x, y, segment, animation } = fields; - const xDimension = dimensionsById[x.componentId]; + const yDimension = dimensionsById[y.componentId]; const filters = useChartConfigFilters(chartConfig); const baseVariables = useBaseVariables(chartConfig); - const bandXVariables = useBandXVariables(x, { + const numericalXVariables = useNumericalXVariables("bar", x, { + measuresById, + }); + const bandYVariables = useBandYVariables(y, { dimensionsById, observations, }); - const numericalYVariables = useNumericalYVariables("bar", y, { - measuresById, - }); - const numericalYErrorVariables = useNumericalYErrorVariables(y, { - numericalYVariables, + const numericalYErrorVariables = useNumericalXErrorVariables(x, { + numericalXVariables, dimensions, measures, }); @@ -75,33 +75,33 @@ export const useBarsGroupedStateVariables = ( { dimensionsById } ); - const { getX, getXAsDate } = bandXVariables; - const { getY } = numericalYVariables; + const { getY, getYAsDate } = bandYVariables; + const { getX } = numericalXVariables; const sortData: BarsGroupedStateVariables["sortData"] = useCallback( (data) => { - const { sortingOrder, sortingType } = x.sorting ?? {}; - const xGetter = isTemporalEntityDimension(xDimension) - ? (d: Observation) => getXAsDate(d).getTime().toString() - : getX; + const { sortingOrder, sortingType } = y.sorting ?? {}; + const yGetter = isTemporalEntityDimension(yDimension) + ? (d: Observation) => getYAsDate(d).getTime().toString() + : getY; const order = [ ...rollup( data, - (v) => sum(v, (d) => getY(d)), - (d) => xGetter(d) + (v) => sum(v, (d) => getX(d)), + (d) => yGetter(d) ), ] .sort((a, b) => ascending(a[1], b[1])) .map((d) => d[0]); if (sortingType === "byDimensionLabel") { - return orderBy(data, xGetter, sortingOrder); + return orderBy(data, yGetter, sortingOrder); } else if (sortingType === "byMeasure") { - return sortByIndex({ data, order, getCategory: xGetter, sortingOrder }); + return sortByIndex({ data, order, getCategory: yGetter, sortingOrder }); } else { - return orderBy(data, xGetter, "asc"); + return orderBy(data, yGetter, "asc"); } }, - [getX, getXAsDate, getY, x.sorting, xDimension] + [getX, getYAsDate, getY, y.sorting, yDimension] ); const getRenderingKey = useRenderingKeyVariable( @@ -114,8 +114,8 @@ export const useBarsGroupedStateVariables = ( return { ...baseVariables, sortData, - ...bandXVariables, - ...numericalYVariables, + ...bandYVariables, + ...numericalXVariables, ...numericalYErrorVariables, ...segmentVariables, ...interactiveFiltersVariables, @@ -130,22 +130,22 @@ export const useBarsGroupedStateData = ( const { chartConfig, observations } = chartProps; const { sortData, - xDimension, - getXAsDate, - getY, + yDimension, + getYAsDate, + getX, getSegmentAbbreviationOrLabel, getTimeRangeDate, } = variables; const plottableData = usePlottableData(observations, { - getY, + getX, }); const sortedPlottableData = useMemo(() => { return sortData(plottableData); }, [sortData, plottableData]); const data = useChartData(sortedPlottableData, { chartConfig, - timeRangeDimensionId: xDimension.id, - getXAsDate, + timeRangeDimensionId: yDimension.id, + getXAsDate: getYAsDate, getSegmentAbbreviationOrLabel, getTimeRangeDate, }); diff --git a/app/charts/bar/bars-grouped-state.tsx b/app/charts/bar/bars-grouped-state.tsx index 302346410..71716c822 100644 --- a/app/charts/bar/bars-grouped-state.tsx +++ b/app/charts/bar/bars-grouped-state.tsx @@ -31,9 +31,9 @@ import { ChartContext, ChartStateData, CommonChartState, - InteractiveXTimeRangeState, + InteractiveYTimeRangeState, } from "@/charts/shared/chart-state"; -import { TooltipInfo } from "@/charts/shared/interaction/tooltip"; +import { TooltipInfoInverted } from "@/charts/shared/interaction/tooltip"; import { getCenteredTooltipPlacement, MOBILE_TOOLTIP_PLACEMENT, @@ -56,17 +56,17 @@ import { ChartProps } from "../shared/ChartProps"; export type GroupedBarsState = CommonChartState & BarsGroupedStateVariables & - InteractiveXTimeRangeState & { + InteractiveYTimeRangeState & { chartType: "bar"; - xScale: ScaleBand<string>; - xScaleInteraction: ScaleBand<string>; - xScaleIn: ScaleBand<string>; - yScale: ScaleLinear<number, number>; + yScale: ScaleBand<string>; + yScaleInteraction: ScaleBand<string>; + yScaleIn: ScaleBand<string>; + xScale: ScaleLinear<number, number>; segments: string[]; colors: ScaleOrdinal<string, string>; getColorLabel: (segment: string) => string; grouped: [string, Observation[]][]; - getAnnotationInfo: (d: Observation) => TooltipInfo; + getAnnotationInfo: (d: Observation) => TooltipInfoInverted; }; const useBarsGroupedState = ( @@ -76,18 +76,18 @@ const useBarsGroupedState = ( ): GroupedBarsState => { const { chartConfig } = chartProps; const { - xDimension, + yDimension, getX, - getXAsDate, - getXAbbreviationOrLabel, - getXLabel, - yMeasure, + getYAsDate, + getYAbbreviationOrLabel, + getYLabel, + xMeasure, getY, - getMinY, - showYStandardError, - yErrorMeasure, - getYError, - getYErrorRange, + getMinX, + showXStandardError, + xErrorMeasure, + getXError, + getXErrorRange, segmentDimension, segmentsByAbbreviationOrLabel, getSegment, @@ -121,11 +121,11 @@ const useBarsGroupedState = ( return Object.fromEntries( rollup( segmentData, - (v) => sum(v, (x) => getY(x)), - (x) => getSegment(x) + (v) => sum(v, (y) => getX(y)), + (y) => getSegment(y) ) ); - }, [segmentData, getY, getSegment]); + }, [segmentData, getX, getSegment]); const segmentFilter = segmentDimension?.id ? chartConfig.cubes.find((d) => d.iri === segmentDimension.cubeIri) @@ -167,27 +167,27 @@ const useBarsGroupedState = ( ]); /* Scales */ - const xFilter = chartConfig.cubes.find((d) => d.iri === xDimension.cubeIri) - ?.filters[xDimension.id]; - const sumsByX = useMemo(() => { + const yFilter = chartConfig.cubes.find((d) => d.iri === yDimension.cubeIri) + ?.filters[yDimension.id]; + const sumsByY = useMemo(() => { return Object.fromEntries( rollup( chartData, - (v) => sum(v, (x) => getY(x)), - (x) => getX(x) + (v) => sum(v, (y) => getX(y)), + (y) => getY(y) ) ); }, [chartData, getX, getY]); const { - xTimeRangeDomainLabels, + yTimeRangeDomainLabels, colors, - yScale, - paddingYScale, - xScaleTimeRange, xScale, - xScaleIn, - xScaleInteraction, + paddingYScale, + yScaleTimeRange, + yScale, + yScaleIn, + yScaleInteraction, } = useMemo(() => { const colors = scaleOrdinal<string, string>(); @@ -213,54 +213,54 @@ const useBarsGroupedState = ( colors.unknown(() => undefined); - const xValues = [...new Set(scalesData.map(getX))]; - const xTimeRangeValues = [...new Set(timeRangeData.map(getX))]; - const xSorting = fields.x?.sorting; - const xSorters = makeDimensionValueSorters(xDimension, { - sorting: xSorting, - useAbbreviations: fields.x?.useAbbreviations, - measureBySegment: sumsByX, - dimensionFilter: xFilter, + const yValues = [...new Set(scalesData.map(getY))]; + const yTimeRangeValues = [...new Set(timeRangeData.map(getY))]; + const ySorting = fields.y?.sorting; + const ySorters = makeDimensionValueSorters(yDimension, { + sorting: ySorting, + useAbbreviations: fields.y?.useAbbreviations, + measureBySegment: sumsByY, + dimensionFilter: yFilter, }); - const xDomain = orderBy( - xValues, - xSorters, - getSortingOrders(xSorters, xSorting) + const yDomain = orderBy( + yValues, + ySorters, + getSortingOrders(ySorters, ySorting) ); - const xTimeRangeDomainLabels = xTimeRangeValues.map(getXLabel); - const xScale = scaleBand() - .domain(xDomain) + const yTimeRangeDomainLabels = yTimeRangeValues.map(getYLabel); + const yScale = scaleBand() + .domain(yDomain) .paddingInner(PADDING_INNER) .paddingOuter(PADDING_OUTER); - const xScaleInteraction = scaleBand() - .domain(xDomain) + const yScaleInteraction = scaleBand() + .domain(yDomain) .paddingInner(0) .paddingOuter(0); - const xScaleIn = scaleBand().domain(segments).padding(PADDING_WITHIN); + const yScaleIn = scaleBand().domain(segments).padding(PADDING_WITHIN); - const xScaleTimeRangeDomain = extent(timeRangeData, (d) => - getXAsDate(d) + const yScaleTimeRangeDomain = extent(timeRangeData, (d) => + getYAsDate(d) ) as [Date, Date]; - const xScaleTimeRange = scaleTime().domain(xScaleTimeRangeDomain); + const yScaleTimeRange = scaleTime().domain(yScaleTimeRangeDomain); - // y - const minValue = getMinY(scalesData, (d) => - getYErrorRange ? getYErrorRange(d)[0] : getY(d) + // x + const minValue = getMinX(scalesData, (d) => + getXErrorRange ? getXErrorRange(d)[0] : getX(d) ); const maxValue = Math.max( max(scalesData, (d) => - getYErrorRange ? getYErrorRange(d)[1] : getY(d) + getXErrorRange ? getXErrorRange(d)[1] : getX(d) ) ?? 0, 0 ); - const yScale = scaleLinear().domain([minValue, maxValue]).nice(); + const xScale = scaleLinear().domain([minValue, maxValue]).nice(); - const minPaddingValue = getMinY(paddingData, (d) => - getYErrorRange ? getYErrorRange(d)[0] : getY(d) + const minPaddingValue = getMinX(paddingData, (d) => + getXErrorRange ? getXErrorRange(d)[0] : getX(d) ); const maxPaddingValue = Math.max( max(paddingData, (d) => - getYErrorRange ? getYErrorRange(d)[1] : getY(d) + getXErrorRange ? getXErrorRange(d)[1] : getX(d) ) ?? 0, 0 ); @@ -270,44 +270,44 @@ const useBarsGroupedState = ( return { colors, - yScale, - paddingYScale, - xScaleTimeRange, xScale, - xScaleIn, - xScaleInteraction, - xTimeRangeDomainLabels, + paddingYScale, + yScaleTimeRange, + yScale, + yScaleIn, + yScaleInteraction, + yTimeRangeDomainLabels, }; }, [ fields.segment, - fields.x?.sorting, - fields.x?.useAbbreviations, + fields.y?.sorting, + fields.y?.useAbbreviations, segmentDimension, scalesData, - getX, - xDimension, - sumsByX, - xFilter, - getXLabel, + getY, + yDimension, + sumsByY, + yFilter, + getYLabel, segments, timeRangeData, paddingData, allSegments, segmentsByAbbreviationOrLabel, segmentsByValue, - getXAsDate, - getYErrorRange, - getY, - getMinY, + getYAsDate, + getXErrorRange, + getX, + getMinX, ]); // Group const grouped: [string, Observation[]][] = useMemo(() => { - const xKeys = xScale.domain(); - const groupedMap = group(chartData, getX); + const yKeys = yScale.domain(); + const groupedMap = group(chartData, getY); const grouped: [string, Observation[]][] = - groupedMap.size < xKeys.length - ? xKeys.map((d) => { + groupedMap.size < yKeys.length + ? yKeys.map((d) => { if (groupedMap.has(d)) { return [d, groupedMap.get(d) as Observation[]]; } else { @@ -327,7 +327,7 @@ const useBarsGroupedState = ( }), ]; }); - }, [getSegment, getX, chartData, segmentSortingOrder, segments, xScale]); + }, [getSegment, getY, chartData, segmentSortingOrder, segments, yScale]); const { left, bottom } = useChartPadding({ yScale: paddingYScale, @@ -336,13 +336,13 @@ const useBarsGroupedState = ( interactiveFiltersConfig, animationPresent: !!fields.animation, formatNumber, - bandDomain: xTimeRangeDomainLabels.every((d) => d === undefined) - ? xScale.domain() - : xTimeRangeDomainLabels, + bandDomain: yTimeRangeDomainLabels.every((d) => d === undefined) + ? yScale.domain() + : yTimeRangeDomainLabels, }); const right = 40; const { offset: yAxisLabelMargin } = useAxisLabelHeightOffset({ - label: yMeasure.label, + label: xMeasure.label, width, marginLeft: left, marginRight: right, @@ -357,21 +357,21 @@ const useBarsGroupedState = ( const { chartWidth, chartHeight } = bounds; // Adjust of scales based on chart dimensions - xScale.range([0, chartWidth]); - xScaleInteraction.range([0, chartWidth]); - xScaleIn.range([0, xScale.bandwidth()]); - xScaleTimeRange.range([0, chartWidth]); - yScale.range([chartHeight, 0]); + yScale.range([0, chartWidth]); + yScaleInteraction.range([0, chartWidth]); + yScaleIn.range([0, yScale.bandwidth()]); + yScaleTimeRange.range([0, chartWidth]); + xScale.range([chartHeight, 0]); const isMobile = useIsMobile(); // Tooltip - const getAnnotationInfo = (datum: Observation): TooltipInfo => { - const bw = xScale.bandwidth(); - const x = getX(datum); + const getAnnotationInfo = (datum: Observation): TooltipInfoInverted => { + const bw = yScale.bandwidth(); + const y = getY(datum); - const tooltipValues = chartData.filter((d) => getX(d) === x); - const yValues = tooltipValues.map(getY); + const tooltipValues = chartData.filter((d) => getY(d) === y); + const xValues = tooltipValues.map(getX); const sortedTooltipValues = sortByIndex({ data: tooltipValues, order: segments, @@ -379,49 +379,49 @@ const useBarsGroupedState = ( // Always ascending to match visual order of colors of the stack sortingOrder: "asc", }); - const yValueFormatter = (value: number | null) => { + const xValueFormatter = (value: number | null) => { return formatNumberWithUnit( value, - formatters[yMeasure.id] ?? formatNumber, - yMeasure.unit + formatters[xMeasure.id] ?? formatNumber, + xMeasure.unit ); }; - const xAnchorRaw = (xScale(x) as number) + bw * 0.5; - const [yMin, yMax] = extent(yValues, (d) => d ?? 0) as [number, number]; - const yAnchor = isMobile ? chartHeight : yScale((yMin + yMax) * 0.5); + const yAnchorRaw = (yScale(y) as number) + bw * 0.5; + const [xMin, xMax] = extent(xValues, (d) => d ?? 0) as [number, number]; + const xAnchor = isMobile ? chartHeight : xScale((xMin + xMax) * 0.5); const placement = isMobile ? MOBILE_TOOLTIP_PLACEMENT : getCenteredTooltipPlacement({ chartWidth, - xAnchor: xAnchorRaw, + xAnchor: yAnchorRaw, topAnchor: !fields.segment, }); const getError = (d: Observation) => { - if (!showYStandardError || !getYError || getYError(d) == null) { + if (!showXStandardError || !getXError || getXError(d) == null) { return; } - return `${getYError(d)}${yErrorMeasure?.unit ?? ""}`; + return `${getXError(d)}${xErrorMeasure?.unit ?? ""}`; }; return { - xAnchor: xAnchorRaw + (placement.x === "right" ? 0.5 : -0.5) * bw, - yAnchor, + yAnchor: yAnchorRaw + (placement.y === "bottom" ? 0.5 : -0.5) * bw, + xAnchor, placement, - xValue: getXAbbreviationOrLabel(datum), + yValue: getYAbbreviationOrLabel(datum), datum: { label: fields.segment && getSegmentAbbreviationOrLabel(datum), - value: yValueFormatter(getY(datum)), + value: xValueFormatter(getX(datum)), error: getError(datum), color: colors(getSegment(datum)) as string, }, values: sortedTooltipValues.map((td) => ({ label: getSegmentAbbreviationOrLabel(td), - value: yMeasure.unit - ? `${formatNumber(getY(td))} ${yMeasure.unit}` - : formatNumber(getY(td)), + value: xMeasure.unit + ? `${formatNumber(getX(td))} ${xMeasure.unit}` + : formatNumber(getX(td)), error: getError(td), color: colors(getSegment(td)) as string, })), @@ -433,11 +433,11 @@ const useBarsGroupedState = ( bounds, chartData, allData, - xScale, - xScaleInteraction, - xScaleIn, - xScaleTimeRange, yScale, + yScaleInteraction, + yScaleIn, + yScaleTimeRange, + xScale, segments, colors, getColorLabel: getSegmentLabel, diff --git a/app/charts/bar/bars-grouped.tsx b/app/charts/bar/bars-grouped.tsx index c293fd57d..3b9d9c18a 100644 --- a/app/charts/bar/bars-grouped.tsx +++ b/app/charts/bar/bars-grouped.tsx @@ -4,10 +4,10 @@ import { GroupedBarsState } from "@/charts/bar/bars-grouped-state"; import { RenderBarDatum, renderBars } from "@/charts/bar/rendering-utils"; import { useChartState } from "@/charts/shared/chart-state"; import { - RenderWhiskerDatum, filterWithoutErrors, + renderBarWhiskers, renderContainer, - renderWhiskers, + RenderWhiskerBarDatum, } from "@/charts/shared/rendering-utils"; import { useTransitionStore } from "@/stores/transition"; @@ -15,49 +15,49 @@ export const ErrorWhiskers = () => { const { bounds, xScale, - xScaleIn, - getYErrorRange, - getYError, + yScaleIn, + getXErrorRange, + getXError, yScale, getSegment, grouped, - showYStandardError, + showXStandardError, } = useChartState() as GroupedBarsState; const { margins, width, height } = bounds; const ref = useRef<SVGGElement>(null); const enableTransition = useTransitionStore((state) => state.enable); const transitionDuration = useTransitionStore((state) => state.duration); - const renderData: RenderWhiskerDatum[] = useMemo(() => { - if (!getYErrorRange || !showYStandardError) { + const renderData: RenderWhiskerBarDatum[] = useMemo(() => { + if (!getXErrorRange || !showXStandardError) { return []; } - const bandwidth = xScaleIn.bandwidth(); + const bandwidth = yScaleIn.bandwidth(); return grouped - .filter((d) => d[1].some(filterWithoutErrors(getYError))) + .filter((d) => d[1].some(filterWithoutErrors(getXError))) .flatMap(([segment, observations]) => observations.map((d) => { - const x0 = xScaleIn(getSegment(d)) as number; + const y0 = yScaleIn(getSegment(d)) as number; const barWidth = Math.min(bandwidth, 15); - const [y1, y2] = getYErrorRange(d); + const [x1, x2] = getXErrorRange(d); return { key: `${segment}-${getSegment(d)}`, - x: (xScale(segment) as number) + x0 + bandwidth / 2 - barWidth / 2, - y1: yScale(y1), - y2: yScale(y2), + y: (yScale(segment) as number) + y0 + bandwidth / 2 - barWidth / 2, + x1: xScale(x1), + x2: xScale(x2), width: barWidth, - } as RenderWhiskerDatum; + } as RenderWhiskerBarDatum; }) ); // eslint-disable-next-line react-hooks/exhaustive-deps }, [ getSegment, - getYErrorRange, - getYError, + getXErrorRange, + getXError, grouped, - showYStandardError, + showXStandardError, xScale, - xScaleIn, + yScaleIn, yScale, width, height, @@ -69,7 +69,7 @@ export const ErrorWhiskers = () => { id: "bars-grouped-error-whiskers", transform: `translate(${margins.left} ${margins.top})`, transition: { enable: enableTransition, duration: transitionDuration }, - render: (g, opts) => renderWhiskers(g, renderData, opts), + render: (g, opts) => renderBarWhiskers(g, renderData, opts), }); } }, [ @@ -87,8 +87,8 @@ export const BarsGrouped = () => { const { bounds, xScale, - xScaleIn, - getY, + yScaleIn, + getX, yScale, getSegment, colors, @@ -99,22 +99,22 @@ export const BarsGrouped = () => { const enableTransition = useTransitionStore((state) => state.enable); const transitionDuration = useTransitionStore((state) => state.duration); const { margins, height } = bounds; - const bandwidth = xScaleIn.bandwidth(); - const y0 = yScale(0); + const bandwidth = yScaleIn.bandwidth(); + const x0 = xScale(0); const renderData: RenderBarDatum[] = useMemo(() => { return grouped.flatMap(([segment, observations]) => { return observations.map((d) => { const key = getRenderingKey(d, getSegment(d)); - const x = getSegment(d); - const y = getY(d) ?? NaN; + const y = getSegment(d); + const x = getX(d) ?? NaN; return { key, - x: (xScale(segment) as number) + (xScaleIn(x) as number), - y: yScale(Math.max(y, 0)), - width: bandwidth, - height: Math.max(0, Math.abs(yScale(y) - y0)), - color: colors(x), + y: (yScale(segment) as number) + (yScaleIn(y) as number), + x: xScale(Math.max(x, 0)), + width: Math.max(0, Math.abs(xScale(x) - x0)), + height: bandwidth, + color: colors(y), }; }); }); @@ -123,12 +123,12 @@ export const BarsGrouped = () => { colors, getSegment, bandwidth, - getY, + getX, grouped, - xScaleIn, + yScaleIn, xScale, yScale, - y0, + x0, getRenderingKey, height, ]); @@ -139,7 +139,7 @@ export const BarsGrouped = () => { id: "bars-grouped", transform: `translate(${margins.left} ${margins.top})`, transition: { enable: enableTransition, duration: transitionDuration }, - render: (g, opts) => renderBars(g, renderData, { ...opts, y0 }), + render: (g, opts) => renderBars(g, renderData, { ...opts, x0 }), }); } }, [ @@ -148,7 +148,7 @@ export const BarsGrouped = () => { margins.top, renderData, transitionDuration, - y0, + x0, ]); return <g ref={ref} />; diff --git a/app/charts/bar/bars-stacked-state-props.ts b/app/charts/bar/bars-stacked-state-props.ts index f8584cc91..eb0f105ea 100644 --- a/app/charts/bar/bars-stacked-state-props.ts +++ b/app/charts/bar/bars-stacked-state-props.ts @@ -3,19 +3,19 @@ import { useCallback, useMemo } from "react"; import { getWideData, usePlottableData } from "@/charts/shared/chart-helpers"; import { - BandXVariables, + BandYVariables, BaseVariables, ChartStateData, InteractiveFiltersVariables, - NumericalYVariables, + NumericalXVariables, RenderingVariables, SegmentVariables, SortingVariables, - useBandXVariables, + useBandYVariables, useBaseVariables, useChartData, useInteractiveFiltersVariables, - useNumericalYVariables, + useNumericalXVariables, useSegmentVariables, } from "@/charts/shared/chart-state"; import { useRenderingKeyVariable } from "@/charts/shared/rendering-utils"; @@ -27,8 +27,8 @@ import { ChartProps } from "../shared/ChartProps"; export type BarsStackedStateVariables = BaseVariables & SortingVariables<{ plottableDataWide: Observation[] }> & - BandXVariables & - NumericalYVariables & + BandYVariables & + NumericalXVariables & SegmentVariables & RenderingVariables & InteractiveFiltersVariables; @@ -45,17 +45,17 @@ export const useBarsStackedStateVariables = ( } = props; const { fields, interactiveFiltersConfig } = chartConfig; const { x, y, segment, animation } = fields; - const xDimension = dimensionsById[x.componentId]; + const yDimension = dimensionsById[y.componentId]; const filters = useChartConfigFilters(chartConfig); const baseVariables = useBaseVariables(chartConfig); - const bandXVariables = useBandXVariables(x, { + const numericalXVariables = useNumericalXVariables("bar", x, { + measuresById, + }); + const bandYVariables = useBandYVariables(y, { dimensionsById, observations, }); - const numericalYVariables = useNumericalYVariables("bar", y, { - measuresById, - }); const segmentVariables = useSegmentVariables(segment, { dimensionsById, observations, @@ -65,33 +65,33 @@ export const useBarsStackedStateVariables = ( { dimensionsById } ); - const { getX, getXAsDate } = bandXVariables; + const { getY, getYAsDate } = bandYVariables; const sortData: BarsStackedStateVariables["sortData"] = useCallback( (data, { plottableDataWide }) => { - const { sortingOrder, sortingType } = x.sorting ?? {}; - const xGetter = isTemporalEntityDimension(xDimension) - ? (d: Observation) => getXAsDate(d).getTime().toString() - : getX; - const xOrder = plottableDataWide + const { sortingOrder, sortingType } = y.sorting ?? {}; + const yGetter = isTemporalEntityDimension(yDimension) + ? (d: Observation) => getYAsDate(d).getTime().toString() + : getY; + const yOrder = plottableDataWide .sort((a, b) => ascending(a.total ?? undefined, b.total ?? undefined)) - .map(xGetter); + .map(yGetter); if (sortingOrder === "desc" && sortingType === "byDimensionLabel") { - return [...data].sort((a, b) => descending(xGetter(a), xGetter(b))); + return [...data].sort((a, b) => descending(yGetter(a), yGetter(b))); } else if (sortingOrder === "asc" && sortingType === "byDimensionLabel") { - return [...data].sort((a, b) => ascending(xGetter(a), xGetter(b))); + return [...data].sort((a, b) => ascending(yGetter(a), yGetter(b))); } else if (sortingType === "byMeasure") { return sortByIndex({ data, - order: xOrder, - getCategory: xGetter, + order: yOrder, + getCategory: yGetter, sortingOrder, }); } else { - return [...data].sort((a, b) => ascending(xGetter(a), xGetter(b))); + return [...data].sort((a, b) => ascending(yGetter(a), yGetter(b))); } }, - [getX, getXAsDate, x.sorting, xDimension] + [getY, getYAsDate, y.sorting, yDimension] ); const getRenderingKey = useRenderingKeyVariable( @@ -104,8 +104,8 @@ export const useBarsStackedStateVariables = ( return { ...baseVariables, sortData, - ...bandXVariables, - ...numericalYVariables, + ...bandYVariables, + ...numericalXVariables, ...segmentVariables, ...interactiveFiltersVariables, getRenderingKey, @@ -122,26 +122,26 @@ export const useBarsStackedStateData = ( ): BarsStackedStateData => { const { chartConfig, observations } = chartProps; const { fields } = chartConfig; - const { x } = fields; + const { y } = fields; const { sortData, - xDimension, + yDimension, getX, - getXAsDate, + getYAsDate, getY, getSegment, getSegmentAbbreviationOrLabel, getTimeRangeDate, } = variables; const plottableData = usePlottableData(observations, { - getY, + getX, }); const { sortedPlottableData, plottableDataWide } = useMemo(() => { - const plottableDataByX = group(plottableData, getX); + const plottableDataByY = group(plottableData, getY); const plottableDataWide = getWideData({ - dataGroupedByX: plottableDataByX, - xKey: x.componentId, - getY, + dataGroupedByX: plottableDataByY, + xKey: y.componentId, + getY: getX, getSegment, }); @@ -151,11 +151,11 @@ export const useBarsStackedStateData = ( }), plottableDataWide, }; - }, [plottableData, getX, x.componentId, getY, getSegment, sortData]); + }, [plottableData, getX, y.componentId, getY, getSegment, sortData]); const data = useChartData(sortedPlottableData, { chartConfig, - timeRangeDimensionId: xDimension.id, - getXAsDate, + timeRangeDimensionId: yDimension.id, + getXAsDate: getYAsDate, getSegmentAbbreviationOrLabel, getTimeRangeDate, }); diff --git a/app/charts/bar/bars-stacked-state.tsx b/app/charts/bar/bars-stacked-state.tsx index d6d58ce24..76f892174 100644 --- a/app/charts/bar/bars-stacked-state.tsx +++ b/app/charts/bar/bars-stacked-state.tsx @@ -32,23 +32,23 @@ import { useChartPadding, } from "@/charts/shared/chart-dimensions"; import { - getWideData, - normalizeData, - useGetIdentityY, + getWideDataInverted, + normalizeDataInverted, + useGetIdentityX, } from "@/charts/shared/chart-helpers"; import { ChartContext, CommonChartState, - InteractiveXTimeRangeState, + InteractiveYTimeRangeState, } from "@/charts/shared/chart-state"; -import { TooltipInfo } from "@/charts/shared/interaction/tooltip"; +import { TooltipInfoInverted } from "@/charts/shared/interaction/tooltip"; import { getCenteredTooltipPlacement, MOBILE_TOOLTIP_PLACEMENT, } from "@/charts/shared/interaction/tooltip-box"; import { - getStackedTooltipValueFormatter, - getStackedYScale, + getStackedTooltipValueFormatterInverted, + getStackedXScale, } from "@/charts/shared/stacked-helpers"; import useChartFormatters from "@/charts/shared/use-chart-formatters"; import { InteractionProvider } from "@/charts/shared/use-interaction"; @@ -69,11 +69,11 @@ import { ChartProps } from "../shared/ChartProps"; export type StackedBarsState = CommonChartState & BarsStackedStateVariables & - InteractiveXTimeRangeState & { + InteractiveYTimeRangeState & { chartType: "bar"; - xScale: ScaleBand<string>; - xScaleInteraction: ScaleBand<string>; - yScale: ScaleLinear<number, number>; + yScale: ScaleBand<string>; + yScaleInteraction: ScaleBand<string>; + xScale: ScaleLinear<number, number>; segments: string[]; colors: ScaleOrdinal<string, string>; getColorLabel: (segment: string) => string; @@ -82,7 +82,7 @@ export type StackedBarsState = CommonChartState & getAnnotationInfo: ( d: Observation, orderedSegments: string[] - ) => TooltipInfo; + ) => TooltipInfoInverted; }; const useBarsStackedState = ( @@ -92,12 +92,12 @@ const useBarsStackedState = ( ): StackedBarsState => { const { chartConfig } = chartProps; const { - xDimension, + yDimension, getX, - getXAsDate, - getXAbbreviationOrLabel, - getXLabel, - yMeasure, + getYAsDate, + getYAbbreviationOrLabel, + getYLabel, + xMeasure, getY, segmentDimension, segmentsByAbbreviationOrLabel, @@ -105,7 +105,7 @@ const useBarsStackedState = ( getSegmentAbbreviationOrLabel, getSegmentLabel, } = variables; - const getIdentityY = useGetIdentityY(yMeasure.id); + const getIdentityX = useGetIdentityX(xMeasure.id); const { chartData, scalesData, @@ -121,7 +121,7 @@ const useBarsStackedState = ( const formatters = useChartFormatters(chartProps); const calculationType = useChartInteractiveFilters((d) => d.calculation.type); - const xKey = fields.x.componentId; + const yKey = fields.y.componentId; const segmentsByValue = useMemo(() => { const values = segmentDimension?.values || []; @@ -133,11 +133,11 @@ const useBarsStackedState = ( return Object.fromEntries( rollup( scalesData, - (v) => sum(v, (x) => getY(x)), + (v) => sum(v, (x) => getX(x)), (x) => getSegment(x) ) ); - }, [getSegment, getY, scalesData]); + }, [getSegment, getX, scalesData]); const segmentFilter = segmentDimension?.id ? chartConfig.cubes.find((d) => d.iri === segmentDimension.cubeIri) @@ -174,53 +174,53 @@ const useBarsStackedState = ( getSegment, ]); - const sumsByX = useMemo(() => { + const sumsByY = useMemo(() => { return Object.fromEntries( rollup( chartData, - (v) => sum(v, (x) => getY(x)), - (x) => getX(x) + (v) => sum(v, (x) => getX(x)), + (x) => getY(x) ) ); }, [chartData, getX, getY]); const normalize = calculationType === "percent"; - const chartDataGroupedByX = useMemo(() => { + const chartDataGroupedByY = useMemo(() => { if (normalize) { return group( - normalizeData(chartData, { - yKey: yMeasure.id, - getY, - getTotalGroupValue: (d) => sumsByX[getX(d)], + normalizeDataInverted(chartData, { + xKey: xMeasure.id, + getX, + getTotalGroupValue: (d) => sumsByY[getY(d)], }), - getX + getY ); } - return group(chartData, getX); - }, [chartData, getX, sumsByX, getY, yMeasure.id, normalize]); + return group(chartData, getY); + }, [chartData, getX, sumsByY, getY, xMeasure.id, normalize]); const chartWideData = useMemo(() => { - return getWideData({ - dataGroupedByX: chartDataGroupedByX, - xKey, - getY, + return getWideDataInverted({ + dataGroupedByY: chartDataGroupedByY, + yKey, + getX, getSegment, allSegments: segments, imputationType: "zeros", }); - }, [getSegment, getY, chartDataGroupedByX, segments, xKey]); + }, [getSegment, getX, chartDataGroupedByY, segments, yKey]); - const xFilter = chartConfig.cubes.find((d) => d.iri === xDimension.cubeIri) - ?.filters[xDimension.id]; + const yFilter = chartConfig.cubes.find((d) => d.iri === yDimension.cubeIri) + ?.filters[yDimension.id]; // Map ordered segments labels to colors const { colors, - xScale, - xTimeRangeDomainLabels, - xScaleInteraction, - xScaleTimeRange, + yScale, + yTimeRangeDomainLabels, + yScaleInteraction, + yScaleTimeRange, } = useMemo(() => { const colors = scaleOrdinal<string, string>(); @@ -257,52 +257,52 @@ const useBarsStackedState = ( colors.unknown(() => undefined); - const xValues = [...new Set(scalesData.map(getX))]; - const xTimeRangeValues = [...new Set(timeRangeData.map(getX))]; - const xSorting = fields.x?.sorting; - const xSorters = makeDimensionValueSorters(xDimension, { - sorting: xSorting, - useAbbreviations: fields.x?.useAbbreviations, - measureBySegment: sumsByX, - dimensionFilter: xFilter, + const yValues = [...new Set(scalesData.map(getY))]; + const yTimeRangeValues = [...new Set(timeRangeData.map(getY))]; + const ySorting = fields.y?.sorting; + const ySorters = makeDimensionValueSorters(yDimension, { + sorting: ySorting, + useAbbreviations: fields.y?.useAbbreviations, + measureBySegment: sumsByY, + dimensionFilter: yFilter, }); - const xDomain = orderBy( - xValues, - xSorters, - getSortingOrders(xSorters, xSorting) + const yDomain = orderBy( + yValues, + ySorters, + getSortingOrders(ySorters, ySorting) ); - const xTimeRangeDomainLabels = xTimeRangeValues.map(getXLabel); - const xScale = scaleBand() - .domain(xDomain) + const yTimeRangeDomainLabels = yTimeRangeValues.map(getYLabel); + const yScale = scaleBand() + .domain(yDomain) .paddingInner(PADDING_INNER) .paddingOuter(PADDING_OUTER); - const xScaleInteraction = scaleBand() - .domain(xDomain) + const yScaleInteraction = scaleBand() + .domain(yDomain) .paddingInner(0) .paddingOuter(0); - const xScaleTimeRangeDomain = extent(timeRangeData, (d) => - getXAsDate(d) + const yScaleTimeRangeDomain = extent(timeRangeData, (d) => + getYAsDate(d) ) as [Date, Date]; - const xScaleTimeRange = scaleTime().domain(xScaleTimeRangeDomain); + const yScaleTimeRange = scaleTime().domain(yScaleTimeRangeDomain); return { colors, - xScale, - xTimeRangeDomainLabels, - xScaleTimeRange, - xScaleInteraction, + yScale, + yTimeRangeDomainLabels, + yScaleTimeRange, + yScaleInteraction, }; }, [ fields.segment, - fields.x.sorting, - fields.x.useAbbreviations, - xDimension, - xFilter, - sumsByX, - getX, - getXLabel, - getXAsDate, + fields.y.sorting, + fields.y.useAbbreviations, + yDimension, + yFilter, + sumsByY, + getY, + getYLabel, + getYAsDate, scalesData, timeRangeData, segmentsByAbbreviationOrLabel, @@ -318,21 +318,21 @@ const useBarsStackedState = ( [animationIri] ); - const yScale = useMemo(() => { - return getStackedYScale(scalesData, { + const xScale = useMemo(() => { + return getStackedXScale(scalesData, { normalize, - getX, getY, + getX, getTime: getAnimation, }); }, [scalesData, normalize, getX, getY, getAnimation]); - const paddingYScale = useMemo(() => { + const paddingXScale = useMemo(() => { // When the user can toggle between absolute and relative values, we use the // absolute values to calculate the yScale domain, so that the yScale doesn't // change when the user toggles between absolute and relative values. if (interactiveFiltersConfig?.calculation.active) { - const scale = getStackedYScale(paddingData, { + const scale = getStackedXScale(paddingData, { normalize: false, getX, getY, @@ -346,7 +346,7 @@ const useBarsStackedState = ( return scale; } - return getStackedYScale(paddingData, { + return getStackedXScale(paddingData, { normalize, getX, getY, @@ -388,20 +388,21 @@ const useBarsStackedState = ( /** Chart dimensions */ const { left, bottom } = useChartPadding({ - yScale: paddingYScale, + //TODO: This is wrong, need to fix + yScale: paddingXScale, width, height, interactiveFiltersConfig, animationPresent: !!fields.animation, formatNumber, - bandDomain: xTimeRangeDomainLabels.every((d) => d === undefined) - ? xScale.domain() - : xTimeRangeDomainLabels, + bandDomain: yTimeRangeDomainLabels.every((d) => d === undefined) + ? yScale.domain() + : yTimeRangeDomainLabels, normalize, }); const right = 40; const { offset: yAxisLabelMargin } = useAxisLabelHeightOffset({ - label: yMeasure.label, + label: xMeasure.label, width, marginLeft: left, marginRight: right, @@ -415,60 +416,61 @@ const useBarsStackedState = ( const bounds = useChartBounds(width, margins, height); const { chartWidth, chartHeight } = bounds; - xScale.range([0, chartWidth]); - xScaleInteraction.range([0, chartWidth]); - xScaleTimeRange.range([0, chartWidth]); - yScale.range([chartHeight, 0]); + yScale.range([0, chartWidth]); + yScaleInteraction.range([0, chartWidth]); + yScaleTimeRange.range([0, chartWidth]); + xScale.range([chartHeight, 0]); const isMobile = useIsMobile(); // Tooltips const getAnnotationInfo = useCallback( - (datum: Observation): TooltipInfo => { - const bw = xScale.bandwidth(); - const x = getX(datum); + (datum: Observation): TooltipInfoInverted => { + const bw = yScale.bandwidth(); + const y = getY(datum); - const tooltipValues = chartDataGroupedByX.get(x) as Observation[]; - const yValues = tooltipValues.map(getY); + const tooltipValues = chartDataGroupedByY.get(y) as Observation[]; + const xValues = tooltipValues.map(getX); const sortedTooltipValues = sortByIndex({ data: tooltipValues, order: segments, getCategory: getSegment, sortingOrder: "asc", }); - const yValueFormatter = getStackedTooltipValueFormatter({ + const xValueFormatter = getStackedTooltipValueFormatterInverted({ normalize, - yMeasureId: yMeasure.id, - yMeasureUnit: yMeasure.unit, + xMeasureId: xMeasure.id, + xMeasureUnit: xMeasure.unit, formatters, formatNumber, }); - const xAnchorRaw = (xScale(x) as number) + bw * 0.5; - const yAnchor = isMobile + const yAnchorRaw = (yScale(y) as number) + bw * 0.5; + const xAnchor = isMobile ? chartHeight - : yScale(sum(yValues.map((d) => d ?? 0)) * 0.5); + : xScale(sum(xValues.map((d) => d ?? 0)) * 0.5); const placement = isMobile ? MOBILE_TOOLTIP_PLACEMENT : getCenteredTooltipPlacement({ chartWidth, - xAnchor: xAnchorRaw, + //NOTE: this might be wrong + xAnchor, topAnchor: !fields.segment, }); return { - xAnchor: xAnchorRaw + (placement.x === "right" ? 0.5 : -0.5) * bw, - yAnchor, + yAnchor: yAnchorRaw + (placement.y === "top" ? 0.5 : -0.5) * bw, + xAnchor, placement, - xValue: getXAbbreviationOrLabel(datum), + yValue: getYAbbreviationOrLabel(datum), datum: { label: fields.segment && getSegmentAbbreviationOrLabel(datum), - value: yValueFormatter(getY(datum), getIdentityY(datum)), + value: xValueFormatter(getX(datum), getIdentityX(datum)), color: colors(getSegment(datum)) as string, }, values: sortedTooltipValues.map((td) => ({ label: getSegmentAbbreviationOrLabel(td), - value: yValueFormatter(getY(td), getIdentityY(td)), + value: xValueFormatter(getX(td), getIdentityX(td)), color: colors(getSegment(td)) as string, })), }; @@ -476,18 +478,18 @@ const useBarsStackedState = ( [ getX, xScale, - chartDataGroupedByX, + chartDataGroupedByY, segments, getSegment, - yMeasure.id, - yMeasure.unit, + xMeasure.id, + xMeasure.unit, formatters, formatNumber, - getXAbbreviationOrLabel, + getYAbbreviationOrLabel, fields.segment, getSegmentAbbreviationOrLabel, getY, - getIdentityY, + getIdentityX, colors, chartWidth, chartHeight, @@ -503,8 +505,8 @@ const useBarsStackedState = ( chartData, allData, xScale, - xScaleInteraction, - xScaleTimeRange, + yScaleInteraction, + yScaleTimeRange, yScale, segments, colors, diff --git a/app/charts/bar/bars-stacked.tsx b/app/charts/bar/bars-stacked.tsx index d7d5382c4..72a5bb3ae 100644 --- a/app/charts/bar/bars-stacked.tsx +++ b/app/charts/bar/bars-stacked.tsx @@ -10,11 +10,11 @@ export const BarsStacked = () => { const ref = useRef<SVGGElement>(null); const enableTransition = useTransitionStore((state) => state.enable); const transitionDuration = useTransitionStore((state) => state.duration); - const { bounds, getX, xScale, yScale, colors, series, getRenderingKey } = + const { bounds, getY, xScale, yScale, colors, series, getRenderingKey } = useChartState() as StackedBarsState; const { margins, height } = bounds; - const bandwidth = xScale.bandwidth(); - const y0 = yScale(0); + const bandwidth = yScale.bandwidth(); + const x0 = xScale(0); const renderData: RenderBarDatum[] = useMemo(() => { return series.flatMap((d) => { const color = colors(d.key); @@ -24,10 +24,10 @@ export const BarsStacked = () => { return { key: getRenderingKey(observation, d.key), - x: xScale(getX(observation)) as number, - y: yScale(segment[1]), - width: bandwidth, - height: Math.max(0, yScale(segment[0]) - yScale(segment[1])), + y: yScale(getY(observation)) as number, + x: xScale(segment[1]), + height: bandwidth, + width: Math.max(0, xScale(segment[0]) - xScale(segment[1])), color, }; }); @@ -36,7 +36,7 @@ export const BarsStacked = () => { }, [ bandwidth, colors, - getX, + getY, series, xScale, yScale, @@ -51,7 +51,7 @@ export const BarsStacked = () => { id: "bars-stacked", transform: `translate(${margins.left} ${margins.top})`, transition: { enable: enableTransition, duration: transitionDuration }, - render: (g, opts) => renderBars(g, renderData, { ...opts, y0 }), + render: (g, opts) => renderBars(g, renderData, { ...opts, x0 }), }); } }, [ @@ -60,7 +60,7 @@ export const BarsStacked = () => { margins.top, renderData, transitionDuration, - y0, + x0, ]); return <g ref={ref} />; diff --git a/app/charts/bar/bars-state-props.ts b/app/charts/bar/bars-state-props.ts index 5859052b9..c9fb5344a 100644 --- a/app/charts/bar/bars-state-props.ts +++ b/app/charts/bar/bars-state-props.ts @@ -3,20 +3,20 @@ import { useCallback, useMemo } from "react"; import { usePlottableData } from "@/charts/shared/chart-helpers"; import { - BandXVariables, + BandYVariables, BaseVariables, ChartStateData, InteractiveFiltersVariables, - NumericalYErrorVariables, - NumericalYVariables, + NumericalXErrorVariables, + NumericalXVariables, RenderingVariables, SortingVariables, - useBandXVariables, + useBandYVariables, + useBarChartData, useBaseVariables, - useChartData, useInteractiveFiltersVariables, - useNumericalYErrorVariables, - useNumericalYVariables, + useNumericalXErrorVariables, + useNumericalXVariables, } from "@/charts/shared/chart-state"; import { useRenderingKeyVariable } from "@/charts/shared/rendering-utils"; import { BarConfig, useChartConfigFilters } from "@/configurator"; @@ -26,9 +26,9 @@ import { ChartProps } from "../shared/ChartProps"; export type BarsStateVariables = BaseVariables & SortingVariables & - BandXVariables & - NumericalYVariables & - NumericalYErrorVariables & + NumericalXVariables & + BandYVariables & + NumericalXErrorVariables & RenderingVariables & InteractiveFiltersVariables; @@ -45,19 +45,19 @@ export const useBarsStateVariables = ( } = props; const { fields, interactiveFiltersConfig } = chartConfig; const { x, y, animation } = fields; - const xDimension = dimensionsById[x.componentId]; + const yDimension = dimensionsById[y.componentId]; const filters = useChartConfigFilters(chartConfig); const baseVariables = useBaseVariables(chartConfig); - const bandXVariables = useBandXVariables(x, { + const numericalXVariables = useNumericalXVariables("bar", x, { + measuresById, + }); + const bandYVariables = useBandYVariables(y, { dimensionsById, observations, }); - const numericalYVariables = useNumericalYVariables("bar", y, { - measuresById, - }); - const numericalYErrorVariables = useNumericalYErrorVariables(y, { - numericalYVariables, + const numericalXErrorVariables = useNumericalXErrorVariables(x, { + numericalXVariables, dimensions, measures, }); @@ -66,29 +66,29 @@ export const useBarsStateVariables = ( { dimensionsById } ); - const { getX, getXAsDate } = bandXVariables; - const { getY } = numericalYVariables; + const { getY, getYAsDate } = bandYVariables; + const { getX } = numericalXVariables; const sortData: BarsStateVariables["sortData"] = useCallback( (data) => { - const { sortingOrder, sortingType } = x.sorting ?? {}; - const xGetter = isTemporalEntityDimension(xDimension) ? getXAsDate : getX; + const { sortingOrder, sortingType } = y.sorting ?? {}; + const yGetter = isTemporalEntityDimension(yDimension) ? getYAsDate : getY; if (sortingOrder === "desc" && sortingType === "byDimensionLabel") { - return [...data].sort((a, b) => descending(xGetter(a), xGetter(b))); + return [...data].sort((a, b) => descending(yGetter(a), yGetter(b))); } else if (sortingOrder === "asc" && sortingType === "byDimensionLabel") { - return [...data].sort((a, b) => ascending(xGetter(a), xGetter(b))); + return [...data].sort((a, b) => ascending(yGetter(a), yGetter(b))); } else if (sortingOrder === "desc" && sortingType === "byMeasure") { return [...data].sort((a, b) => - descending(getY(a) ?? -1, getY(b) ?? -1) + descending(getX(a) ?? -1, getX(b) ?? -1) ); } else if (sortingOrder === "asc" && sortingType === "byMeasure") { return [...data].sort((a, b) => - ascending(getY(a) ?? -1, getY(b) ?? -1) + ascending(getX(a) ?? -1, getX(b) ?? -1) ); } else { - return [...data].sort((a, b) => ascending(xGetter(a), xGetter(b))); + return [...data].sort((a, b) => ascending(yGetter(a), yGetter(b))); } }, - [getX, getXAsDate, getY, x.sorting, xDimension] + [getX, getYAsDate, getY, y.sorting, yDimension] ); const getRenderingKey = useRenderingKeyVariable( @@ -101,9 +101,9 @@ export const useBarsStateVariables = ( return { ...baseVariables, sortData, - ...bandXVariables, - ...numericalYVariables, - ...numericalYErrorVariables, + ...bandYVariables, + ...numericalXVariables, + ...numericalXErrorVariables, ...interactiveFiltersVariables, getRenderingKey, }; @@ -114,18 +114,18 @@ export const useBarsStateData = ( variables: BarsStateVariables ): ChartStateData => { const { chartConfig, observations } = chartProps; - const { sortData, xDimension, getXAsDate, getY, getTimeRangeDate } = + const { sortData, yDimension, getYAsDate, getX, getTimeRangeDate } = variables; const plottableData = usePlottableData(observations, { - getY, + getX, }); const sortedPlottableData = useMemo(() => { return sortData(plottableData); }, [sortData, plottableData]); - const data = useChartData(sortedPlottableData, { + const data = useBarChartData(sortedPlottableData, { chartConfig, - timeRangeDimensionId: xDimension.id, - getXAsDate, + timeRangeDimensionId: yDimension.id, + getYAsDate, getTimeRangeDate, }); diff --git a/app/charts/bar/bars-state.tsx b/app/charts/bar/bars-state.tsx index efd3dfb2e..65d27612e 100644 --- a/app/charts/bar/bars-state.tsx +++ b/app/charts/bar/bars-state.tsx @@ -24,9 +24,9 @@ import { ChartContext, ChartStateData, CommonChartState, - InteractiveXTimeRangeState, + InteractiveYTimeRangeState, } from "@/charts/shared/chart-state"; -import { TooltipInfo } from "@/charts/shared/interaction/tooltip"; +import { TooltipInfoInverted } from "@/charts/shared/interaction/tooltip"; import { getCenteredTooltipPlacement, MOBILE_TOOLTIP_PLACEMENT, @@ -51,12 +51,13 @@ import { ChartProps } from "../shared/ChartProps"; export type BarsState = CommonChartState & BarsStateVariables & - InteractiveXTimeRangeState & { + InteractiveYTimeRangeState & { chartType: "bar"; - xScale: ScaleBand<string>; - xScaleInteraction: ScaleBand<string>; - yScale: ScaleLinear<number, number>; - getAnnotationInfo: (d: Observation) => TooltipInfo; + xScale: ScaleLinear<number, number>; + yScaleInteraction: ScaleBand<string>; + yScale: ScaleBand<string>; + minY: string; + getAnnotationInfo: (d: Observation) => TooltipInfoInverted; }; const useBarsState = ( @@ -66,19 +67,19 @@ const useBarsState = ( ): BarsState => { const { chartConfig } = chartProps; const { - xDimension, + yDimension, getX, - getXAsDate, - getXAbbreviationOrLabel, - getXLabel, - xTimeUnit, - yMeasure, + getYAsDate, + getYAbbreviationOrLabel, + getYLabel, + yTimeUnit, + xMeasure, getY, - getMinY, - showYStandardError, - yErrorMeasure, - getYError, - getYErrorRange, + getMinX, + showXStandardError, + xErrorMeasure, + getXError, + getXErrorRange, } = variables; const { chartData, scalesData, timeRangeData, paddingData, allData } = data; const { fields, interactiveFiltersConfig } = chartConfig; @@ -88,12 +89,12 @@ const useBarsState = ( const formatters = useChartFormatters(chartProps); const timeFormatUnit = useTimeFormatUnit(); - const sumsByX = useMemo(() => { + const sumsByY = useMemo(() => { return Object.fromEntries( rollup( chartData, - (v) => sum(v, (x) => getY(x)), - (x) => getX(x) + (v) => sum(v, (x) => getX(x)), + (x) => getY(x) ) ); }, [chartData, getX, getY]); @@ -101,61 +102,62 @@ const useBarsState = ( const { xScale, yScale, + minY, paddingYScale, - xScaleTimeRange, - xScaleInteraction, - xTimeRangeDomainLabels, + yScaleTimeRange, + yScaleInteraction, + yTimeRangeDomainLabels, } = useMemo(() => { - const sorters = makeDimensionValueSorters(xDimension, { - sorting: fields.x.sorting, - measureBySegment: sumsByX, - useAbbreviations: fields.x.useAbbreviations, - dimensionFilter: xDimension?.id - ? chartConfig.cubes.find((d) => d.iri === xDimension.cubeIri)?.filters[ - xDimension.id + const sorters = makeDimensionValueSorters(yDimension, { + sorting: fields.y.sorting, + measureBySegment: sumsByY, + useAbbreviations: fields.y.useAbbreviations, + dimensionFilter: yDimension?.id + ? chartConfig.cubes.find((d) => d.iri === yDimension.cubeIri)?.filters[ + yDimension.id ] : undefined, }); - const sortingOrders = getSortingOrders(sorters, fields.x.sorting); + const sortingOrders = getSortingOrders(sorters, fields.y.sorting); const bandDomain = orderBy( - [...new Set(scalesData.map(getX))], + [...new Set(scalesData.map(getY))], sorters, sortingOrders ); - const xTimeRangeValues = [...new Set(timeRangeData.map(getX))]; - const xTimeRangeDomainLabels = xTimeRangeValues.map(getXLabel); - const xScale = scaleBand() + const yTimeRangeValues = [...new Set(timeRangeData.map(getY))]; + const yTimeRangeDomainLabels = yTimeRangeValues.map(getYLabel); + const yScale = scaleBand() .domain(bandDomain) .paddingInner(PADDING_INNER) .paddingOuter(PADDING_OUTER); - const xScaleInteraction = scaleBand() + const yScaleInteraction = scaleBand() .domain(bandDomain) .paddingInner(0) .paddingOuter(0); - const xScaleTimeRangeDomain = extent(timeRangeData, (d) => - getXAsDate(d) + const yScaleTimeRangeDomain = extent(timeRangeData, (d) => + getYAsDate(d) ) as [Date, Date]; - const xScaleTimeRange = scaleTime().domain(xScaleTimeRangeDomain); + const yScaleTimeRange = scaleTime().domain(yScaleTimeRangeDomain); - const minValue = getMinY(scalesData, (d) => - getYErrorRange ? getYErrorRange(d)[0] : getY(d) + const minValue = getMinX(scalesData, (d) => + getXErrorRange ? getXErrorRange(d)[0] : getX(d) ); const maxValue = Math.max( max(scalesData, (d) => - getYErrorRange ? getYErrorRange(d)[1] : getY(d) + getXErrorRange ? getXErrorRange(d)[1] : getX(d) ) ?? 0, 0 ); - const yScale = scaleLinear().domain([minValue, maxValue]).nice(); + const xScale = scaleLinear().domain([minValue, maxValue]).nice(); - const paddingMinValue = getMinY(paddingData, (d) => - getYErrorRange ? getYErrorRange(d)[0] : getY(d) + const paddingMinValue = getMinX(paddingData, (d) => + getXErrorRange ? getXErrorRange(d)[0] : getX(d) ); const paddingMaxValue = Math.max( max(paddingData, (d) => - getYErrorRange ? getYErrorRange(d)[1] : getY(d) + getXErrorRange ? getXErrorRange(d)[1] : getX(d) ) ?? 0, 0 ); @@ -166,26 +168,27 @@ const useBarsState = ( return { xScale, yScale, + minY: bandDomain[0], paddingYScale, - xScaleTimeRange, - xScaleInteraction, - xTimeRangeDomainLabels, + yScaleTimeRange, + yScaleInteraction, + yTimeRangeDomainLabels, }; }, [ getX, - getXLabel, - getXAsDate, + getYLabel, + getYAsDate, getY, - getYErrorRange, + getXErrorRange, scalesData, paddingData, timeRangeData, - fields.x.sorting, - fields.x.useAbbreviations, - xDimension, + fields.y.sorting, + fields.y.useAbbreviations, + yDimension, chartConfig.cubes, - sumsByX, - getMinY, + sumsByY, + getMinX, ]); const { left, bottom } = useChartPadding({ @@ -195,19 +198,19 @@ const useBarsState = ( interactiveFiltersConfig, animationPresent: !!fields.animation, formatNumber, - bandDomain: xTimeRangeDomainLabels.every((d) => d === undefined) - ? xScale.domain() - : xTimeRangeDomainLabels, + bandDomain: yTimeRangeDomainLabels.every((d) => d === undefined) + ? yScale.domain() + : yTimeRangeDomainLabels, }); const right = 40; - const { offset: yAxisLabelMargin } = useAxisLabelHeightOffset({ - label: yMeasure.label, + const { offset: xAxisLabelMargin } = useAxisLabelHeightOffset({ + label: xMeasure.label, width, marginLeft: left, marginRight: right, }); const margins = { - top: 50 + yAxisLabelMargin, + top: 50 + xAxisLabelMargin, right, bottom, left, @@ -217,53 +220,53 @@ const useBarsState = ( const { chartWidth, chartHeight } = bounds; xScale.range([0, chartWidth]); - xScaleInteraction.range([0, chartWidth]); - xScaleTimeRange.range([0, chartWidth]); + yScaleInteraction.range([0, chartHeight]); + yScaleTimeRange.range([0, chartHeight]); yScale.range([chartHeight, 0]); const isMobile = useIsMobile(); // Tooltip - const getAnnotationInfo = (d: Observation): TooltipInfo => { - const xAnchor = (xScale(getX(d)) as number) + xScale.bandwidth() * 0.5; - const yAnchor = isMobile + const getAnnotationInfo = (d: Observation): TooltipInfoInverted => { + const yAnchor = (yScale(getY(d)) as number) + yScale.bandwidth() * 0.5; + const xAnchor = isMobile ? chartHeight - : yScale(Math.max(getY(d) ?? NaN, 0)); + : xScale(Math.max(getX(d) ?? NaN, 0)); const placement = isMobile ? MOBILE_TOOLTIP_PLACEMENT : getCenteredTooltipPlacement({ chartWidth, - xAnchor, + xAnchor: yAnchor, topAnchor: !fields.segment, }); - const xLabel = getXAbbreviationOrLabel(d); + const yLabel = getYAbbreviationOrLabel(d); - const yValueFormatter = (value: number | null) => + const xValueFormatter = (value: number | null) => formatNumberWithUnit( value, - formatters[yMeasure.id] ?? formatNumber, - yMeasure.unit + formatters[xMeasure.id] ?? formatNumber, + xMeasure.unit ); const getError = (d: Observation) => { - if (!showYStandardError || !getYError || getYError(d) === null) { + if (!showXStandardError || !getXError || getXError(d) === null) { return; } - return `${getYError(d)}${yErrorMeasure?.unit ?? ""}`; + return `${getXError(d)}${xErrorMeasure?.unit ?? ""}`; }; - const y = getY(d); + const x = getX(d); return { xAnchor, yAnchor, placement, - xValue: xTimeUnit ? timeFormatUnit(xLabel, xTimeUnit) : xLabel, + yValue: yTimeUnit ? timeFormatUnit(yLabel, yTimeUnit) : yLabel, datum: { label: undefined, - value: y !== null && isNaN(y) ? "-" : `${yValueFormatter(getY(d))}`, + value: x !== null && isNaN(x) ? "-" : `${xValueFormatter(getX(d))}`, error: getError(d), color: "", }, @@ -277,8 +280,9 @@ const useBarsState = ( chartData, allData, xScale, - xScaleTimeRange, - xScaleInteraction, + minY, + yScaleTimeRange, + yScaleInteraction, yScale, getAnnotationInfo, ...variables, diff --git a/app/charts/bar/bars.tsx b/app/charts/bar/bars.tsx index 810b30026..e7099e1d2 100644 --- a/app/charts/bar/bars.tsx +++ b/app/charts/bar/bars.tsx @@ -5,54 +5,54 @@ import { BarsState } from "@/charts/bar/bars-state"; import { RenderBarDatum, renderBars } from "@/charts/bar/rendering-utils"; import { useChartState } from "@/charts/shared/chart-state"; import { - RenderWhiskerDatum, + RenderWhiskerBarDatum, filterWithoutErrors, + renderBarWhiskers, renderContainer, - renderWhiskers, } from "@/charts/shared/rendering-utils"; import { useTransitionStore } from "@/stores/transition"; import { useTheme } from "@/themes"; export const ErrorWhiskers = () => { const { - getX, - getYError, - getYErrorRange, + getY, + getXError, + getXErrorRange, chartData, yScale, xScale, - showYStandardError, + showXStandardError, bounds, } = useChartState() as BarsState; const { margins, width, height } = bounds; const ref = useRef<SVGGElement>(null); const enableTransition = useTransitionStore((state) => state.enable); const transitionDuration = useTransitionStore((state) => state.duration); - const renderData: RenderWhiskerDatum[] = useMemo(() => { - if (!getYErrorRange || !showYStandardError) { + const renderData: RenderWhiskerBarDatum[] = useMemo(() => { + if (!getXErrorRange || !showXStandardError) { return []; } - const bandwidth = xScale.bandwidth(); - return chartData.filter(filterWithoutErrors(getYError)).map((d, i) => { - const x0 = xScale(getX(d)) as number; + const bandwidth = yScale.bandwidth(); + return chartData.filter(filterWithoutErrors(getXError)).map((d, i) => { + const y0 = yScale(getY(d)) as number; const barWidth = Math.min(bandwidth, 15); - const [y1, y2] = getYErrorRange(d); + const [x1, x2] = getXErrorRange(d); return { key: `${i}`, - x: x0 + bandwidth / 2 - barWidth / 2, - y1: yScale(y1), - y2: yScale(y2), + y: y0 + bandwidth / 2 - barWidth / 2, + x1: xScale(x1), + x2: xScale(x2), width: barWidth, - } as RenderWhiskerDatum; + } as RenderWhiskerBarDatum; }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [ chartData, - getX, - getYError, - getYErrorRange, - showYStandardError, + getY, + getXError, + getXErrorRange, + showXStandardError, xScale, yScale, width, @@ -65,7 +65,7 @@ export const ErrorWhiskers = () => { id: "bars-error-whiskers", transform: `translate(${margins.left} ${margins.top})`, transition: { enable: enableTransition, duration: transitionDuration }, - render: (g, opts) => renderWhiskers(g, renderData, opts), + render: (g, opts) => renderBarWhiskers(g, renderData, opts), }); } }, [ @@ -87,8 +87,8 @@ export const Bars = () => { const ref = useRef<SVGGElement>(null); const enableTransition = useTransitionStore((state) => state.enable); const transitionDuration = useTransitionStore((state) => state.duration); - const bandwidth = xScale.bandwidth(); - const y0 = yScale(0); + const bandwidth = yScale.bandwidth(); + const x0 = xScale(0); const renderData: RenderBarDatum[] = useMemo(() => { const getColor = (d: number) => { return d <= 0 ? theme.palette.secondary.main : schemeCategory10[0]; @@ -96,20 +96,20 @@ export const Bars = () => { return chartData.map((d) => { const key = getRenderingKey(d); - const xScaled = xScale(getX(d)) as number; - const yRaw = getY(d); - const y = yRaw === null || isNaN(yRaw) ? 0 : yRaw; - const yScaled = yScale(y); - const yRender = yScale(Math.max(y, 0)); - const height = Math.max(0, Math.abs(yScaled - y0)); - const color = getColor(y); + const yScaled = yScale(getY(d)) as number; + const xRaw = getX(d); + const x = xRaw === null || isNaN(xRaw) ? 0 : xRaw; + const xScaled = xScale(x); + const xRender = xScale(Math.min(x, 0)); + const width = Math.max(0, Math.abs(xScaled - x0)); + const color = getColor(x); return { key, - x: xScaled, - y: yRender, - width: bandwidth, - height, + x: xRender, + y: yScaled, + width, + height: bandwidth, color, }; }); @@ -120,7 +120,7 @@ export const Bars = () => { getY, xScale, yScale, - y0, + x0, theme.palette.secondary.main, getRenderingKey, ]); @@ -131,7 +131,7 @@ export const Bars = () => { id: "bars", transform: `translate(${margins.left} ${margins.top})`, transition: { enable: enableTransition, duration: transitionDuration }, - render: (g, opts) => renderBars(g, renderData, { ...opts, y0 }), + render: (g, opts) => renderBars(g, renderData, { ...opts, x0 }), }); } }, [ @@ -140,7 +140,7 @@ export const Bars = () => { margins.top, renderData, transitionDuration, - y0, + x0, ]); return <g ref={ref} />; diff --git a/app/charts/bar/chart-bar.tsx b/app/charts/bar/chart-bar.tsx index 215b657c2..05ccdd948 100644 --- a/app/charts/bar/chart-bar.tsx +++ b/app/charts/bar/chart-bar.tsx @@ -11,11 +11,11 @@ import { StackedBarsChart } from "@/charts/bar/bars-stacked-state"; import { BarChart } from "@/charts/bar/bars-state"; import { InteractionBars } from "@/charts/bar/overlay-bars"; import { ChartDataWrapper } from "@/charts/chart-data-wrapper"; -import { AxisHeightLinear } from "@/charts/shared/axis-height-linear"; import { AxisWidthBand, AxisWidthBandDomain, -} from "@/charts/shared/axis-width-band"; +} from "@/charts/shared/axis-width-band-vertical"; +import { AxisWidthLinear } from "@/charts/shared/axis-width-linear"; import { BrushTime, shouldShowBrush } from "@/charts/shared/brush"; import { ChartContainer, @@ -53,7 +53,7 @@ const ChartBars = memo((props: ChartProps<BarConfig>) => { <StackedBarsChart {...props}> <ChartContainer> <ChartSvg> - <AxisHeightLinear /> + <AxisWidthLinear /> <AxisWidthBand /> <BarsStacked /> <AxisWidthBandDomain /> @@ -83,7 +83,7 @@ const ChartBars = memo((props: ChartProps<BarConfig>) => { <GroupedBarChart {...props}> <ChartContainer> <ChartSvg> - <AxisHeightLinear /> + <AxisWidthLinear /> <AxisWidthBand /> <BarsGrouped /> <ErrorWhiskersGrouped /> @@ -114,7 +114,7 @@ const ChartBars = memo((props: ChartProps<BarConfig>) => { <BarChart {...props}> <ChartContainer> <ChartSvg> - <AxisHeightLinear /> + <AxisWidthLinear /> <AxisWidthBand /> <Bars /> <ErrorWhiskers /> diff --git a/app/charts/bar/overlay-bars.tsx b/app/charts/bar/overlay-bars.tsx index c919f2a41..0850d2d30 100644 --- a/app/charts/bar/overlay-bars.tsx +++ b/app/charts/bar/overlay-bars.tsx @@ -1,5 +1,4 @@ -import { ColumnsState } from "@/charts/column/columns-state"; -import { ComboLineColumnState } from "@/charts/combo/combo-line-column-state"; +import { BarsState } from "@/charts/bar/bars-state"; import { useChartState } from "@/charts/shared/chart-state"; import { useInteraction } from "@/charts/shared/use-interaction"; import { Observation } from "@/domain/data"; @@ -7,10 +6,9 @@ import { Observation } from "@/domain/data"; export const InteractionBars = () => { const [, dispatch] = useInteraction(); - const { chartData, bounds, getX, xScaleInteraction } = useChartState() as - | ColumnsState - | ComboLineColumnState; - const { margins, chartHeight } = bounds; + const { chartData, bounds, getY, yScaleInteraction } = + useChartState() as BarsState; + const { margins, chartWidth } = bounds; const showTooltip = (d: Observation) => { dispatch({ @@ -28,10 +26,10 @@ export const InteractionBars = () => { {chartData.map((d, i) => ( <rect key={i} - x={xScaleInteraction(getX(d)) as number} - y={0} - width={xScaleInteraction.bandwidth()} - height={Math.max(0, chartHeight)} + x={0} + y={yScaleInteraction(getY(d)) as number} + height={yScaleInteraction.bandwidth()} + width={Math.max(0, chartWidth)} fill="hotpink" fillOpacity={0} stroke="none" diff --git a/app/charts/bar/rendering-utils.ts b/app/charts/bar/rendering-utils.ts index 67123ec61..1e7cbb104 100644 --- a/app/charts/bar/rendering-utils.ts +++ b/app/charts/bar/rendering-utils.ts @@ -15,7 +15,7 @@ export type RenderBarDatum = { }; type RenderBarOptions = RenderOptions & { - y0: number; + x0: number; }; export const renderBars = ( @@ -23,7 +23,7 @@ export const renderBars = ( data: RenderBarDatum[], options: RenderBarOptions ) => { - const { transition, y0 } = options; + const { transition, x0 } = options; g.selectAll<SVGRectElement, RenderBarDatum>("rect") .data(data, (d) => d.key) @@ -32,15 +32,15 @@ export const renderBars = ( enter .append("rect") .attr("data-index", (_, i) => i) - .attr("x", (d) => d.x) - .attr("y", y0) + .attr("y", (d) => d.y) + .attr("x", x0) .attr("width", (d) => d.width) .attr("height", 0) .attr("fill", (d) => d.color) .call((enter) => maybeTransition(enter, { transition, - s: (g) => g.attr("y", (d) => d.y).attr("height", (d) => d.height), + s: (g) => g.attr("x", (d) => d.x).attr("width", (d) => d.width), }) ), (update) => @@ -57,7 +57,7 @@ export const renderBars = ( (exit) => maybeTransition(exit, { transition, - s: (g) => g.attr("y", y0).attr("height", 0).remove(), + s: (g) => g.attr("x", x0).attr("height", 0).remove(), }) ); }; diff --git a/app/charts/chart-config-ui-options.ts b/app/charts/chart-config-ui-options.ts index b4d544c1b..bc96a5cfe 100644 --- a/app/charts/chart-config-ui-options.ts +++ b/app/charts/chart-config-ui-options.ts @@ -773,7 +773,7 @@ const chartConfigOptionsUISpec: ChartSpecs = { chartType: "bar", encodings: [ { - field: "y", + field: "x", optional: false, idAttributes: ["componentId"], componentTypes: ["NumericalMeasure"], @@ -801,7 +801,7 @@ const chartConfigOptionsUISpec: ChartSpecs = { }, }, { - field: "x", + field: "y", optional: false, idAttributes: ["componentId"], componentTypes: [ diff --git a/app/charts/index.ts b/app/charts/index.ts index 9e667d844..1430ed1ae 100644 --- a/app/charts/index.ts +++ b/app/charts/index.ts @@ -444,11 +444,11 @@ export const getInitialConfig = ( timeRangeComponentId: barXComponentId, }), fields: { - x: { + y: { componentId: barXComponentId, sorting: DEFAULT_SORTING, }, - y: { componentId: numericalMeasures[0].id }, + x: { componentId: numericalMeasures[0].id }, }, }; case "line": @@ -1019,7 +1019,8 @@ const chartConfigsAdjusters: ChartConfigsAdjusters = { }, fields: { x: { - componentId: ({ oldValue, newChartConfig, dimensions }) => { + componentId: ({ oldValue, newChartConfig, dimensions, measures }) => { + measures[0]; // When switching from a scatterplot, x is a measure. if (dimensions.find((d) => d.id === oldValue)) { return produce(newChartConfig, (draft) => { @@ -1031,7 +1032,8 @@ const chartConfigsAdjusters: ChartConfigsAdjusters = { }, }, y: { - componentId: ({ oldValue, newChartConfig }) => { + componentId: ({ oldValue, newChartConfig, dimensions }) => { + dimensions[0]; return produce(newChartConfig, (draft) => { draft.fields.y.componentId = oldValue; }); diff --git a/app/charts/shared/axis-width-band-vertical.tsx b/app/charts/shared/axis-width-band-vertical.tsx new file mode 100644 index 000000000..b8a3d32b0 --- /dev/null +++ b/app/charts/shared/axis-width-band-vertical.tsx @@ -0,0 +1,134 @@ +import { axisLeft } from "d3-axis"; +import { useEffect, useRef } from "react"; + +import { BarsState } from "@/charts/bar/bars-state"; +import { useChartState } from "@/charts/shared/chart-state"; +import { + maybeTransition, + renderContainer, +} from "@/charts/shared/rendering-utils"; +import { useChartTheme } from "@/charts/shared/use-chart-theme"; +import { useTimeFormatUnit } from "@/formatters"; +import { useTransitionStore } from "@/stores/transition"; + +export const AxisWidthBand = () => { + const ref = useRef<SVGGElement>(null); + const state = useChartState() as BarsState; + const { xScale, getYLabel, yTimeUnit, yScale, bounds } = state; + const enableTransition = useTransitionStore((state) => state.enable); + const transitionDuration = useTransitionStore((state) => state.duration); + const formatDate = useTimeFormatUnit(); + const { chartHeight, margins } = bounds; + const { labelColor, gridColor, labelFontSize, fontFamily, domainColor } = + useChartTheme(); + + useEffect(() => { + if (ref.current) { + const rotation = true; + const hasNegativeValues = xScale.domain()[0] < 0; + const fontSize = + yScale.bandwidth() > labelFontSize ? labelFontSize : yScale.bandwidth(); + const axis = axisLeft(yScale) + .tickSizeOuter(0) + .tickSizeInner(hasNegativeValues ? -chartHeight : 6) + .tickPadding(rotation ? -10 : 0); + + if (yTimeUnit) { + axis.tickFormat((d) => formatDate(d, yTimeUnit)); + } else { + axis.tickFormat((d) => getYLabel(d)); + } + + const g = renderContainer(ref.current, { + id: "axis-width-band-vertical", + transform: `translate(${margins.left} ${margins.top})`, + transition: { enable: enableTransition, duration: transitionDuration }, + render: (g) => g.attr("data-testid", "axis-width-band").call(axis), + renderUpdate: (g, opts) => + maybeTransition(g, { + transition: opts.transition, + s: (g) => g.call(axis), + }), + }); + + g.select(".domain").remove(); + g.selectAll(".tick line").attr( + "stroke", + hasNegativeValues ? gridColor : domainColor + ); + g.selectAll(".tick text") + .attr("transform", rotation ? "rotate(90)" : "rotate(0)") + .attr("x", rotation ? fontSize : 0) + .attr("font-size", fontSize) + .attr("font-family", fontFamily) + .attr("fill", labelColor) + .attr("text-anchor", rotation ? "start" : "unset"); + } + }, [ + chartHeight, + domainColor, + enableTransition, + fontFamily, + formatDate, + getYLabel, + gridColor, + labelColor, + labelFontSize, + margins.left, + margins.top, + transitionDuration, + xScale, + yTimeUnit, + yScale, + ]); + + return <g ref={ref} />; +}; + +export const AxisWidthBandDomain = () => { + const ref = useRef<SVGGElement>(null); + const enableTransition = useTransitionStore((state) => state.enable); + const transitionDuration = useTransitionStore((state) => state.duration); + const { xScale, yScale, bounds, minY } = useChartState() as BarsState; + const { chartHeight, margins } = bounds; + const { domainColor } = useChartTheme(); + + useEffect(() => { + if (ref.current) { + const axis = axisLeft(yScale).tickSizeOuter(0); + const g = renderContainer(ref.current, { + id: "axis-width-band-vertical-domain", + transform: `translate(${margins.left} ${margins.top})`, + transition: { enable: enableTransition, duration: transitionDuration }, + render: (g) => g.call(axis), + renderUpdate: (g, opts) => + maybeTransition(g, { + transition: opts.transition, + s: (g) => g.call(axis), + }), + }); + + g.selectAll(".tick line").remove(); + g.selectAll(".tick text").remove(); + g.select(".domain") + .attr( + "transform", + `translate(0, -${chartHeight - (yScale(minY) || 0)})` + ) + .attr("stroke", domainColor); + } + }, [ + minY, + bounds.chartHeight, + chartHeight, + domainColor, + enableTransition, + margins.left, + margins.top, + transitionDuration, + xScale, + yScale, + ]); + + return <g ref={ref} />; +}; diff --git a/app/charts/shared/axis-width-linear.tsx b/app/charts/shared/axis-width-linear.tsx index d092320fa..ee53f38fb 100644 --- a/app/charts/shared/axis-width-linear.tsx +++ b/app/charts/shared/axis-width-linear.tsx @@ -1,6 +1,7 @@ import { axisBottom } from "d3-axis"; import { useEffect, useRef } from "react"; +import { BarsState } from "@/charts/bar/bars-state"; import { ScatterplotState } from "@/charts/scatterplot/scatterplot-state"; import { useAxisLabelHeightOffset } from "@/charts/shared/chart-dimensions"; import { useChartState } from "@/charts/shared/chart-state"; @@ -16,8 +17,9 @@ import { getTextWidth } from "@/utils/get-text-width"; export const AxisWidthLinear = () => { const formatNumber = useFormatNumber(); - const { xScale, bounds, xAxisLabel, xMeasure } = - useChartState() as ScatterplotState; + const { xScale, bounds, xAxisLabel, xMeasure } = useChartState() as + | ScatterplotState + | BarsState; const { chartWidth, chartHeight, margins } = bounds; const { labelColor, diff --git a/app/charts/shared/chart-helpers.tsx b/app/charts/shared/chart-helpers.tsx index 1a9ca605d..08390a218 100644 --- a/app/charts/shared/chart-helpers.tsx +++ b/app/charts/shared/chart-helpers.tsx @@ -6,6 +6,7 @@ import { useCallback, useMemo } from "react"; import { useMaybeAbbreviations } from "@/charts/shared/abbreviations"; import { imputeTemporalLinearSeries, + imputeTemporalLinearSeriesInverted, interpolateZerosValue, } from "@/charts/shared/imputation"; import { useObservationLabels } from "@/charts/shared/observation-labels"; @@ -486,6 +487,88 @@ export const getWideData = ({ } }; +export const getWideDataInverted = ({ + dataGroupedByY, + yKey, + getX, + allSegments, + getSegment, + imputationType = "none", +}: { + dataGroupedByY: InternMap<string, Array<Observation>>; + yKey: string; + getX: (d: Observation) => number | null; + allSegments?: Array<string>; + getSegment: (d: Observation) => string; + imputationType?: ImputationType; +}) => { + switch (imputationType) { + case "linear": + if (allSegments) { + const dataGroupedByYEntries = [...dataGroupedByY.entries()]; + const dataGroupedByYWithImputedValues: Array<{ + [key: string]: number; + }> = Array.from({ length: dataGroupedByY.size }, () => ({})); + + for (const segment of allSegments) { + const imputedSeriesValues = imputeTemporalLinearSeriesInverted({ + dataSortedByY: dataGroupedByYEntries.map(([date, values]) => { + const observation = values.find((d) => getSegment(d) === segment); + + return { + date: new Date(date), + value: observation ? getX(observation) : null, + }; + }), + }); + + for (let i = 0; i < imputedSeriesValues.length; i++) { + dataGroupedByYWithImputedValues[i][segment] = + imputedSeriesValues[i].value; + } + } + + return getBaseWideDataInverted({ + dataGroupedByY, + yKey, + getX, + getSegment, + getOptionalObservationProps: (i) => { + return allSegments.map((d) => { + return { + [d]: dataGroupedByYWithImputedValues[i][d], + }; + }); + }, + }); + } + case "zeros": + if (allSegments) { + return getBaseWideDataInverted({ + dataGroupedByY, + yKey, + getX, + getSegment, + getOptionalObservationProps: () => { + return allSegments.map((d) => { + return { + [d]: interpolateZerosValue(), + }; + }); + }, + }); + } + case "none": + default: + return getBaseWideDataInverted({ + dataGroupedByY, + yKey, + getX, + getSegment, + }); + } +}; + const getBaseWideData = ({ dataGroupedByX, xKey, @@ -533,6 +616,53 @@ const getBaseWideData = ({ return wideData; }; +const getBaseWideDataInverted = ({ + dataGroupedByY, + yKey, + getX, + getSegment, + getOptionalObservationProps = () => [], +}: { + dataGroupedByY: InternMap<string, Array<Observation>>; + yKey: string; + getX: (d: Observation) => number | null; + getSegment: (d: Observation) => string; + getOptionalObservationProps?: ( + datumIndex: number + ) => Array<{ [key: string]: number }>; +}): Array<Observation> => { + const wideData = []; + const dataGroupedByYEntries = [...dataGroupedByY.entries()]; + + for (let i = 0; i < dataGroupedByY.size; i++) { + const [k, v] = dataGroupedByYEntries[i]; + + const observation: Observation = Object.assign( + { + [yKey]: k, + [`${yKey}/__iri__`]: v[0][`${yKey}/__iri__`], + total: sum(v, getX), + }, + ...getOptionalObservationProps(i), + ...v + // Sorting the values in case of multiple values for the same segment + // (desired behavior for getting the domain when time slider is active). + .sort((a, b) => { + return (getX(a) ?? 0) - (getX(b) ?? 0); + }) + .map((d) => { + return { + [getSegment(d)]: getX(d), + }; + }) + ); + + wideData.push(observation); + } + + return wideData; +}; + const getIdentityId = (id: string) => `${id}/__identity__`; export const useGetIdentityY = (id: string) => { return useCallback( @@ -542,6 +672,14 @@ export const useGetIdentityY = (id: string) => { [id] ); }; +export const useGetIdentityX = (id: string) => { + return useCallback( + (d: Observation) => { + return (d[getIdentityId(id)] as number | null) ?? null; + }, + [id] + ); +}; export const normalizeData = ( sortedData: Observation[], @@ -567,6 +705,30 @@ export const normalizeData = ( }); }; +export const normalizeDataInverted = ( + sortedData: Observation[], + { + xKey, + getX, + getTotalGroupValue, + }: { + xKey: string; + getX: (d: Observation) => number | null; + getTotalGroupValue: (d: Observation) => number; + } +): Observation[] => { + return sortedData.map((d) => { + const totalGroupValue = getTotalGroupValue(d); + const x = getX(d); + + return { + ...d, + [xKey]: 100 * (x ? x / totalGroupValue : x ?? 0), + [getIdentityId(xKey)]: x, + }; + }); +}; + const SlugRe = /\W+/g; export const getSlugifiedId = (id: string) => id.replace(SlugRe, "_"); diff --git a/app/charts/shared/chart-state.ts b/app/charts/shared/chart-state.ts index 871a9f369..2d01838ef 100644 --- a/app/charts/shared/chart-state.ts +++ b/app/charts/shared/chart-state.ts @@ -109,7 +109,7 @@ export const useChartState = () => { export type ChartWithInteractiveXTimeRangeState = | AreasState | ColumnsState - | BarsState + // | BarsState | LinesState; export type NumericalValueGetter = (d: Observation) => number | null; @@ -147,6 +147,15 @@ export const useBaseVariables = (chartConfig: ChartConfig): BaseVariables => { }; }; +export type BandYVariables = { + yDimension: Dimension; + getY: StringValueGetter; + getYLabel: (d: string) => string; + getYAbbreviationOrLabel: (d: Observation) => string; + yTimeUnit: TimeUnit | undefined; + getYAsDate: TemporalValueGetter; +}; + export type BandXVariables = { xDimension: Dimension; getX: StringValueGetter; @@ -156,6 +165,51 @@ export type BandXVariables = { getXAsDate: TemporalValueGetter; }; +export const useBandYVariables = ( + y: GenericField, + { + dimensionsById, + observations, + }: { + dimensionsById: DimensionsById; + observations: Observation[]; + } +): BandYVariables => { + const yDimension = dimensionsById[y.componentId]; + if (!yDimension) { + throw Error(`No dimension <${y.componentId}> in cube! (useBandXVariables)`); + } + + const yTimeUnit = isTemporalDimension(yDimension) + ? yDimension.timeUnit + : undefined; + + const { + getAbbreviationOrLabelByValue: getYAbbreviationOrLabel, + getValue: getY, + getLabel: getYLabel, + } = useDimensionWithAbbreviations(yDimension, { + observations, + field: y, + }); + + const getYAsDate = useTemporalVariable(y.componentId); + const getYTemporalEntity = useTemporalEntityVariable( + dimensionsById[y.componentId].values + )(y.componentId); + + return { + yDimension, + getY, + getYLabel, + getYAbbreviationOrLabel, + yTimeUnit, + getYAsDate: isTemporalDimension(yDimension) + ? getYAsDate + : getYTemporalEntity, + }; +}; + export const useBandXVariables = ( x: GenericField, { @@ -252,7 +306,7 @@ export type NumericalXVariables = { }; export const useNumericalXVariables = ( - chartType: "scatterplot", + chartType: "scatterplot" | "bar", x: GenericField, { measuresById }: { measuresById: MeasuresById } ): NumericalXVariables => { @@ -273,6 +327,7 @@ export const useNumericalXVariables = ( (data: Observation[], _getX: NumericalValueGetter) => { switch (chartType) { case "scatterplot": + case "bar": return shouldUseDynamicMinScaleValue(xMeasure.scaleType) ? min(data, _getX) ?? 0 : Math.min(0, min(data, _getX) ?? 0); @@ -356,6 +411,13 @@ export type NumericalYErrorVariables = { getYErrorRange: null | ((d: Observation) => [number, number]); }; +export type NumericalXErrorVariables = { + showXStandardError: boolean; + xErrorMeasure: Component | undefined; + getXError: ((d: Observation) => ObservationValue) | null; + getXErrorRange: null | ((d: Observation) => [number, number]); +}; + export const useNumericalYErrorVariables = ( y: GenericField, { @@ -384,6 +446,34 @@ export const useNumericalYErrorVariables = ( }; }; +export const useNumericalXErrorVariables = ( + x: GenericField, + { + numericalXVariables, + dimensions, + measures, + }: { + numericalXVariables: NumericalXVariables; + dimensions: Dimension[]; + measures: Measure[]; + } +): NumericalXErrorVariables => { + const showXStandardError = get(x, ["showStandardError"], true); + const xErrorMeasure = useErrorMeasure(x.componentId, { + dimensions, + measures, + }); + const getXErrorRange = useErrorRange(xErrorMeasure, numericalXVariables.getX); + const getXError = useErrorVariable(xErrorMeasure); + + return { + showXStandardError, + xErrorMeasure, + getXError, + getXErrorRange, + }; +}; + export type SegmentVariables = { segmentDimension: Dimension | undefined; segmentsByAbbreviationOrLabel: Map<string, DimensionValue>; @@ -657,7 +747,186 @@ export const useChartData = ( }; }; +export const useBarChartData = ( + observations: Observation[], + { + chartConfig, + timeRangeDimensionId, + getYAsDate, + getSegmentAbbreviationOrLabel, + getTimeRangeDate, + }: { + chartConfig: ChartConfig; + timeRangeDimensionId: string | undefined; + getYAsDate?: (d: Observation) => Date; + getSegmentAbbreviationOrLabel?: (d: Observation) => string; + getTimeRangeDate?: (d: Observation) => Date; + } +): Omit<ChartStateData, "allData"> => { + const { interactiveFiltersConfig } = chartConfig; + const categories = useChartInteractiveFilters((d) => d.categories); + const timeRange = useChartInteractiveFilters((d) => d.timeRange); + const timeSlider = useChartInteractiveFilters((d) => d.timeSlider); + + // time range + const interactiveTimeRange = interactiveFiltersConfig?.timeRange; + const timeRangeFromTime = interactiveTimeRange?.presets.from + ? parseDate(interactiveTimeRange?.presets.from).getTime() + : undefined; + const timeRangeToTime = interactiveTimeRange?.presets.to + ? parseDate(interactiveTimeRange?.presets.to).getTime() + : undefined; + const timeRangeFilters = useMemo(() => { + const timeRangeFilter: ValuePredicate | null = + getTimeRangeDate && timeRangeFromTime && timeRangeToTime + ? (d: Observation) => { + const time = getTimeRangeDate(d).getTime(); + return time >= timeRangeFromTime && time <= timeRangeToTime; + } + : null; + + return timeRangeFilter ? [timeRangeFilter] : []; + }, [timeRangeFromTime, timeRangeToTime, getTimeRangeDate]); + + // interactive time range + const interactiveFromTime = timeRange.from?.getTime(); + const interactiveToTime = timeRange.to?.getTime(); + const [{ dashboardFilters }] = useConfiguratorState(hasChartConfigs); + const { potentialTimeRangeFilterIds } = useDashboardInteractiveFilters(); + const interactiveTimeRangeFilters = useMemo(() => { + const interactiveTimeRangeFilter: ValuePredicate | null = + getYAsDate && + interactiveFromTime && + interactiveToTime && + (interactiveTimeRange?.active || + (dashboardFilters?.timeRange.active && + timeRangeDimensionId && + potentialTimeRangeFilterIds.includes(timeRangeDimensionId))) + ? (d: Observation) => { + const time = getYAsDate(d).getTime(); + return time >= interactiveFromTime && time <= interactiveToTime; + } + : null; + + return interactiveTimeRangeFilter ? [interactiveTimeRangeFilter] : []; + }, [ + getYAsDate, + interactiveFromTime, + interactiveToTime, + interactiveTimeRange?.active, + dashboardFilters?.timeRange.active, + timeRangeDimensionId, + potentialTimeRangeFilterIds, + ]); + + // interactive time slider + const animationField = getAnimationField(chartConfig); + const dynamicScales = animationField?.dynamicScales ?? true; + const animationComponentId = animationField?.componentId ?? ""; + const getAnimationDate = useTemporalVariable(animationComponentId); + const getAnimationOrdinalDate = useStringVariable(animationComponentId); + const interactiveTimeSliderFilters = useMemo(() => { + const interactiveTimeSliderFilter: ValuePredicate | null = + animationField?.componentId && timeSlider.value + ? (d: Observation) => { + if (timeSlider.type === "interval") { + return ( + getAnimationDate(d).getTime() === timeSlider.value!.getTime() + ); + } + + const ordinalDate = getAnimationOrdinalDate(d); + return ordinalDate === timeSlider.value!; + } + : null; + + return interactiveTimeSliderFilter ? [interactiveTimeSliderFilter] : []; + }, [ + animationField?.componentId, + timeSlider.type, + timeSlider.value, + getAnimationDate, + getAnimationOrdinalDate, + ]); + + // interactive legend + const interactiveLegendFilters = useMemo(() => { + const legendItems = Object.keys(categories); + const interactiveLegendFilter: ValuePredicate | null = + interactiveFiltersConfig?.legend?.active && getSegmentAbbreviationOrLabel + ? (d: Observation) => { + return !legendItems.includes(getSegmentAbbreviationOrLabel(d)); + } + : null; + + return interactiveLegendFilter ? [interactiveLegendFilter] : []; + }, [ + categories, + getSegmentAbbreviationOrLabel, + interactiveFiltersConfig?.legend?.active, + ]); + + const chartData = useMemo(() => { + return observations.filter( + overEvery([ + ...interactiveLegendFilters, + ...interactiveTimeRangeFilters, + ...interactiveTimeSliderFilters, + ]) + ); + }, [ + observations, + interactiveLegendFilters, + interactiveTimeRangeFilters, + interactiveTimeSliderFilters, + ]); + + const scalesData = useMemo(() => { + if (dynamicScales) { + return chartData; + } else { + return observations.filter( + overEvery([...interactiveLegendFilters, ...interactiveTimeRangeFilters]) + ); + } + }, [ + dynamicScales, + chartData, + observations, + interactiveLegendFilters, + interactiveTimeRangeFilters, + ]); + + const segmentData = useMemo(() => { + return observations.filter(overEvery(interactiveTimeRangeFilters)); + }, [observations, interactiveTimeRangeFilters]); + + const timeRangeData = useMemo(() => { + return observations.filter(overEvery(timeRangeFilters)); + }, [observations, timeRangeFilters]); + + const paddingData = useMemo(() => { + if (dynamicScales) { + return chartData; + } else { + return observations.filter(overEvery(interactiveLegendFilters)); + } + }, [dynamicScales, chartData, observations, interactiveLegendFilters]); + + return { + chartData, + scalesData, + segmentData, + timeRangeData, + paddingData, + }; +}; + // TODO: base this on UI encodings? export type InteractiveXTimeRangeState = { xScaleTimeRange: ScaleTime<number, number>; }; + +export type InteractiveYTimeRangeState = { + yScaleTimeRange: ScaleTime<number, number>; +}; diff --git a/app/charts/shared/imputation.tsx b/app/charts/shared/imputation.tsx index 25bc1e71e..d1d69c3cd 100644 --- a/app/charts/shared/imputation.tsx +++ b/app/charts/shared/imputation.tsx @@ -88,6 +88,59 @@ export const imputeTemporalLinearSeries = ({ return dataSortedByX as Array<TemporalSeriesAfterImputationEntry>; }; +export const imputeTemporalLinearSeriesInverted = ({ + dataSortedByY, +}: { + dataSortedByY: Array<TemporalSeriesBeforeImputationEntry>; +}): Array<TemporalSeriesAfterImputationEntry> => { + const presentDataIndexes = []; + const missingDataIndexes = []; + + for (let i = 0; i < dataSortedByY.length; i++) { + if (dataSortedByY[i].value !== null) { + presentDataIndexes.push(i); + } else { + missingDataIndexes.push(i); + } + } + + for (const missingDataIndex of missingDataIndexes) { + const nextPresentDataIndex = presentDataIndexes.findIndex( + (d) => d > missingDataIndex + ); + + if (nextPresentDataIndex) { + const previousPresentDataIndex = nextPresentDataIndex - 1; + + if (previousPresentDataIndex >= 0) { + const previous = + dataSortedByY[presentDataIndexes[previousPresentDataIndex]]; + const next = dataSortedByY[presentDataIndexes[nextPresentDataIndex]]; + + dataSortedByY[missingDataIndex] = { + date: dataSortedByY[missingDataIndex].date, + value: interpolateTemporalLinearValue({ + previousValue: previous.value!, + nextValue: next.value!, + previousTime: previous.date.getTime(), + nextTime: next.date.getTime(), + currentTime: dataSortedByY[missingDataIndex].date.getTime(), + }), + }; + + continue; + } + } + + dataSortedByY[missingDataIndex] = { + date: dataSortedByY[missingDataIndex].date, + value: 0, + }; + } + + return dataSortedByY as Array<TemporalSeriesAfterImputationEntry>; +}; + export const isUsingImputation = (chartConfig: ChartConfig): boolean => { if (isAreaConfig(chartConfig)) { const imputationType = chartConfig.fields.y.imputationType || ""; diff --git a/app/charts/shared/interaction/tooltip-content.tsx b/app/charts/shared/interaction/tooltip-content.tsx index dd4fc3c89..2f8f92b4d 100644 --- a/app/charts/shared/interaction/tooltip-content.tsx +++ b/app/charts/shared/interaction/tooltip-content.tsx @@ -41,6 +41,43 @@ export const TooltipSingle = ({ ); }; +export const TooltipSingleInverted = ({ + yValue, + segment, + xValue, + xError, +}: { + yValue?: string; + segment?: string; + xValue?: string; + xError?: string; +}) => { + return ( + <Box> + {yValue && ( + <Typography + component="div" + variant="caption" + sx={{ fontWeight: "bold" }} + > + {yValue} + </Typography> + )} + {segment && ( + <Typography component="div" variant="caption"> + {segment} + </Typography> + )} + {xValue && ( + <Typography component="div" variant="caption"> + {xValue} + {xError ? <> ± {xError}</> : null} + </Typography> + )} + </Box> + ); +}; + export const TooltipMultiple = ({ xValue, segmentValues, @@ -72,6 +109,37 @@ export const TooltipMultiple = ({ ); }; +export const TooltipMultipleInverted = ({ + yValue, + segmentValues, +}: { + yValue?: string; + segmentValues: TooltipValue[]; +}) => { + return ( + <Box> + {yValue && ( + <Typography + component="div" + variant="caption" + sx={{ fontWeight: "bold" }} + > + {yValue} + </Typography> + )} + {segmentValues.map((d, i) => ( + <LegendItem + key={i} + item={`${d.label}: ${d.value}${d.error ? ` ± ${d.error}` : ""}`} + color={d.color} + symbol={d.symbol ?? "square"} + usage="tooltip" + /> + ))} + </Box> + ); +}; + // Chart Specific export const TooltipScatterplot = ({ firstLine, diff --git a/app/charts/shared/interaction/tooltip.tsx b/app/charts/shared/interaction/tooltip.tsx index 3f6a94ac9..e3625de79 100644 --- a/app/charts/shared/interaction/tooltip.tsx +++ b/app/charts/shared/interaction/tooltip.tsx @@ -1,5 +1,6 @@ import { ReactNode } from "react"; +import { BarsState } from "@/charts/bar/bars-state"; import { LinesState } from "@/charts/line/lines-state"; import { PieState } from "@/charts/pie/pie-state"; import { useChartState } from "@/charts/shared/chart-state"; @@ -9,17 +10,35 @@ import { } from "@/charts/shared/interaction/tooltip-box"; import { TooltipMultiple, + TooltipMultipleInverted, TooltipSingle, + TooltipSingleInverted, } from "@/charts/shared/interaction/tooltip-content"; import { LegendSymbol } from "@/charts/shared/legend-color"; import { useInteraction } from "@/charts/shared/use-interaction"; import { Observation } from "@/domain/data"; -export const Tooltip = ({ type = "single" }: { type: TooltipType }) => { +export const Tooltip = ({ + type = "single", + inverted = false, +}: { + type: TooltipType; + inverted?: boolean; +}) => { const [state] = useInteraction(); const { visible, d } = state.interaction; - return <>{visible && d && <TooltipInner d={d} type={type} />}</>; + return ( + <> + {visible && + d && + (inverted ? ( + <TooltipInnerInverted d={d} type={type} /> + ) : ( + <TooltipInner d={d} type={type} /> + ))} + </> + ); }; export type { TooltipPlacement }; @@ -33,6 +52,7 @@ export interface TooltipValue { yPos?: number; symbol?: LegendSymbol; } + export interface TooltipInfo { xAnchor: number; yAnchor: number | undefined; @@ -43,6 +63,16 @@ export interface TooltipInfo { values: TooltipValue[] | undefined; } +export interface TooltipInfoInverted { + xAnchor: number; + yAnchor: number | undefined; + placement: TooltipPlacement; + yValue: string; + tooltipContent?: ReactNode; + datum: TooltipValue; + values: TooltipValue[] | undefined; +} + const TooltipInner = ({ d, type }: { d: Observation; type: TooltipType }) => { const { bounds, getAnnotationInfo } = useChartState() as | LinesState @@ -72,3 +102,37 @@ const TooltipInner = ({ d, type }: { d: Observation; type: TooltipType }) => { </TooltipBox> ); }; + +const TooltipInnerInverted = ({ + d, + type, +}: { + d: Observation; + type: TooltipType; +}) => { + const { bounds, getAnnotationInfo } = useChartState() as BarsState; + const { margins } = bounds; + const { xAnchor, yAnchor, placement, yValue, tooltipContent, datum, values } = + getAnnotationInfo(d as any); + + if (Number.isNaN(yAnchor)) { + return null; + } + + return ( + <TooltipBox x={xAnchor} y={yAnchor} placement={placement} margins={margins}> + {tooltipContent ? ( + tooltipContent + ) : type === "multiple" && values ? ( + <TooltipMultipleInverted yValue={yValue} segmentValues={values} /> + ) : ( + <TooltipSingleInverted + yValue={yValue} + segment={datum.label} + xValue={datum.value} + xError={datum.error} + /> + )} + </TooltipBox> + ); +}; diff --git a/app/charts/shared/rendering-utils.ts b/app/charts/shared/rendering-utils.ts index 38970bd8b..3725b89eb 100644 --- a/app/charts/shared/rendering-utils.ts +++ b/app/charts/shared/rendering-utils.ts @@ -163,6 +163,16 @@ export type RenderWhiskerDatum = { renderMiddleCircle?: boolean; }; +export type RenderWhiskerBarDatum = { + key: string; + y: number; + x1: number; + x2: number; + width: number; + fill?: string; + renderMiddleCircle?: boolean; +}; + export const renderWhiskers = ( g: Selection<SVGGElement, null, SVGGElement, unknown>, data: RenderWhiskerDatum[], @@ -275,6 +285,118 @@ export const renderWhiskers = ( ); }; +export const renderBarWhiskers = ( + g: Selection<SVGGElement, null, SVGGElement, unknown>, + data: RenderWhiskerBarDatum[], + options: RenderOptions +) => { + const { transition } = options; + + g.selectAll<SVGGElement, RenderWhiskerDatum>("g") + .data(data, (d) => d.key) + .join( + (enter) => + enter + .append("g") + .attr("opacity", 0) + .call((g) => + g + .append("rect") + .attr("class", "top") + .attr("y", (d) => d.y) + .attr("x", (d) => d.x2) + .attr("width", (d) => d.width) + .attr("height", ERROR_WHISKER_SIZE) + .attr("fill", (d) => d.fill ?? "black") + .attr("stroke", "none") + ) + .call((g) => + g + .append("rect") + .attr("class", "middle") + .attr("y", (d) => d.y + (d.width - ERROR_WHISKER_SIZE) / 2) + .attr("x", (d) => d.x2) + .attr("width", ERROR_WHISKER_SIZE) + .attr("height", (d) => Math.max(0, d.x1 - d.x2)) + .attr("fill", (d) => d.fill ?? "black") + .attr("stroke", "none") + ) + .call((g) => + g + .append("rect") + .attr("class", "bottom") + .attr("y", (d) => d.y) + .attr("x", (d) => d.x1) + .attr("width", (d) => d.width) + .attr("height", ERROR_WHISKER_SIZE) + .attr("fill", (d) => d.fill ?? "black") + .attr("stroke", "none") + ) + .call((g) => + g + .filter((d) => d.renderMiddleCircle ?? false) + .append("circle") + .attr("class", "middle-circle") + .attr("cy", (d) => d.y + d.width / 2) + .attr("cx", (d) => (d.x1 + d.x2) / 2) + .attr("r", ERROR_WHISKER_MIDDLE_CIRCLE_RADIUS) + .attr("fill", (d) => d.fill ?? "black") + .attr("stroke", "none") + ) + .call((enter) => + maybeTransition(enter, { + s: (g) => g.attr("opacity", 1), + transition, + }) + ), + (update) => + maybeTransition(update, { + s: (g) => + g + .attr("opacity", 1) + .call((g) => + g + .select(".top") + .attr("y", (d) => d.y) + .attr("x", (d) => d.x2) + .attr("width", (d) => d.width) + .attr("fill", (d) => d.fill ?? "black") + ) + .call((g) => + g + .select(".middle") + .attr("y", (d) => d.y + (d.width - ERROR_WHISKER_SIZE) / 2) + .attr("x", (d) => d.x2) + .attr("height", (d) => Math.max(0, d.x1 - d.x2)) + .attr("fill", (d) => d.fill ?? "black") + ) + .call((g) => + g + .select(".bottom") + .attr("y", (d) => d.y) + .attr("x", (d) => d.x1) + .attr("width", (d) => d.width) + .attr("fill", (d) => d.fill ?? "black") + ) + .call((g) => + g + .select(".middle-circle") + .attr("cy", (d) => d.y + d.width / 2) + .attr("cx", (d) => (d.x1 + d.x2) / 2) + .attr("r", ERROR_WHISKER_MIDDLE_CIRCLE_RADIUS) + .attr("fill", (d) => d.fill ?? "black") + .attr("stroke", "none") + ), + transition, + }), + (exit) => + maybeTransition(exit, { + transition, + s: (g) => g.attr("opacity", 0).remove(), + }) + ); +}; + export const filterWithoutErrors = ( getError: ((d: Observation) => ObservationValue) | null ) => { diff --git a/app/charts/shared/stacked-helpers.ts b/app/charts/shared/stacked-helpers.ts index 6f2bb7795..11c11a807 100644 --- a/app/charts/shared/stacked-helpers.ts +++ b/app/charts/shared/stacked-helpers.ts @@ -9,6 +9,7 @@ import { NumericalMeasure, Observation } from "@/domain/data"; import { formatNumberWithUnit } from "@/formatters"; const NORMALIZED_Y_DOMAIN = [0, 100]; +const NORMALIZED_X_DOMAIN = [0, 100]; export const getStackedYScale = ( data: Observation[], @@ -49,6 +50,45 @@ export const getStackedYScale = ( return yScale; }; +export const getStackedXScale = ( + data: Observation[], + options: { + normalize: boolean; + getY: StringValueGetter; + getX: NumericalValueGetter; + getTime?: StringValueGetter; + } +): ScaleLinear<number, number> => { + const { normalize, getX, getY, getTime } = options; + const xScale = scaleLinear(); + + if (normalize) { + xScale.domain(NORMALIZED_X_DOMAIN); + } else { + const grouped = group(data, (d) => getY(d) + getTime?.(d)); + let xMin = 0; + let xMax = 0; + + for (const [, v] of grouped) { + const values = v.map(getX).filter((d) => d !== null) as number[]; + const newXMin = sum(values.filter((d) => d < 0)); + const newXMax = sum(values.filter((d) => d >= 0)); + + if (xMin === undefined || newXMin < xMin) { + xMin = newXMin; + } + + if (xMax === undefined || newXMax > xMax) { + xMax = newXMax; + } + } + + xScale.domain([xMin, xMax]).nice(); + } + + return xScale; +}; + export const getStackedTooltipValueFormatter = ({ normalize, yMeasureId, @@ -79,3 +119,34 @@ export const getStackedTooltipValueFormatter = ({ return formatNumberWithUnit(d, format, yMeasureUnit); }; }; + +export const getStackedTooltipValueFormatterInverted = ({ + normalize, + xMeasureId, + xMeasureUnit, + formatters, + formatNumber, +}: { + normalize: boolean; + xMeasureId: string; + xMeasureUnit: NumericalMeasure["unit"]; + formatters: { [k: string]: (s: any) => string }; + formatNumber: (d: NumberValue | null | undefined) => string; +}) => { + return (d: number | null, dIdentity: number | null) => { + if (d === null && dIdentity === null) { + return "-"; + } + + const format = formatters[xMeasureId] ?? formatNumber; + + if (normalize) { + const rounded = Math.round(d as number); + const fValue = formatNumberWithUnit(dIdentity, format, xMeasureUnit); + + return `${rounded}% (${fValue})`; + } + + return formatNumberWithUnit(d, format, xMeasureUnit); + }; +}; diff --git a/app/config-types.ts b/app/config-types.ts index 635a77e40..7ab27cd18 100644 --- a/app/config-types.ts +++ b/app/config-types.ts @@ -316,8 +316,8 @@ export type BarSegmentField = t.TypeOf<typeof BarSegmentField>; const BarFields = t.intersection([ t.type({ - x: t.intersection([GenericField, SortingField]), - y: GenericField, + x: GenericField, + y: t.intersection([GenericField, SortingField]), }), t.partial({ segment: BarSegmentField, From fffca2f5189a22c53601101aea907344123d0530 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joa=CC=83o=20Sobral?= <joao.tiago.sobral@gmail.com> Date: Fri, 29 Nov 2024 13:47:06 +0000 Subject: [PATCH 04/54] =?UTF-8?q?feat=20=E2=9A=A1=EF=B8=8F:=20remove=20rot?= =?UTF-8?q?ation=20from=20left=20axis?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/charts/shared/axis-width-band-vertical.tsx | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/app/charts/shared/axis-width-band-vertical.tsx b/app/charts/shared/axis-width-band-vertical.tsx index b8a3d32b0..c5e7a1989 100644 --- a/app/charts/shared/axis-width-band-vertical.tsx +++ b/app/charts/shared/axis-width-band-vertical.tsx @@ -24,14 +24,13 @@ export const AxisWidthBand = () => { useEffect(() => { if (ref.current) { - const rotation = true; const hasNegativeValues = xScale.domain()[0] < 0; const fontSize = yScale.bandwidth() > labelFontSize ? labelFontSize : yScale.bandwidth(); const axis = axisLeft(yScale) .tickSizeOuter(0) - .tickSizeInner(hasNegativeValues ? -chartHeight : 6) - .tickPadding(rotation ? -10 : 0); + .tickSizeInner(hasNegativeValues ? -chartHeight : 6); + // .tickPadding(rotation ? -10 : 0); if (yTimeUnit) { axis.tickFormat((d) => formatDate(d, yTimeUnit)); @@ -57,12 +56,11 @@ export const AxisWidthBand = () => { hasNegativeValues ? gridColor : domainColor ); g.selectAll(".tick text") - .attr("transform", rotation ? "rotate(90)" : "rotate(0)") - .attr("x", rotation ? fontSize : 0) + .attr("x", 0) .attr("font-size", fontSize) .attr("font-family", fontFamily) .attr("fill", labelColor) - .attr("text-anchor", rotation ? "start" : "unset"); + .attr("text-anchor", "unset"); } }, [ chartHeight, From 4d791db8034c7d7c1141bfec136ec0c4078da7d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joa=CC=83o=20Sobral?= <joao.tiago.sobral@gmail.com> Date: Fri, 29 Nov 2024 13:47:37 +0000 Subject: [PATCH 05/54] =?UTF-8?q?feat=20=E2=9A=A1=EF=B8=8F:=20adjust=20lef?= =?UTF-8?q?t=20margin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/charts/bar/bars-grouped-state.tsx | 11 ++--------- app/charts/bar/bars-stacked-state.tsx | 11 ++--------- app/charts/bar/bars-state.tsx | 12 +++--------- 3 files changed, 7 insertions(+), 27 deletions(-) diff --git a/app/charts/bar/bars-grouped-state.tsx b/app/charts/bar/bars-grouped-state.tsx index 71716c822..1fb2937f7 100644 --- a/app/charts/bar/bars-grouped-state.tsx +++ b/app/charts/bar/bars-grouped-state.tsx @@ -23,7 +23,6 @@ import { PADDING_WITHIN, } from "@/charts/bar/constants"; import { - useAxisLabelHeightOffset, useChartBounds, useChartPadding, } from "@/charts/shared/chart-dimensions"; @@ -341,17 +340,11 @@ const useBarsGroupedState = ( : yTimeRangeDomainLabels, }); const right = 40; - const { offset: yAxisLabelMargin } = useAxisLabelHeightOffset({ - label: xMeasure.label, - width, - marginLeft: left, - marginRight: right, - }); const margins = { - top: 50 + yAxisLabelMargin, + top: 0, right, bottom, - left, + left: 50 + left, }; const bounds = useChartBounds(width, margins, height); const { chartWidth, chartHeight } = bounds; diff --git a/app/charts/bar/bars-stacked-state.tsx b/app/charts/bar/bars-stacked-state.tsx index 76f892174..1e9aefe9b 100644 --- a/app/charts/bar/bars-stacked-state.tsx +++ b/app/charts/bar/bars-stacked-state.tsx @@ -27,7 +27,6 @@ import { } from "@/charts/bar/bars-stacked-state-props"; import { PADDING_INNER, PADDING_OUTER } from "@/charts/bar/constants"; import { - useAxisLabelHeightOffset, useChartBounds, useChartPadding, } from "@/charts/shared/chart-dimensions"; @@ -401,17 +400,11 @@ const useBarsStackedState = ( normalize, }); const right = 40; - const { offset: yAxisLabelMargin } = useAxisLabelHeightOffset({ - label: xMeasure.label, - width, - marginLeft: left, - marginRight: right, - }); const margins = { - top: 50 + yAxisLabelMargin, + top: 0, right, bottom, - left, + left: 50 + left, }; const bounds = useChartBounds(width, margins, height); const { chartWidth, chartHeight } = bounds; diff --git a/app/charts/bar/bars-state.tsx b/app/charts/bar/bars-state.tsx index 65d27612e..8947d2691 100644 --- a/app/charts/bar/bars-state.tsx +++ b/app/charts/bar/bars-state.tsx @@ -16,7 +16,6 @@ import { } from "@/charts/bar/bars-state-props"; import { PADDING_INNER, PADDING_OUTER } from "@/charts/bar/constants"; import { - useAxisLabelHeightOffset, useChartBounds, useChartPadding, } from "@/charts/shared/chart-dimensions"; @@ -203,17 +202,12 @@ const useBarsState = ( : yTimeRangeDomainLabels, }); const right = 40; - const { offset: xAxisLabelMargin } = useAxisLabelHeightOffset({ - label: xMeasure.label, - width, - marginLeft: left, - marginRight: right, - }); const margins = { - top: 50 + xAxisLabelMargin, + top: 0, right, bottom, - left, + //NOTE: hardcoded for the moment + left: 50 + left, }; const bounds = useChartBounds(width, margins, height); From db39b8d78c7cbc60fc397b370cad70233a8f51c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joa=CC=83o=20Sobral?= <joao.tiago.sobral@gmail.com> Date: Fri, 29 Nov 2024 14:24:01 +0000 Subject: [PATCH 06/54] =?UTF-8?q?fix=20=F0=9F=90=9B:=20adjust=20interactio?= =?UTF-8?q?n=20scale?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/charts/bar/bars-state.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/charts/bar/bars-state.tsx b/app/charts/bar/bars-state.tsx index 8947d2691..54c73ef4a 100644 --- a/app/charts/bar/bars-state.tsx +++ b/app/charts/bar/bars-state.tsx @@ -130,7 +130,8 @@ const useBarsState = ( .paddingInner(PADDING_INNER) .paddingOuter(PADDING_OUTER); const yScaleInteraction = scaleBand() - .domain(bandDomain) + //NOTE: not sure if this is the right way to go here + .domain(bandDomain.reverse()) .paddingInner(0) .paddingOuter(0); From e63474e129d4ac24262013f699cdbda7146c8f8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joa=CC=83o=20Sobral?= <joao.tiago.sobral@gmail.com> Date: Fri, 29 Nov 2024 15:12:54 +0000 Subject: [PATCH 07/54] =?UTF-8?q?fix=20=F0=9F=90=9B:=20x=20scale=20on=20gr?= =?UTF-8?q?ouped=20bar=20charts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/charts/bar/bars-grouped-state.tsx | 2 +- app/charts/bar/bars-grouped.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/charts/bar/bars-grouped-state.tsx b/app/charts/bar/bars-grouped-state.tsx index 1fb2937f7..b2c66b679 100644 --- a/app/charts/bar/bars-grouped-state.tsx +++ b/app/charts/bar/bars-grouped-state.tsx @@ -252,7 +252,7 @@ const useBarsGroupedState = ( ) ?? 0, 0 ); - const xScale = scaleLinear().domain([minValue, maxValue]).nice(); + const xScale = scaleLinear().domain([maxValue, minValue]).nice(); const minPaddingValue = getMinX(paddingData, (d) => getXErrorRange ? getXErrorRange(d)[0] : getX(d) diff --git a/app/charts/bar/bars-grouped.tsx b/app/charts/bar/bars-grouped.tsx index 3b9d9c18a..87d54e2de 100644 --- a/app/charts/bar/bars-grouped.tsx +++ b/app/charts/bar/bars-grouped.tsx @@ -111,7 +111,7 @@ export const BarsGrouped = () => { return { key, y: (yScale(segment) as number) + (yScaleIn(y) as number), - x: xScale(Math.max(x, 0)), + x: xScale(Math.min(x, 0)), width: Math.max(0, Math.abs(xScale(x) - x0)), height: bandwidth, color: colors(y), From d437b9c9e8c5a8bba126cfb0cf75e9324fd62305 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joa=CC=83o=20Sobral?= <joao.tiago.sobral@gmail.com> Date: Fri, 29 Nov 2024 15:37:10 +0000 Subject: [PATCH 08/54] =?UTF-8?q?fix=20=F0=9F=90=9B:=20adjust=20grouped/st?= =?UTF-8?q?acked=20chart=20width=20and=20height?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/charts/bar/bars-grouped-state.tsx | 8 ++++---- app/charts/bar/bars-stacked-state.tsx | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/app/charts/bar/bars-grouped-state.tsx b/app/charts/bar/bars-grouped-state.tsx index b2c66b679..db274ee68 100644 --- a/app/charts/bar/bars-grouped-state.tsx +++ b/app/charts/bar/bars-grouped-state.tsx @@ -350,11 +350,11 @@ const useBarsGroupedState = ( const { chartWidth, chartHeight } = bounds; // Adjust of scales based on chart dimensions - yScale.range([0, chartWidth]); - yScaleInteraction.range([0, chartWidth]); + yScale.range([0, chartHeight]); + yScaleInteraction.range([0, chartHeight]); yScaleIn.range([0, yScale.bandwidth()]); - yScaleTimeRange.range([0, chartWidth]); - xScale.range([chartHeight, 0]); + yScaleTimeRange.range([0, chartHeight]); + xScale.range([chartWidth, 0]); const isMobile = useIsMobile(); diff --git a/app/charts/bar/bars-stacked-state.tsx b/app/charts/bar/bars-stacked-state.tsx index 1e9aefe9b..d31c50f2f 100644 --- a/app/charts/bar/bars-stacked-state.tsx +++ b/app/charts/bar/bars-stacked-state.tsx @@ -409,10 +409,10 @@ const useBarsStackedState = ( const bounds = useChartBounds(width, margins, height); const { chartWidth, chartHeight } = bounds; - yScale.range([0, chartWidth]); - yScaleInteraction.range([0, chartWidth]); - yScaleTimeRange.range([0, chartWidth]); - xScale.range([chartHeight, 0]); + yScale.range([0, chartHeight]); + yScaleInteraction.range([0, chartHeight]); + yScaleTimeRange.range([0, chartHeight]); + xScale.range([chartWidth, 0]); const isMobile = useIsMobile(); From 4f4165c7bcca5ab192cb2879db70ebbeb961d95f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joa=CC=83o=20Sobral?= <joao.tiago.sobral@gmail.com> Date: Mon, 2 Dec 2024 14:20:27 +0000 Subject: [PATCH 09/54] =?UTF-8?q?refactor=20=E2=99=BB=EF=B8=8F:=20addresse?= =?UTF-8?q?d=20some=20of=20the=20feedback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/charts/bar/bars-grouped-state-props.ts | 4 +- app/charts/bar/bars-grouped-state.tsx | 2 +- app/charts/bar/bars-grouped.tsx | 10 ++--- app/charts/bar/bars.tsx | 10 ++--- app/charts/bar/chart-bar.tsx | 18 ++++---- app/charts/column/columns-grouped-state.tsx | 2 +- app/charts/column/columns-grouped.tsx | 10 ++--- app/charts/column/columns.tsx | 10 ++--- app/charts/line/lines.tsx | 10 ++--- ...band-vertical.tsx => axis-height-band.tsx} | 7 ++-- app/charts/shared/chart-state.ts | 1 - app/charts/shared/rendering-utils.ts | 16 ++++---- app/charts/shared/stacked-helpers.ts | 7 ++-- app/config-types.ts | 41 ++++++------------- 14 files changed, 65 insertions(+), 83 deletions(-) rename app/charts/shared/{axis-width-band-vertical.tsx => axis-height-band.tsx} (95%) diff --git a/app/charts/bar/bars-grouped-state-props.ts b/app/charts/bar/bars-grouped-state-props.ts index 4877d4fed..f81efdae3 100644 --- a/app/charts/bar/bars-grouped-state-props.ts +++ b/app/charts/bar/bars-grouped-state-props.ts @@ -61,7 +61,7 @@ export const useBarsGroupedStateVariables = ( dimensionsById, observations, }); - const numericalYErrorVariables = useNumericalXErrorVariables(x, { + const numericalXErrorVariables = useNumericalXErrorVariables(x, { numericalXVariables, dimensions, measures, @@ -116,7 +116,7 @@ export const useBarsGroupedStateVariables = ( sortData, ...bandYVariables, ...numericalXVariables, - ...numericalYErrorVariables, + ...numericalXErrorVariables, ...segmentVariables, ...interactiveFiltersVariables, getRenderingKey, diff --git a/app/charts/bar/bars-grouped-state.tsx b/app/charts/bar/bars-grouped-state.tsx index db274ee68..41b8ea48d 100644 --- a/app/charts/bar/bars-grouped-state.tsx +++ b/app/charts/bar/bars-grouped-state.tsx @@ -172,7 +172,7 @@ const useBarsGroupedState = ( return Object.fromEntries( rollup( chartData, - (v) => sum(v, (y) => getX(y)), + (v) => sum(v, (d) => getX(d)), (y) => getY(y) ) ); diff --git a/app/charts/bar/bars-grouped.tsx b/app/charts/bar/bars-grouped.tsx index 87d54e2de..57f8cf2dd 100644 --- a/app/charts/bar/bars-grouped.tsx +++ b/app/charts/bar/bars-grouped.tsx @@ -5,9 +5,9 @@ import { RenderBarDatum, renderBars } from "@/charts/bar/rendering-utils"; import { useChartState } from "@/charts/shared/chart-state"; import { filterWithoutErrors, - renderBarWhiskers, + renderHorizontalWhisker, renderContainer, - RenderWhiskerBarDatum, + RenderHorizontalWhiskerDatum, } from "@/charts/shared/rendering-utils"; import { useTransitionStore } from "@/stores/transition"; @@ -27,7 +27,7 @@ export const ErrorWhiskers = () => { const ref = useRef<SVGGElement>(null); const enableTransition = useTransitionStore((state) => state.enable); const transitionDuration = useTransitionStore((state) => state.duration); - const renderData: RenderWhiskerBarDatum[] = useMemo(() => { + const renderData: RenderHorizontalWhiskerDatum[] = useMemo(() => { if (!getXErrorRange || !showXStandardError) { return []; } @@ -46,7 +46,7 @@ export const ErrorWhiskers = () => { x1: xScale(x1), x2: xScale(x2), width: barWidth, - } as RenderWhiskerBarDatum; + } as RenderHorizontalWhiskerDatum; }) ); // eslint-disable-next-line react-hooks/exhaustive-deps @@ -69,7 +69,7 @@ export const ErrorWhiskers = () => { id: "bars-grouped-error-whiskers", transform: `translate(${margins.left} ${margins.top})`, transition: { enable: enableTransition, duration: transitionDuration }, - render: (g, opts) => renderBarWhiskers(g, renderData, opts), + render: (g, opts) => renderHorizontalWhisker(g, renderData, opts), }); } }, [ diff --git a/app/charts/bar/bars.tsx b/app/charts/bar/bars.tsx index e7099e1d2..55e3aa914 100644 --- a/app/charts/bar/bars.tsx +++ b/app/charts/bar/bars.tsx @@ -5,10 +5,10 @@ import { BarsState } from "@/charts/bar/bars-state"; import { RenderBarDatum, renderBars } from "@/charts/bar/rendering-utils"; import { useChartState } from "@/charts/shared/chart-state"; import { - RenderWhiskerBarDatum, + RenderHorizontalWhiskerDatum, filterWithoutErrors, - renderBarWhiskers, renderContainer, + renderHorizontalWhisker, } from "@/charts/shared/rendering-utils"; import { useTransitionStore } from "@/stores/transition"; import { useTheme } from "@/themes"; @@ -28,7 +28,7 @@ export const ErrorWhiskers = () => { const ref = useRef<SVGGElement>(null); const enableTransition = useTransitionStore((state) => state.enable); const transitionDuration = useTransitionStore((state) => state.duration); - const renderData: RenderWhiskerBarDatum[] = useMemo(() => { + const renderData: RenderHorizontalWhiskerDatum[] = useMemo(() => { if (!getXErrorRange || !showXStandardError) { return []; } @@ -44,7 +44,7 @@ export const ErrorWhiskers = () => { x1: xScale(x1), x2: xScale(x2), width: barWidth, - } as RenderWhiskerBarDatum; + } as RenderHorizontalWhiskerDatum; }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [ @@ -65,7 +65,7 @@ export const ErrorWhiskers = () => { id: "bars-error-whiskers", transform: `translate(${margins.left} ${margins.top})`, transition: { enable: enableTransition, duration: transitionDuration }, - render: (g, opts) => renderBarWhiskers(g, renderData, opts), + render: (g, opts) => renderHorizontalWhisker(g, renderData, opts), }); } }, [ diff --git a/app/charts/bar/chart-bar.tsx b/app/charts/bar/chart-bar.tsx index 05ccdd948..d4d791577 100644 --- a/app/charts/bar/chart-bar.tsx +++ b/app/charts/bar/chart-bar.tsx @@ -12,9 +12,9 @@ import { BarChart } from "@/charts/bar/bars-state"; import { InteractionBars } from "@/charts/bar/overlay-bars"; import { ChartDataWrapper } from "@/charts/chart-data-wrapper"; import { - AxisWidthBand, - AxisWidthBandDomain, -} from "@/charts/shared/axis-width-band-vertical"; + AxisHeightBand, + AxisHeightBandDomain, +} from "@/charts/shared/axis-height-band"; import { AxisWidthLinear } from "@/charts/shared/axis-width-linear"; import { BrushTime, shouldShowBrush } from "@/charts/shared/brush"; import { @@ -54,9 +54,9 @@ const ChartBars = memo((props: ChartProps<BarConfig>) => { <ChartContainer> <ChartSvg> <AxisWidthLinear /> - <AxisWidthBand /> + <AxisHeightBand /> <BarsStacked /> - <AxisWidthBandDomain /> + <AxisHeightBandDomain /> <InteractionBars /> {showTimeBrush && <BrushTime />} </ChartSvg> @@ -84,10 +84,10 @@ const ChartBars = memo((props: ChartProps<BarConfig>) => { <ChartContainer> <ChartSvg> <AxisWidthLinear /> - <AxisWidthBand /> + <AxisHeightBand /> <BarsGrouped /> <ErrorWhiskersGrouped /> - <AxisWidthBandDomain /> + <AxisHeightBandDomain /> <InteractionBars /> {showTimeBrush && <BrushTime />} </ChartSvg> @@ -115,10 +115,10 @@ const ChartBars = memo((props: ChartProps<BarConfig>) => { <ChartContainer> <ChartSvg> <AxisWidthLinear /> - <AxisWidthBand /> + <AxisHeightBand /> <Bars /> <ErrorWhiskers /> - <AxisWidthBandDomain /> + <AxisHeightBandDomain /> <InteractionBars /> {showTimeBrush && <BrushTime />} </ChartSvg> diff --git a/app/charts/column/columns-grouped-state.tsx b/app/charts/column/columns-grouped-state.tsx index dc6539d94..4bf79220e 100644 --- a/app/charts/column/columns-grouped-state.tsx +++ b/app/charts/column/columns-grouped-state.tsx @@ -173,7 +173,7 @@ const useColumnsGroupedState = ( return Object.fromEntries( rollup( chartData, - (v) => sum(v, (x) => getY(x)), + (v) => sum(v, (d) => getY(d)), (x) => getX(x) ) ); diff --git a/app/charts/column/columns-grouped.tsx b/app/charts/column/columns-grouped.tsx index a3b87609d..627dd75ed 100644 --- a/app/charts/column/columns-grouped.tsx +++ b/app/charts/column/columns-grouped.tsx @@ -7,10 +7,10 @@ import { } from "@/charts/column/rendering-utils"; import { useChartState } from "@/charts/shared/chart-state"; import { - RenderWhiskerDatum, + RenderVerticalWhiskerDatum, filterWithoutErrors, renderContainer, - renderWhiskers, + renderVerticalWhiskers, } from "@/charts/shared/rendering-utils"; import { useTransitionStore } from "@/stores/transition"; @@ -30,7 +30,7 @@ export const ErrorWhiskers = () => { const ref = useRef<SVGGElement>(null); const enableTransition = useTransitionStore((state) => state.enable); const transitionDuration = useTransitionStore((state) => state.duration); - const renderData: RenderWhiskerDatum[] = useMemo(() => { + const renderData: RenderVerticalWhiskerDatum[] = useMemo(() => { if (!getYErrorRange || !showYStandardError) { return []; } @@ -49,7 +49,7 @@ export const ErrorWhiskers = () => { y1: yScale(y1), y2: yScale(y2), width: barWidth, - } as RenderWhiskerDatum; + } as RenderVerticalWhiskerDatum; }) ); // eslint-disable-next-line react-hooks/exhaustive-deps @@ -72,7 +72,7 @@ export const ErrorWhiskers = () => { id: "columns-grouped-error-whiskers", transform: `translate(${margins.left} ${margins.top})`, transition: { enable: enableTransition, duration: transitionDuration }, - render: (g, opts) => renderWhiskers(g, renderData, opts), + render: (g, opts) => renderVerticalWhiskers(g, renderData, opts), }); } }, [ diff --git a/app/charts/column/columns.tsx b/app/charts/column/columns.tsx index cfcc9a256..a71ea0897 100644 --- a/app/charts/column/columns.tsx +++ b/app/charts/column/columns.tsx @@ -8,10 +8,10 @@ import { } from "@/charts/column/rendering-utils"; import { useChartState } from "@/charts/shared/chart-state"; import { - RenderWhiskerDatum, + RenderVerticalWhiskerDatum, filterWithoutErrors, renderContainer, - renderWhiskers, + renderVerticalWhiskers, } from "@/charts/shared/rendering-utils"; import { useTransitionStore } from "@/stores/transition"; import { useTheme } from "@/themes"; @@ -31,7 +31,7 @@ export const ErrorWhiskers = () => { const ref = useRef<SVGGElement>(null); const enableTransition = useTransitionStore((state) => state.enable); const transitionDuration = useTransitionStore((state) => state.duration); - const renderData: RenderWhiskerDatum[] = useMemo(() => { + const renderData: RenderVerticalWhiskerDatum[] = useMemo(() => { if (!getYErrorRange || !showYStandardError) { return []; } @@ -47,7 +47,7 @@ export const ErrorWhiskers = () => { y1: yScale(y1), y2: yScale(y2), width: barWidth, - } as RenderWhiskerDatum; + } as RenderVerticalWhiskerDatum; }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [ @@ -68,7 +68,7 @@ export const ErrorWhiskers = () => { id: "columns-error-whiskers", transform: `translate(${margins.left} ${margins.top})`, transition: { enable: enableTransition, duration: transitionDuration }, - render: (g, opts) => renderWhiskers(g, renderData, opts), + render: (g, opts) => renderVerticalWhiskers(g, renderData, opts), }); } }, [ diff --git a/app/charts/line/lines.tsx b/app/charts/line/lines.tsx index c0a63bd1f..1e1f8c2e1 100644 --- a/app/charts/line/lines.tsx +++ b/app/charts/line/lines.tsx @@ -4,10 +4,10 @@ import { Fragment, memo, useEffect, useMemo, useRef } from "react"; import { LinesState } from "@/charts/line/lines-state"; import { useChartState } from "@/charts/shared/chart-state"; import { - RenderWhiskerDatum, + RenderVerticalWhiskerDatum, filterWithoutErrors, renderContainer, - renderWhiskers, + renderVerticalWhiskers, } from "@/charts/shared/rendering-utils"; import { Observation } from "@/domain/data"; import { useTransitionStore } from "@/stores/transition"; @@ -29,7 +29,7 @@ export const ErrorWhiskers = () => { const ref = useRef<SVGGElement>(null); const enableTransition = useTransitionStore((state) => state.enable); const transitionDuration = useTransitionStore((state) => state.duration); - const renderData: RenderWhiskerDatum[] = useMemo(() => { + const renderData: RenderVerticalWhiskerDatum[] = useMemo(() => { if (!getYErrorRange || !showYStandardError) { return []; } @@ -47,7 +47,7 @@ export const ErrorWhiskers = () => { width: barWidth, fill: colors(segment), renderMiddleCircle: true, - } as RenderWhiskerDatum; + } as RenderVerticalWhiskerDatum; }); }, [ chartData, @@ -67,7 +67,7 @@ export const ErrorWhiskers = () => { id: "lines-error-whiskers", transform: `translate(${margins.left} ${margins.top})`, transition: { enable: enableTransition, duration: transitionDuration }, - render: (g, opts) => renderWhiskers(g, renderData, opts), + render: (g, opts) => renderVerticalWhiskers(g, renderData, opts), }); } }, [ diff --git a/app/charts/shared/axis-width-band-vertical.tsx b/app/charts/shared/axis-height-band.tsx similarity index 95% rename from app/charts/shared/axis-width-band-vertical.tsx rename to app/charts/shared/axis-height-band.tsx index c5e7a1989..2d7790dc2 100644 --- a/app/charts/shared/axis-width-band-vertical.tsx +++ b/app/charts/shared/axis-height-band.tsx @@ -11,7 +11,7 @@ import { useChartTheme } from "@/charts/shared/use-chart-theme"; import { useTimeFormatUnit } from "@/formatters"; import { useTransitionStore } from "@/stores/transition"; -export const AxisWidthBand = () => { +export const AxisHeightBand = () => { const ref = useRef<SVGGElement>(null); const state = useChartState() as BarsState; const { xScale, getYLabel, yTimeUnit, yScale, bounds } = state; @@ -30,7 +30,6 @@ export const AxisWidthBand = () => { const axis = axisLeft(yScale) .tickSizeOuter(0) .tickSizeInner(hasNegativeValues ? -chartHeight : 6); - // .tickPadding(rotation ? -10 : 0); if (yTimeUnit) { axis.tickFormat((d) => formatDate(d, yTimeUnit)); @@ -83,7 +82,7 @@ export const AxisWidthBand = () => { return <g ref={ref} />; }; -export const AxisWidthBandDomain = () => { +export const AxisHeightBandDomain = () => { const ref = useRef<SVGGElement>(null); const enableTransition = useTransitionStore((state) => state.enable); const transitionDuration = useTransitionStore((state) => state.duration); @@ -95,7 +94,7 @@ export const AxisWidthBandDomain = () => { if (ref.current) { const axis = axisLeft(yScale).tickSizeOuter(0); const g = renderContainer(ref.current, { - id: "axis-width-band-vertical-domain", + id: "axis-height-band-domain", transform: `translate(${margins.left} ${margins.top})`, transition: { enable: enableTransition, duration: transitionDuration }, render: (g) => g.call(axis), diff --git a/app/charts/shared/chart-state.ts b/app/charts/shared/chart-state.ts index 2d01838ef..528950ed3 100644 --- a/app/charts/shared/chart-state.ts +++ b/app/charts/shared/chart-state.ts @@ -109,7 +109,6 @@ export const useChartState = () => { export type ChartWithInteractiveXTimeRangeState = | AreasState | ColumnsState - // | BarsState | LinesState; export type NumericalValueGetter = (d: Observation) => number | null; diff --git a/app/charts/shared/rendering-utils.ts b/app/charts/shared/rendering-utils.ts index 3725b89eb..37aacb045 100644 --- a/app/charts/shared/rendering-utils.ts +++ b/app/charts/shared/rendering-utils.ts @@ -153,7 +153,7 @@ type AnyTransition = Transition<any, any, any, any>; const ERROR_WHISKER_SIZE = 1; const ERROR_WHISKER_MIDDLE_CIRCLE_RADIUS = 3.5; -export type RenderWhiskerDatum = { +export type RenderVerticalWhiskerDatum = { key: string; x: number; y1: number; @@ -163,7 +163,7 @@ export type RenderWhiskerDatum = { renderMiddleCircle?: boolean; }; -export type RenderWhiskerBarDatum = { +export type RenderHorizontalWhiskerDatum = { key: string; y: number; x1: number; @@ -173,14 +173,14 @@ export type RenderWhiskerBarDatum = { renderMiddleCircle?: boolean; }; -export const renderWhiskers = ( +export const renderVerticalWhiskers = ( g: Selection<SVGGElement, null, SVGGElement, unknown>, - data: RenderWhiskerDatum[], + data: RenderVerticalWhiskerDatum[], options: RenderOptions ) => { const { transition } = options; - g.selectAll<SVGGElement, RenderWhiskerDatum>("g") + g.selectAll<SVGGElement, RenderVerticalWhiskerDatum>("g") .data(data, (d) => d.key) .join( (enter) => @@ -285,14 +285,14 @@ export const renderWhiskers = ( ); }; -export const renderBarWhiskers = ( +export const renderHorizontalWhisker = ( g: Selection<SVGGElement, null, SVGGElement, unknown>, - data: RenderWhiskerBarDatum[], + data: RenderHorizontalWhiskerDatum[], options: RenderOptions ) => { const { transition } = options; - g.selectAll<SVGGElement, RenderWhiskerDatum>("g") + g.selectAll<SVGGElement, RenderHorizontalWhiskerDatum>("g") .data(data, (d) => d.key) .join( (enter) => diff --git a/app/charts/shared/stacked-helpers.ts b/app/charts/shared/stacked-helpers.ts index 11c11a807..444a8c865 100644 --- a/app/charts/shared/stacked-helpers.ts +++ b/app/charts/shared/stacked-helpers.ts @@ -8,8 +8,7 @@ import { import { NumericalMeasure, Observation } from "@/domain/data"; import { formatNumberWithUnit } from "@/formatters"; -const NORMALIZED_Y_DOMAIN = [0, 100]; -const NORMALIZED_X_DOMAIN = [0, 100]; +const NORMALIZED_VALUE_DOMAIN = [0, 100]; export const getStackedYScale = ( data: Observation[], @@ -24,7 +23,7 @@ export const getStackedYScale = ( const yScale = scaleLinear(); if (normalize) { - yScale.domain(NORMALIZED_Y_DOMAIN); + yScale.domain(NORMALIZED_VALUE_DOMAIN); } else { const grouped = group(data, (d) => getX(d) + getTime?.(d)); let yMin = 0; @@ -63,7 +62,7 @@ export const getStackedXScale = ( const xScale = scaleLinear(); if (normalize) { - xScale.domain(NORMALIZED_X_DOMAIN); + xScale.domain(NORMALIZED_VALUE_DOMAIN); } else { const grouped = group(data, (d) => getY(d) + getTime?.(d)); let xMin = 0; diff --git a/app/config-types.ts b/app/config-types.ts index 7ab27cd18..181876eea 100644 --- a/app/config-types.ts +++ b/app/config-types.ts @@ -757,32 +757,6 @@ const ComboLineColumnConfig = t.intersection([ ]); export type ComboLineColumnConfig = t.TypeOf<typeof ComboLineColumnConfig>; -const ComboLineBarFields = t.type({ - x: GenericField, - y: t.type({ - lineComponentId: t.string, - lineAxisOrientation: t.union([t.literal("left"), t.literal("right")]), - barComponentId: t.string, - palette: t.string, - colorMapping: ColorMapping, - }), -}); - -export type ComboLineBarFields = t.TypeOf<typeof ComboLineBarFields>; - -const ComboLineBarConfig = t.intersection([ - GenericChartConfig, - t.type( - { - chartType: t.literal("comboLineBar"), - fields: ComboLineBarFields, - interactiveFiltersConfig: InteractiveFiltersConfig, - }, - "ComboLineBarConfig" - ), -]); -export type ComboLineBarConfig = t.TypeOf<typeof ComboLineBarConfig>; - export type ChartSegmentField = | AreaSegmentField | ColumnSegmentField @@ -938,7 +912,12 @@ export const isSegmentInConfig = ( export const isSortingInConfig = ( chartConfig: ChartConfig -): chartConfig is AreaConfig | ColumnConfig | LineConfig | PieConfig => { +): chartConfig is + | AreaConfig + | ColumnConfig + | BarConfig + | LineConfig + | PieConfig => { return ["area", "column", "bar", "line", "pie"].includes( chartConfig.chartType ); @@ -946,7 +925,12 @@ export const isSortingInConfig = ( export const isAnimationInConfig = ( chartConfig: ChartConfig -): chartConfig is ColumnConfig | MapConfig | PieConfig | ScatterPlotConfig => { +): chartConfig is + | ColumnConfig + | BarConfig + | MapConfig + | PieConfig + | ScatterPlotConfig => { return ["column", "bar", "map", "pie", "scatterplot"].includes( chartConfig.chartType ); @@ -1029,6 +1013,7 @@ type BarAdjusters = BaseAdjusters<BarConfig> & { y: { componentId: FieldAdjuster<BarConfig, string> }; segment: FieldAdjuster< BarConfig, + | ColumnSegmentField | LineSegmentField | AreaSegmentField | ScatterPlotSegmentField From b670ce46ab58d37663b3310cee2feef72c062bc2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joa=CC=83o=20Sobral?= <joao.tiago.sobral@gmail.com> Date: Mon, 2 Dec 2024 14:57:32 +0000 Subject: [PATCH 10/54] =?UTF-8?q?feat=20=E2=9A=A1=EF=B8=8F:=20show=20stack?= =?UTF-8?q?ed=20bar=20charts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/charts/chart-config-ui-options.ts | 16 ++++++++-------- .../components/chart-options-selector.tsx | 6 ++---- 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/app/charts/chart-config-ui-options.ts b/app/charts/chart-config-ui-options.ts index bc96a5cfe..1170c7909 100644 --- a/app/charts/chart-config-ui-options.ts +++ b/app/charts/chart-config-ui-options.ts @@ -780,9 +780,9 @@ const chartConfigOptionsUISpec: ChartSpecs = { filters: false, onChange: (id, { chartConfig, measures }) => { if (chartConfig.fields.segment?.type === "stacked") { - const yMeasure = measures.find((d) => d.id === id); + const xMeasure = measures.find((d) => d.id === id); - if (disableStacked(yMeasure)) { + if (disableStacked(xMeasure)) { setWith(chartConfig, "fields.segment.type", "grouped", Object); if (chartConfig.interactiveFiltersConfig?.calculation) { @@ -851,15 +851,15 @@ const chartConfigOptionsUISpec: ChartSpecs = { chartConfig, "fields.segment" ); - const yComponent = components.find( - (d) => d.id === chartConfig.fields.y.componentId + const xComponent = components.find( + (d) => d.id === chartConfig.fields.x.componentId ); setWith( chartConfig, "fields.segment", { ...segment, - type: disableStacked(yComponent) ? "grouped" : "stacked", + type: disableStacked(xComponent) ? "grouped" : "stacked", }, Object ); @@ -883,9 +883,9 @@ const chartConfigOptionsUISpec: ChartSpecs = { }, chartSubType: { getValues: (chartConfig, dimensions) => { - const yId = chartConfig.fields.y.componentId; - const yDimension = dimensions.find((d) => d.id === yId); - const disabledStacked = disableStacked(yDimension); + const xId = chartConfig.fields.x.componentId; + const xDimension = dimensions.find((d) => d.id === xId); + const disabledStacked = disableStacked(xDimension); return [ { diff --git a/app/configurator/components/chart-options-selector.tsx b/app/configurator/components/chart-options-selector.tsx index a0715c6ce..e0f30def9 100644 --- a/app/configurator/components/chart-options-selector.tsx +++ b/app/configurator/components/chart-options-selector.tsx @@ -246,7 +246,6 @@ const EncodingOptionsPanel = (props: EncodingOptionsPanelProps) => { measures, observations, } = props; - const { chartType } = chartConfig; const fieldLabelHint: Record<EncodingFieldType, string> = { animation: t({ id: "controls.select.dimension", @@ -327,8 +326,7 @@ const EncodingOptionsPanel = (props: EncodingOptionsPanelProps) => { const hasColorPalette = !!encoding.options?.colorPalette; - const hasSubOptions = - (encoding.options?.chartSubType && chartType === "column") ?? false; + const hasSubOptions = encoding.options?.chartSubType ?? false; return ( <div @@ -412,7 +410,7 @@ const EncodingOptionsPanel = (props: EncodingOptionsPanelProps) => { chartConfig={chartConfig} components={allComponents} hasColorPalette={hasColorPalette} - hasSubOptions={hasSubOptions} + hasSubOptions={!!hasSubOptions} /> )} {encoding.options?.imputation?.shouldShow(chartConfig, observations) && ( From 585228155bab2d7dfc6a9a74fd2807329c7efecb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joa=CC=83o=20Sobral?= <joao.tiago.sobral@gmail.com> Date: Mon, 2 Dec 2024 15:14:59 +0000 Subject: [PATCH 11/54] =?UTF-8?q?refactor=20=E2=99=BB=EF=B8=8F:=20var=20ad?= =?UTF-8?q?justment?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/charts/bar/bars-stacked-state.tsx | 2 +- app/charts/column/columns-stacked-state.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/charts/bar/bars-stacked-state.tsx b/app/charts/bar/bars-stacked-state.tsx index d31c50f2f..a869b715d 100644 --- a/app/charts/bar/bars-stacked-state.tsx +++ b/app/charts/bar/bars-stacked-state.tsx @@ -177,7 +177,7 @@ const useBarsStackedState = ( return Object.fromEntries( rollup( chartData, - (v) => sum(v, (x) => getX(x)), + (v) => sum(v, (d) => getX(d)), (x) => getY(x) ) ); diff --git a/app/charts/column/columns-stacked-state.tsx b/app/charts/column/columns-stacked-state.tsx index 99cf9e869..bc88957c2 100644 --- a/app/charts/column/columns-stacked-state.tsx +++ b/app/charts/column/columns-stacked-state.tsx @@ -178,7 +178,7 @@ const useColumnsStackedState = ( return Object.fromEntries( rollup( chartData, - (v) => sum(v, (x) => getY(x)), + (v) => sum(v, (d) => getY(d)), (x) => getX(x) ) ); From 4ba3b89ce7cf9c9439af92d03a3b0e12e67e7855 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joa=CC=83o=20Sobral?= <joao.tiago.sobral@gmail.com> Date: Mon, 2 Dec 2024 15:53:43 +0000 Subject: [PATCH 12/54] =?UTF-8?q?feat=20=E2=9A=A1=EF=B8=8F:=20change=20id?= =?UTF-8?q?=20name?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/charts/shared/axis-height-band.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/charts/shared/axis-height-band.tsx b/app/charts/shared/axis-height-band.tsx index 2d7790dc2..d3ed87de0 100644 --- a/app/charts/shared/axis-height-band.tsx +++ b/app/charts/shared/axis-height-band.tsx @@ -38,7 +38,7 @@ export const AxisHeightBand = () => { } const g = renderContainer(ref.current, { - id: "axis-width-band-vertical", + id: "axis-height-band", transform: `translate(${margins.left} ${margins.top})`, transition: { enable: enableTransition, duration: transitionDuration }, render: (g) => g.attr("data-testid", "axis-width-band").call(axis), From d4209bb63b2bfcba16fe924d397aab8780fc1903 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joa=CC=83o=20Sobral?= <joao.tiago.sobral@gmail.com> Date: Mon, 2 Dec 2024 16:07:45 +0000 Subject: [PATCH 13/54] =?UTF-8?q?docs=20=F0=9F=93=91:=20corrected=20var=20?= =?UTF-8?q?name?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/charts/bar/bars-stacked-state.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/charts/bar/bars-stacked-state.tsx b/app/charts/bar/bars-stacked-state.tsx index a869b715d..1fdbc5f40 100644 --- a/app/charts/bar/bars-stacked-state.tsx +++ b/app/charts/bar/bars-stacked-state.tsx @@ -328,7 +328,7 @@ const useBarsStackedState = ( const paddingXScale = useMemo(() => { // When the user can toggle between absolute and relative values, we use the - // absolute values to calculate the yScale domain, so that the yScale doesn't + // absolute values to calculate the xScale domain, so that the xScale doesn't // change when the user toggles between absolute and relative values. if (interactiveFiltersConfig?.calculation.active) { const scale = getStackedXScale(paddingData, { From 3757b4084b73e7529ffc06f428e61651e9d2bdad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joa=CC=83o=20Sobral?= <joao.tiago.sobral@gmail.com> Date: Mon, 2 Dec 2024 16:08:10 +0000 Subject: [PATCH 14/54] =?UTF-8?q?feat=20=E2=9A=A1=EF=B8=8F:=20adjusted=20x?= =?UTF-8?q?Scale?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/charts/bar/bars-stacked-state.tsx | 2 +- app/charts/bar/bars-stacked.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/charts/bar/bars-stacked-state.tsx b/app/charts/bar/bars-stacked-state.tsx index 1fdbc5f40..cf66703eb 100644 --- a/app/charts/bar/bars-stacked-state.tsx +++ b/app/charts/bar/bars-stacked-state.tsx @@ -412,7 +412,7 @@ const useBarsStackedState = ( yScale.range([0, chartHeight]); yScaleInteraction.range([0, chartHeight]); yScaleTimeRange.range([0, chartHeight]); - xScale.range([chartWidth, 0]); + xScale.range([0, chartWidth]); const isMobile = useIsMobile(); diff --git a/app/charts/bar/bars-stacked.tsx b/app/charts/bar/bars-stacked.tsx index 72a5bb3ae..e7a61b793 100644 --- a/app/charts/bar/bars-stacked.tsx +++ b/app/charts/bar/bars-stacked.tsx @@ -25,9 +25,9 @@ export const BarsStacked = () => { return { key: getRenderingKey(observation, d.key), y: yScale(getY(observation)) as number, - x: xScale(segment[1]), + x: xScale(segment[0]), height: bandwidth, - width: Math.max(0, xScale(segment[0]) - xScale(segment[1])), + width: Math.min(0, xScale(segment[0]) - xScale(segment[1])) * -1, color, }; }); From 51f46026e19a21f8a7a3ee66529a73d31cc7d308 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joa=CC=83o=20Sobral?= <joao.tiago.sobral@gmail.com> Date: Tue, 3 Dec 2024 13:13:40 +0000 Subject: [PATCH 15/54] =?UTF-8?q?feat=20=E2=9A=A1=EF=B8=8F:=20column,=20ba?= =?UTF-8?q?r,=20line,=20area=20and=20scaterplot=20added=20to=20chartConfig?= =?UTF-8?q?sPathOverrides?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/charts/index.ts | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/app/charts/index.ts b/app/charts/index.ts index ac321e5ec..882890074 100644 --- a/app/charts/index.ts +++ b/app/charts/index.ts @@ -1771,6 +1771,10 @@ const chartConfigsPathOverrides: { }; } = { column: { + bar: { + "fields.x.componentId": { path: "fields.y.componentId" }, + "fields.y.componentId": { path: "fields.x.componentId" }, + }, map: { "fields.areaLayer.componentId": { path: "fields.x.componentId" }, "fields.areaLayer.color.componentId": { path: "fields.y.componentId" }, @@ -1799,6 +1803,22 @@ const chartConfigsPathOverrides: { }, }, bar: { + column: { + "fields.x.componentId": { path: "fields.y.componentId" }, + "fields.y.componentId": { path: "fields.x.componentId" }, + }, + line: { + "fields.x.componentId": { path: "fields.y.componentId" }, + "fields.y.componentId": { path: "fields.x.componentId" }, + }, + area: { + "fields.x.componentId": { path: "fields.y.componentId" }, + "fields.y.componentId": { path: "fields.x.componentId" }, + }, + scatterplot: { + "fields.x.componentId": { path: "fields.y.componentId" }, + "fields.y.componentId": { path: "fields.x.componentId" }, + }, map: { "fields.areaLayer.componentId": { path: "fields.x.componentId" }, "fields.areaLayer.color.componentId": { path: "fields.y.componentId" }, @@ -1827,6 +1847,10 @@ const chartConfigsPathOverrides: { }, }, line: { + bar: { + "fields.x.componentId": { path: "fields.y.componentId" }, + "fields.y.componentId": { path: "fields.x.componentId" }, + }, map: { "fields.areaLayer.color.componentId": { path: "fields.y.componentId" }, }, @@ -1854,6 +1878,10 @@ const chartConfigsPathOverrides: { }, }, area: { + bar: { + "fields.x.componentId": { path: "fields.y.componentId" }, + "fields.y.componentId": { path: "fields.x.componentId" }, + }, map: { "fields.areaLayer.color.componentId": { path: "fields.y.componentId" }, }, @@ -1881,6 +1909,10 @@ const chartConfigsPathOverrides: { }, }, scatterplot: { + bar: { + "fields.x.componentId": { path: "fields.y.componentId" }, + "fields.y.componentId": { path: "fields.x.componentId" }, + }, map: { "fields.areaLayer.color.componentId": { path: "fields.y.componentId" }, }, From 5300dda43e95c2e21bd032c8e85aca84dd907a99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joa=CC=83o=20Sobral?= <joao.tiago.sobral@gmail.com> Date: Tue, 3 Dec 2024 13:50:23 +0000 Subject: [PATCH 16/54] =?UTF-8?q?fix=20=F0=9F=90=9B:=20bad=20merge=20confl?= =?UTF-8?q?ict=20resolution?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/charts/bar/bars-grouped-state-props.ts | 2 +- app/charts/bar/bars-grouped-state.tsx | 16 +--- app/charts/bar/bars-grouped.tsx | 15 ++- app/charts/bar/bars-state-props.ts | 2 +- app/charts/bar/bars-state.tsx | 14 +-- app/charts/bar/bars.tsx | 13 ++- app/charts/shared/chart-state.ts | 106 ++++++++++++++++++--- 7 files changed, 115 insertions(+), 53 deletions(-) diff --git a/app/charts/bar/bars-grouped-state-props.ts b/app/charts/bar/bars-grouped-state-props.ts index f81efdae3..7424d1f06 100644 --- a/app/charts/bar/bars-grouped-state-props.ts +++ b/app/charts/bar/bars-grouped-state-props.ts @@ -62,7 +62,7 @@ export const useBarsGroupedStateVariables = ( observations, }); const numericalXErrorVariables = useNumericalXErrorVariables(x, { - numericalXVariables, + getValue: numericalXVariables.getX, dimensions, measures, }); diff --git a/app/charts/bar/bars-grouped-state.tsx b/app/charts/bar/bars-grouped-state.tsx index 41b8ea48d..35d1d49e0 100644 --- a/app/charts/bar/bars-grouped-state.tsx +++ b/app/charts/bar/bars-grouped-state.tsx @@ -83,10 +83,8 @@ const useBarsGroupedState = ( xMeasure, getY, getMinX, - showXStandardError, - xErrorMeasure, - getXError, getXErrorRange, + getFormattedXUncertainty, segmentDimension, segmentsByAbbreviationOrLabel, getSegment, @@ -391,14 +389,6 @@ const useBarsGroupedState = ( topAnchor: !fields.segment, }); - const getError = (d: Observation) => { - if (!showXStandardError || !getXError || getXError(d) == null) { - return; - } - - return `${getXError(d)}${xErrorMeasure?.unit ?? ""}`; - }; - return { yAnchor: yAnchorRaw + (placement.y === "bottom" ? 0.5 : -0.5) * bw, xAnchor, @@ -407,7 +397,7 @@ const useBarsGroupedState = ( datum: { label: fields.segment && getSegmentAbbreviationOrLabel(datum), value: xValueFormatter(getX(datum)), - error: getError(datum), + error: getFormattedXUncertainty(datum), color: colors(getSegment(datum)) as string, }, values: sortedTooltipValues.map((td) => ({ @@ -415,7 +405,7 @@ const useBarsGroupedState = ( value: xMeasure.unit ? `${formatNumber(getX(td))} ${xMeasure.unit}` : formatNumber(getX(td)), - error: getError(td), + error: getFormattedXUncertainty(td), color: colors(getSegment(td)) as string, })), }; diff --git a/app/charts/bar/bars-grouped.tsx b/app/charts/bar/bars-grouped.tsx index 57f8cf2dd..8c55d9445 100644 --- a/app/charts/bar/bars-grouped.tsx +++ b/app/charts/bar/bars-grouped.tsx @@ -4,9 +4,8 @@ import { GroupedBarsState } from "@/charts/bar/bars-grouped-state"; import { RenderBarDatum, renderBars } from "@/charts/bar/rendering-utils"; import { useChartState } from "@/charts/shared/chart-state"; import { - filterWithoutErrors, - renderHorizontalWhisker, renderContainer, + renderHorizontalWhisker, RenderHorizontalWhiskerDatum, } from "@/charts/shared/rendering-utils"; import { useTransitionStore } from "@/stores/transition"; @@ -17,24 +16,24 @@ export const ErrorWhiskers = () => { xScale, yScaleIn, getXErrorRange, - getXError, + getXErrorPresent, yScale, getSegment, grouped, - showXStandardError, + showXUncertainty, } = useChartState() as GroupedBarsState; const { margins, width, height } = bounds; const ref = useRef<SVGGElement>(null); const enableTransition = useTransitionStore((state) => state.enable); const transitionDuration = useTransitionStore((state) => state.duration); const renderData: RenderHorizontalWhiskerDatum[] = useMemo(() => { - if (!getXErrorRange || !showXStandardError) { + if (!getXErrorRange || !showXUncertainty) { return []; } const bandwidth = yScaleIn.bandwidth(); return grouped - .filter((d) => d[1].some(filterWithoutErrors(getXError))) + .filter((d) => d[1].some(getXErrorPresent)) .flatMap(([segment, observations]) => observations.map((d) => { const y0 = yScaleIn(getSegment(d)) as number; @@ -53,9 +52,9 @@ export const ErrorWhiskers = () => { }, [ getSegment, getXErrorRange, - getXError, + getXErrorPresent, grouped, - showXStandardError, + showXUncertainty, xScale, yScaleIn, yScale, diff --git a/app/charts/bar/bars-state-props.ts b/app/charts/bar/bars-state-props.ts index c9fb5344a..e4f7d2490 100644 --- a/app/charts/bar/bars-state-props.ts +++ b/app/charts/bar/bars-state-props.ts @@ -57,7 +57,7 @@ export const useBarsStateVariables = ( observations, }); const numericalXErrorVariables = useNumericalXErrorVariables(x, { - numericalXVariables, + getValue: numericalXVariables.getX, dimensions, measures, }); diff --git a/app/charts/bar/bars-state.tsx b/app/charts/bar/bars-state.tsx index 54c73ef4a..4ce268fa7 100644 --- a/app/charts/bar/bars-state.tsx +++ b/app/charts/bar/bars-state.tsx @@ -75,10 +75,8 @@ const useBarsState = ( xMeasure, getY, getMinX, - showXStandardError, - xErrorMeasure, - getXError, getXErrorRange, + getFormattedXUncertainty, } = variables; const { chartData, scalesData, timeRangeData, paddingData, allData } = data; const { fields, interactiveFiltersConfig } = chartConfig; @@ -244,14 +242,6 @@ const useBarsState = ( xMeasure.unit ); - const getError = (d: Observation) => { - if (!showXStandardError || !getXError || getXError(d) === null) { - return; - } - - return `${getXError(d)}${xErrorMeasure?.unit ?? ""}`; - }; - const x = getX(d); return { @@ -262,7 +252,7 @@ const useBarsState = ( datum: { label: undefined, value: x !== null && isNaN(x) ? "-" : `${xValueFormatter(getX(d))}`, - error: getError(d), + error: getFormattedXUncertainty(d), color: "", }, values: undefined, diff --git a/app/charts/bar/bars.tsx b/app/charts/bar/bars.tsx index 55e3aa914..39bb341e9 100644 --- a/app/charts/bar/bars.tsx +++ b/app/charts/bar/bars.tsx @@ -6,7 +6,6 @@ import { RenderBarDatum, renderBars } from "@/charts/bar/rendering-utils"; import { useChartState } from "@/charts/shared/chart-state"; import { RenderHorizontalWhiskerDatum, - filterWithoutErrors, renderContainer, renderHorizontalWhisker, } from "@/charts/shared/rendering-utils"; @@ -16,12 +15,12 @@ import { useTheme } from "@/themes"; export const ErrorWhiskers = () => { const { getY, - getXError, + getXErrorPresent, getXErrorRange, chartData, yScale, xScale, - showXStandardError, + showXUncertainty, bounds, } = useChartState() as BarsState; const { margins, width, height } = bounds; @@ -29,12 +28,12 @@ export const ErrorWhiskers = () => { const enableTransition = useTransitionStore((state) => state.enable); const transitionDuration = useTransitionStore((state) => state.duration); const renderData: RenderHorizontalWhiskerDatum[] = useMemo(() => { - if (!getXErrorRange || !showXStandardError) { + if (!getXErrorRange || !showXUncertainty) { return []; } const bandwidth = yScale.bandwidth(); - return chartData.filter(filterWithoutErrors(getXError)).map((d, i) => { + return chartData.filter(getXErrorPresent).map((d, i) => { const y0 = yScale(getY(d)) as number; const barWidth = Math.min(bandwidth, 15); const [x1, x2] = getXErrorRange(d); @@ -50,9 +49,9 @@ export const ErrorWhiskers = () => { }, [ chartData, getY, - getXError, + getXErrorPresent, getXErrorRange, - showXStandardError, + showXUncertainty, xScale, yScale, width, diff --git a/app/charts/shared/chart-state.ts b/app/charts/shared/chart-state.ts index 5c79cd1bb..94757c7ea 100644 --- a/app/charts/shared/chart-state.ts +++ b/app/charts/shared/chart-state.ts @@ -410,10 +410,10 @@ export type NumericalYErrorVariables = { }; export type NumericalXErrorVariables = { - showXStandardError: boolean; - xErrorMeasure: Component | undefined; - getXError: ((d: Observation) => ObservationValue) | null; + showXUncertainty: boolean; + getXErrorPresent: (d: Observation) => boolean; getXErrorRange: null | ((d: Observation) => [number, number]); + getFormattedXUncertainty: (d: Observation) => string | undefined; }; export const useNumericalYErrorVariables = ( @@ -531,28 +531,112 @@ export const useNumericalYErrorVariables = ( export const useNumericalXErrorVariables = ( x: GenericField, { - numericalXVariables, + getValue, dimensions, measures, }: { - numericalXVariables: NumericalXVariables; + getValue: NumericalXVariables["getX"]; dimensions: Dimension[]; measures: Measure[]; } ): NumericalXErrorVariables => { const showXStandardError = get(x, ["showStandardError"], true); - const xErrorMeasure = useErrorMeasure(x.componentId, { + const xStandardErrorMeasure = useErrorMeasure(x.componentId, { + dimensions, + measures, + type: RelatedDimensionType.StandardError, + }); + const getXStandardError = useErrorVariable(xStandardErrorMeasure); + + const showXConfidenceInterval = get(x, ["showConfidenceInterval"], true); + const xConfidenceIntervalUpperMeasure = useErrorMeasure(x.componentId, { dimensions, measures, + type: RelatedDimensionType.ConfidenceUpperBound, }); - const getXErrorRange = useErrorRange(xErrorMeasure, numericalXVariables.getX); - const getXError = useErrorVariable(xErrorMeasure); + const getXConfidenceIntervalUpper = useErrorVariable( + xConfidenceIntervalUpperMeasure + ); + const xConfidenceIntervalLowerMeasure = useErrorMeasure(x.componentId, { + dimensions, + measures, + type: RelatedDimensionType.ConfidenceLowerBound, + }); + const getXConfidenceIntervalLower = useErrorVariable( + xConfidenceIntervalLowerMeasure + ); + + const getXErrorPresent = useCallback( + (d: Observation) => { + return ( + (showXStandardError && getXStandardError?.(d) !== null) || + (showXConfidenceInterval && + getXConfidenceIntervalUpper?.(d) !== null && + getXConfidenceIntervalLower?.(d) !== null) + ); + }, + [ + showXStandardError, + getXStandardError, + showXConfidenceInterval, + getXConfidenceIntervalUpper, + getXConfidenceIntervalLower, + ] + ); + const getXErrorRange = useErrorRange( + showXStandardError && xStandardErrorMeasure + ? xStandardErrorMeasure + : xConfidenceIntervalUpperMeasure, + showXStandardError && xStandardErrorMeasure + ? xStandardErrorMeasure + : xConfidenceIntervalLowerMeasure, + getValue + ); + const getFormattedXUncertainty = useCallback( + (d: Observation) => { + if ( + showXStandardError && + getXStandardError && + getXStandardError(d) !== null + ) { + const sd = getXStandardError(d); + const unit = xStandardErrorMeasure?.unit ?? ""; + return ` ± ${sd}${unit}`; + } + + if ( + showXConfidenceInterval && + getXConfidenceIntervalUpper && + getXConfidenceIntervalLower && + getXConfidenceIntervalUpper(d) !== null && + getXConfidenceIntervalLower(d) !== null + ) { + const cil = getXConfidenceIntervalLower(d); + const ciu = getXConfidenceIntervalUpper(d); + const unit = xConfidenceIntervalUpperMeasure?.unit ?? ""; + return `, [-${cil}${unit}, +${ciu}${unit}]`; + } + }, + [ + showXStandardError, + getXStandardError, + showXConfidenceInterval, + getXConfidenceIntervalUpper, + getXConfidenceIntervalLower, + xStandardErrorMeasure?.unit, + xConfidenceIntervalUpperMeasure?.unit, + ] + ); return { - showXStandardError, - xErrorMeasure, - getXError, + showXUncertainty: + (showXStandardError && !!xStandardErrorMeasure) || + (showXConfidenceInterval && + !!xConfidenceIntervalUpperMeasure && + !!xConfidenceIntervalLowerMeasure), + getXErrorPresent, getXErrorRange, + getFormattedXUncertainty, }; }; From 535f4e6b8a28d246edcf85f4d49f4f1441a36b2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joa=CC=83o=20Sobral?= <joao.tiago.sobral@gmail.com> Date: Tue, 3 Dec 2024 13:58:51 +0000 Subject: [PATCH 17/54] =?UTF-8?q?fix=20=F0=9F=90=9B:=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/charts/index.spec.ts | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/app/charts/index.spec.ts b/app/charts/index.spec.ts index cc422c430..289421ce1 100644 --- a/app/charts/index.spec.ts +++ b/app/charts/index.spec.ts @@ -182,7 +182,14 @@ describe("initial config", () => { describe("enabled chart types", () => { it("should allow appropriate chart types based on available dimensions", () => { - const expectedChartTypes = ["area", "column", "line", "pie", "table"]; + const expectedChartTypes = [ + "area", + "bar", + "column", + "line", + "pie", + "table", + ]; const { enabledChartTypes, possibleChartTypesDict } = getEnabledChartTypes({ dimensions: bathingWaterData.data.dataCubeByIri .dimensions as any as Dimension[], @@ -212,14 +219,20 @@ describe("enabled chart types", () => { ).toBe(true); }); - it("should only allow column, map, pie and table if only geo dimensions are available", () => { + it("should only allow column, bar, map, pie and table if only geo dimensions are available", () => { const { enabledChartTypes, possibleChartTypesDict } = getEnabledChartTypes({ dimensions: [{ __typename: "GeoShapesDimension" }] as any, measures: [{ __typename: "NumericalMeasure" }] as any, cubeCount: 1, }); - expect(enabledChartTypes.sort()).toEqual(["column", "map", "pie", "table"]); + expect(enabledChartTypes.sort()).toEqual([ + "bar", + "column", + "map", + "pie", + "table", + ]); expect(possibleChartTypesDict["line"].message).toBeDefined(); }); From 51114bd75a2b17051842142e492a4b3cda87caf6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joa=CC=83o=20Sobral?= <joao.tiago.sobral@gmail.com> Date: Tue, 3 Dec 2024 14:09:33 +0000 Subject: [PATCH 18/54] =?UTF-8?q?fix=20=F0=9F=90=9B:=20PR=20reviews?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/charts/shared/chart-state.ts | 3 +-- app/config-types.ts | 6 ++++++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/app/charts/shared/chart-state.ts b/app/charts/shared/chart-state.ts index 94757c7ea..74a3949d8 100644 --- a/app/charts/shared/chart-state.ts +++ b/app/charts/shared/chart-state.ts @@ -356,7 +356,7 @@ export type NumericalYVariables = { export const useNumericalYVariables = ( // Combo charts have their own logic for y scales. - chartType: "area" | "column" | "bar" | "line" | "pie" | "scatterplot", + chartType: "area" | "column" | "line" | "pie" | "scatterplot", y: GenericField, { measuresById }: { measuresById: MeasuresById } ): NumericalYVariables => { @@ -378,7 +378,6 @@ export const useNumericalYVariables = ( switch (chartType) { case "area": case "column": - case "bar": case "pie": return Math.min(0, min(data, _getY) ?? 0); case "line": diff --git a/app/config-types.ts b/app/config-types.ts index 204717495..8153cf130 100644 --- a/app/config-types.ts +++ b/app/config-types.ts @@ -1008,6 +1008,7 @@ type ColumnAdjusters = BaseAdjusters<ColumnConfig> & { y: { componentId: FieldAdjuster<ColumnConfig, string> }; segment: FieldAdjuster< ColumnConfig, + | BarSegmentField | LineSegmentField | AreaSegmentField | ScatterPlotSegmentField @@ -1042,6 +1043,7 @@ type LineAdjusters = BaseAdjusters<LineConfig> & { segment: FieldAdjuster< LineConfig, | ColumnSegmentField + | BarSegmentField | AreaSegmentField | ScatterPlotSegmentField | PieSegmentField @@ -1057,6 +1059,7 @@ type AreaAdjusters = BaseAdjusters<AreaConfig> & { segment: FieldAdjuster< AreaConfig, | ColumnSegmentField + | BarSegmentField | LineSegmentField | ScatterPlotSegmentField | PieSegmentField @@ -1071,6 +1074,7 @@ type ScatterPlotAdjusters = BaseAdjusters<ScatterPlotConfig> & { segment: FieldAdjuster< ScatterPlotConfig, | ColumnSegmentField + | BarSegmentField | LineSegmentField | AreaSegmentField | PieSegmentField @@ -1086,6 +1090,7 @@ type PieAdjusters = BaseAdjusters<PieConfig> & { segment: FieldAdjuster< PieConfig, | ColumnSegmentField + | BarSegmentField | LineSegmentField | AreaSegmentField | ScatterPlotSegmentField @@ -1100,6 +1105,7 @@ type TableAdjusters = { fields: FieldAdjuster< TableConfig, | ColumnSegmentField + | BarSegmentField | LineSegmentField | AreaSegmentField | ScatterPlotSegmentField From ebf6dcfa23839cd494271835dbca9da7311f10e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joa=CC=83o=20Sobral?= <joao.tiago.sobral@gmail.com> Date: Tue, 3 Dec 2024 14:42:02 +0000 Subject: [PATCH 19/54] =?UTF-8?q?feat=20=E2=9A=A1=EF=B8=8F:=20add=20map=20?= =?UTF-8?q?and=20pie=20configs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/charts/index.ts | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/app/charts/index.ts b/app/charts/index.ts index 882890074..19c37d9e8 100644 --- a/app/charts/index.ts +++ b/app/charts/index.ts @@ -1819,9 +1819,13 @@ const chartConfigsPathOverrides: { "fields.x.componentId": { path: "fields.y.componentId" }, "fields.y.componentId": { path: "fields.x.componentId" }, }, + pie: { + "fields.segment.componentId": { path: "fields.y.componentId" }, + "fields.y.componentId": { path: "fields.x.componentId" }, + }, map: { - "fields.areaLayer.componentId": { path: "fields.x.componentId" }, - "fields.areaLayer.color.componentId": { path: "fields.y.componentId" }, + "fields.areaLayer.componentId": { path: "fields.y.componentId" }, + "fields.areaLayer.color.componentId": { path: "fields.x.componentId" }, }, table: { fields: { path: "fields.segment" }, @@ -1940,6 +1944,10 @@ const chartConfigsPathOverrides: { }, }, pie: { + bar: { + "fields.segment.componentId": { path: "fields.y.componentId" }, + "fields.y.componentId": { path: "fields.x.componentId" }, + }, map: { "fields.areaLayer.componentId": { path: "fields.x.componentId" }, "fields.areaLayer.color.componentId": { path: "fields.y.componentId" }, @@ -1989,6 +1997,10 @@ const chartConfigsPathOverrides: { "fields.x.componentId": { path: "fields.areaLayer.componentId" }, "fields.y.componentId": { path: "fields.areaLayer.color.componentId" }, }, + bar: { + "fields.x.componentId": { path: "fields.areaLayer.color.componentId" }, + "fields.y.componentId": { path: "fields.areaLayer.componentId" }, + }, line: { "fields.y.componentId": { path: "fields.areaLayer.color.componentId" }, }, From 4e6bf4fcaa0305efa1d527347e91f293abf5621d Mon Sep 17 00:00:00 2001 From: Bartosz Prusinowski <bartosz@interactivethings.com> Date: Tue, 3 Dec 2024 15:58:32 +0100 Subject: [PATCH 20/54] fix: Bars enter animation --- app/charts/bar/rendering-utils.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/charts/bar/rendering-utils.ts b/app/charts/bar/rendering-utils.ts index 1e7cbb104..ce622bf47 100644 --- a/app/charts/bar/rendering-utils.ts +++ b/app/charts/bar/rendering-utils.ts @@ -34,8 +34,8 @@ export const renderBars = ( .attr("data-index", (_, i) => i) .attr("y", (d) => d.y) .attr("x", x0) - .attr("width", (d) => d.width) - .attr("height", 0) + .attr("width", 0) + .attr("height", (d) => d.height) .attr("fill", (d) => d.color) .call((enter) => maybeTransition(enter, { From dda2553341ceee3f896f156b0dfdc879b905d59a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joa=CC=83o=20Sobral?= <joao.tiago.sobral@gmail.com> Date: Tue, 3 Dec 2024 15:05:53 +0000 Subject: [PATCH 21/54] =?UTF-8?q?fix=20=F0=9F=90=9B:=20bar=20<->=20pie=20a?= =?UTF-8?q?djuster?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/charts/index.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/charts/index.ts b/app/charts/index.ts index 19c37d9e8..df806b230 100644 --- a/app/charts/index.ts +++ b/app/charts/index.ts @@ -1821,6 +1821,7 @@ const chartConfigsPathOverrides: { }, pie: { "fields.segment.componentId": { path: "fields.y.componentId" }, + "fields.x.componentId": { path: "fields.y.componentId" }, "fields.y.componentId": { path: "fields.x.componentId" }, }, map: { @@ -1946,6 +1947,7 @@ const chartConfigsPathOverrides: { pie: { bar: { "fields.segment.componentId": { path: "fields.y.componentId" }, + "fields.x.componentId": { path: "fields.y.componentId" }, "fields.y.componentId": { path: "fields.x.componentId" }, }, map: { From 65ed293a4314362215f732788e6fa3d6128e2215 Mon Sep 17 00:00:00 2001 From: Bartosz Prusinowski <bartosz@interactivethings.com> Date: Tue, 3 Dec 2024 16:18:26 +0100 Subject: [PATCH 22/54] fix: Bar -> pie adjuster (pie doesn't have x field, segment is carried automatically) --- app/charts/index.ts | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/app/charts/index.ts b/app/charts/index.ts index df806b230..c5b98c0ef 100644 --- a/app/charts/index.ts +++ b/app/charts/index.ts @@ -1026,8 +1026,7 @@ const chartConfigsAdjusters: ChartConfigsAdjusters = { }, fields: { x: { - componentId: ({ oldValue, newChartConfig, dimensions, measures }) => { - measures[0]; + componentId: ({ oldValue, newChartConfig, dimensions }) => { // When switching from a scatterplot, x is a measure. if (dimensions.find((d) => d.id === oldValue)) { return produce(newChartConfig, (draft) => { @@ -1039,8 +1038,7 @@ const chartConfigsAdjusters: ChartConfigsAdjusters = { }, }, y: { - componentId: ({ oldValue, newChartConfig, dimensions }) => { - dimensions[0]; + componentId: ({ oldValue, newChartConfig }) => { return produce(newChartConfig, (draft) => { draft.fields.y.componentId = oldValue; }); @@ -1054,8 +1052,8 @@ const chartConfigsAdjusters: ChartConfigsAdjusters = { measures, }) => { let newSegment: ColumnSegmentField | undefined; - const yMeasure = measures.find( - (d) => d.id === newChartConfig.fields.y.componentId + const xMeasure = measures.find( + (d) => d.id === newChartConfig.fields.x.componentId ); // When switching from a table chart, a whole fields object is passed as oldValue. @@ -1070,14 +1068,14 @@ const chartConfigsAdjusters: ChartConfigsAdjusters = { newSegment = { ...tableSegment, sorting: DEFAULT_SORTING, - type: disableStacked(yMeasure) ? "grouped" : "stacked", + type: disableStacked(xMeasure) ? "grouped" : "stacked", }; } // Otherwise we are dealing with a segment field. We shouldn't take // the segment from oldValue if the component has already been used as - // x axis. + // y axis. } else if ( - newChartConfig.fields.x.componentId !== oldValue.componentId + newChartConfig.fields.y.componentId !== oldValue.componentId ) { const oldSegment = oldValue as Exclude<typeof oldValue, TableFields>; newSegment = { @@ -1089,7 +1087,7 @@ const chartConfigsAdjusters: ChartConfigsAdjusters = { acceptedValues: COLUMN_SEGMENT_SORTING.map((d) => d.sortingType), defaultValue: "byTotalSize", }), - type: disableStacked(yMeasure) ? "grouped" : "stacked", + type: disableStacked(xMeasure) ? "grouped" : "stacked", }; } @@ -1820,8 +1818,6 @@ const chartConfigsPathOverrides: { "fields.y.componentId": { path: "fields.x.componentId" }, }, pie: { - "fields.segment.componentId": { path: "fields.y.componentId" }, - "fields.x.componentId": { path: "fields.y.componentId" }, "fields.y.componentId": { path: "fields.x.componentId" }, }, map: { From 0702e198681a5e7b4bef1d8971f3277db0a0deaf Mon Sep 17 00:00:00 2001 From: Bartosz Prusinowski <bartosz@interactivethings.com> Date: Tue, 3 Dec 2024 16:25:56 +0100 Subject: [PATCH 23/54] fix: Bar -> combo chart adjusters --- app/charts/index.ts | 33 +++++++++++++++++++++++---------- 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/app/charts/index.ts b/app/charts/index.ts index c5b98c0ef..2415a5458 100644 --- a/app/charts/index.ts +++ b/app/charts/index.ts @@ -1026,9 +1026,8 @@ const chartConfigsAdjusters: ChartConfigsAdjusters = { }, fields: { x: { - componentId: ({ oldValue, newChartConfig, dimensions }) => { - // When switching from a scatterplot, x is a measure. - if (dimensions.find((d) => d.id === oldValue)) { + componentId: ({ oldValue, newChartConfig, measures }) => { + if (measures.find((d) => d.id === oldValue)) { return produce(newChartConfig, (draft) => { draft.fields.x.componentId = oldValue; }); @@ -1038,10 +1037,15 @@ const chartConfigsAdjusters: ChartConfigsAdjusters = { }, }, y: { - componentId: ({ oldValue, newChartConfig }) => { - return produce(newChartConfig, (draft) => { - draft.fields.y.componentId = oldValue; - }); + componentId: ({ oldValue, newChartConfig, dimensions }) => { + // For most charts, y is a measure. + if (dimensions.find((d) => d.id === oldValue)) { + return produce(newChartConfig, (draft) => { + draft.fields.y.componentId = oldValue; + }); + } + + return newChartConfig; }, }, segment: ({ @@ -1829,16 +1833,16 @@ const chartConfigsPathOverrides: { }, comboLineSingle: { "fields.y.componentIds": { - path: "fields.y.componentId", + path: "fields.x.componentId", oldValue: (d: ComboLineSingleFields["y"]["componentIds"]) => d[0], }, }, comboLineDual: { - "fields.y.leftAxisComponentId": { path: "fields.y.componentId" }, + "fields.y.leftAxisComponentId": { path: "fields.x.componentId" }, }, comboLineColumn: { "fields.y": { - path: "fields.y.componentId", + path: "fields.x.componentId", oldValue: (d: ComboLineColumnFields["y"]) => { return d.lineAxisOrientation === "left" ? d.lineComponentId @@ -2038,6 +2042,9 @@ const chartConfigsPathOverrides: { column: { "fields.y.componentId": { path: "fields.y.componentIds" }, }, + bar: { + "fields.x.componentId": { path: "fields.y.componentIds" }, + }, line: { "fields.y.componentId": { path: "fields.y.componentIds" }, }, @@ -2068,6 +2075,9 @@ const chartConfigsPathOverrides: { column: { "fields.y": { path: "fields.y" }, }, + bar: { + "fields.x": { path: "fields.y" }, + }, line: { "fields.y": { path: "fields.y" }, }, @@ -2094,6 +2104,9 @@ const chartConfigsPathOverrides: { column: { "fields.y": { path: "fields.y" }, }, + bar: { + "fields.x": { path: "fields.y" }, + }, line: { "fields.y": { path: "fields.y" }, }, From 1321900246f596e5f2b0b8c571ea7e9954c1a114 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joa=CC=83o=20Sobral?= <joao.tiago.sobral@gmail.com> Date: Tue, 3 Dec 2024 16:10:51 +0000 Subject: [PATCH 24/54] =?UTF-8?q?fix=20=F0=9F=90=9B:=20y=20label=20cutoff?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/charts/shared/axis-height-band.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/charts/shared/axis-height-band.tsx b/app/charts/shared/axis-height-band.tsx index d3ed87de0..c079c05eb 100644 --- a/app/charts/shared/axis-height-band.tsx +++ b/app/charts/shared/axis-height-band.tsx @@ -55,7 +55,7 @@ export const AxisHeightBand = () => { hasNegativeValues ? gridColor : domainColor ); g.selectAll(".tick text") - .attr("x", 0) + .attr("x", -fontSize) .attr("font-size", fontSize) .attr("font-family", fontFamily) .attr("fill", labelColor) From eadab83e53554ae07d9acd24dfc75922309f51c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joa=CC=83o=20Sobral?= <joao.tiago.sobral@gmail.com> Date: Thu, 5 Dec 2024 17:46:19 +0000 Subject: [PATCH 25/54] =?UTF-8?q?refactor=20=E2=99=BB=EF=B8=8F:=20ditch=20?= =?UTF-8?q?tooltip=20inverted?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/charts/area/areas-state.tsx | 2 +- app/charts/bar/bars-grouped-state.tsx | 8 +-- app/charts/bar/bars-stacked-state.tsx | 8 +-- app/charts/bar/bars-state.tsx | 8 +-- app/charts/column/columns-grouped-state.tsx | 2 +- app/charts/column/columns-stacked-state.tsx | 2 +- app/charts/column/columns-state.tsx | 2 +- app/charts/combo/combo-line-column-state.tsx | 2 +- app/charts/combo/combo-line-dual-state.tsx | 6 +- app/charts/combo/combo-line-single-state.tsx | 2 +- app/charts/line/lines-state.tsx | 2 +- app/charts/pie/pie-state.tsx | 2 +- app/charts/scatterplot/scatterplot-state.tsx | 2 +- app/charts/shared/interaction/ruler.tsx | 4 +- app/charts/shared/interaction/tooltip.tsx | 68 ++------------------ 15 files changed, 31 insertions(+), 89 deletions(-) diff --git a/app/charts/area/areas-state.tsx b/app/charts/area/areas-state.tsx index 13b7d2154..f4fc35d7a 100644 --- a/app/charts/area/areas-state.tsx +++ b/app/charts/area/areas-state.tsx @@ -394,7 +394,7 @@ const useAreasState = ( xAnchor, yAnchor, placement, - xValue: timeFormatUnit(getX(datum), xDimension.timeUnit), + value: timeFormatUnit(getX(datum), xDimension.timeUnit), datum: { label: fields.segment && getSegmentAbbreviationOrLabel(datum), value: yValueFormatter(getY(datum), getIdentityY(datum)), diff --git a/app/charts/bar/bars-grouped-state.tsx b/app/charts/bar/bars-grouped-state.tsx index 35d1d49e0..e5dbec0ff 100644 --- a/app/charts/bar/bars-grouped-state.tsx +++ b/app/charts/bar/bars-grouped-state.tsx @@ -32,7 +32,7 @@ import { CommonChartState, InteractiveYTimeRangeState, } from "@/charts/shared/chart-state"; -import { TooltipInfoInverted } from "@/charts/shared/interaction/tooltip"; +import { TooltipInfo } from "@/charts/shared/interaction/tooltip"; import { getCenteredTooltipPlacement, MOBILE_TOOLTIP_PLACEMENT, @@ -65,7 +65,7 @@ export type GroupedBarsState = CommonChartState & colors: ScaleOrdinal<string, string>; getColorLabel: (segment: string) => string; grouped: [string, Observation[]][]; - getAnnotationInfo: (d: Observation) => TooltipInfoInverted; + getAnnotationInfo: (d: Observation) => TooltipInfo; }; const useBarsGroupedState = ( @@ -357,7 +357,7 @@ const useBarsGroupedState = ( const isMobile = useIsMobile(); // Tooltip - const getAnnotationInfo = (datum: Observation): TooltipInfoInverted => { + const getAnnotationInfo = (datum: Observation): TooltipInfo => { const bw = yScale.bandwidth(); const y = getY(datum); @@ -393,7 +393,7 @@ const useBarsGroupedState = ( yAnchor: yAnchorRaw + (placement.y === "bottom" ? 0.5 : -0.5) * bw, xAnchor, placement, - yValue: getYAbbreviationOrLabel(datum), + value: getYAbbreviationOrLabel(datum), datum: { label: fields.segment && getSegmentAbbreviationOrLabel(datum), value: xValueFormatter(getX(datum)), diff --git a/app/charts/bar/bars-stacked-state.tsx b/app/charts/bar/bars-stacked-state.tsx index cf66703eb..5ea229cb9 100644 --- a/app/charts/bar/bars-stacked-state.tsx +++ b/app/charts/bar/bars-stacked-state.tsx @@ -40,7 +40,7 @@ import { CommonChartState, InteractiveYTimeRangeState, } from "@/charts/shared/chart-state"; -import { TooltipInfoInverted } from "@/charts/shared/interaction/tooltip"; +import { TooltipInfo } from "@/charts/shared/interaction/tooltip"; import { getCenteredTooltipPlacement, MOBILE_TOOLTIP_PLACEMENT, @@ -81,7 +81,7 @@ export type StackedBarsState = CommonChartState & getAnnotationInfo: ( d: Observation, orderedSegments: string[] - ) => TooltipInfoInverted; + ) => TooltipInfo; }; const useBarsStackedState = ( @@ -418,7 +418,7 @@ const useBarsStackedState = ( // Tooltips const getAnnotationInfo = useCallback( - (datum: Observation): TooltipInfoInverted => { + (datum: Observation): TooltipInfo => { const bw = yScale.bandwidth(); const y = getY(datum); @@ -455,7 +455,7 @@ const useBarsStackedState = ( yAnchor: yAnchorRaw + (placement.y === "top" ? 0.5 : -0.5) * bw, xAnchor, placement, - yValue: getYAbbreviationOrLabel(datum), + value: getYAbbreviationOrLabel(datum), datum: { label: fields.segment && getSegmentAbbreviationOrLabel(datum), value: xValueFormatter(getX(datum), getIdentityX(datum)), diff --git a/app/charts/bar/bars-state.tsx b/app/charts/bar/bars-state.tsx index 4ce268fa7..b158cd749 100644 --- a/app/charts/bar/bars-state.tsx +++ b/app/charts/bar/bars-state.tsx @@ -25,7 +25,7 @@ import { CommonChartState, InteractiveYTimeRangeState, } from "@/charts/shared/chart-state"; -import { TooltipInfoInverted } from "@/charts/shared/interaction/tooltip"; +import { TooltipInfo } from "@/charts/shared/interaction/tooltip"; import { getCenteredTooltipPlacement, MOBILE_TOOLTIP_PLACEMENT, @@ -56,7 +56,7 @@ export type BarsState = CommonChartState & yScaleInteraction: ScaleBand<string>; yScale: ScaleBand<string>; minY: string; - getAnnotationInfo: (d: Observation) => TooltipInfoInverted; + getAnnotationInfo: (d: Observation) => TooltipInfo; }; const useBarsState = ( @@ -220,7 +220,7 @@ const useBarsState = ( const isMobile = useIsMobile(); // Tooltip - const getAnnotationInfo = (d: Observation): TooltipInfoInverted => { + const getAnnotationInfo = (d: Observation): TooltipInfo => { const yAnchor = (yScale(getY(d)) as number) + yScale.bandwidth() * 0.5; const xAnchor = isMobile ? chartHeight @@ -248,7 +248,7 @@ const useBarsState = ( xAnchor, yAnchor, placement, - yValue: yTimeUnit ? timeFormatUnit(yLabel, yTimeUnit) : yLabel, + value: yTimeUnit ? timeFormatUnit(yLabel, yTimeUnit) : yLabel, datum: { label: undefined, value: x !== null && isNaN(x) ? "-" : `${xValueFormatter(getX(d))}`, diff --git a/app/charts/column/columns-grouped-state.tsx b/app/charts/column/columns-grouped-state.tsx index cab70d69d..736281268 100644 --- a/app/charts/column/columns-grouped-state.tsx +++ b/app/charts/column/columns-grouped-state.tsx @@ -400,7 +400,7 @@ const useColumnsGroupedState = ( xAnchor: xAnchorRaw + (placement.x === "right" ? 0.5 : -0.5) * bw, yAnchor, placement, - xValue: getXAbbreviationOrLabel(datum), + value: getXAbbreviationOrLabel(datum), datum: { label: fields.segment && getSegmentAbbreviationOrLabel(datum), value: yValueFormatter(getY(datum)), diff --git a/app/charts/column/columns-stacked-state.tsx b/app/charts/column/columns-stacked-state.tsx index bc88957c2..16daeb5a1 100644 --- a/app/charts/column/columns-stacked-state.tsx +++ b/app/charts/column/columns-stacked-state.tsx @@ -460,7 +460,7 @@ const useColumnsStackedState = ( xAnchor: xAnchorRaw + (placement.x === "right" ? 0.5 : -0.5) * bw, yAnchor, placement, - xValue: getXAbbreviationOrLabel(datum), + value: getXAbbreviationOrLabel(datum), datum: { label: fields.segment && getSegmentAbbreviationOrLabel(datum), value: yValueFormatter(getY(datum), getIdentityY(datum)), diff --git a/app/charts/column/columns-state.tsx b/app/charts/column/columns-state.tsx index 97bc98aeb..a6efece3d 100644 --- a/app/charts/column/columns-state.tsx +++ b/app/charts/column/columns-state.tsx @@ -249,7 +249,7 @@ const useColumnsState = ( xAnchor, yAnchor, placement, - xValue: xTimeUnit ? timeFormatUnit(xLabel, xTimeUnit) : xLabel, + value: xTimeUnit ? timeFormatUnit(xLabel, xTimeUnit) : xLabel, datum: { label: undefined, value: y !== null && isNaN(y) ? "-" : `${yValueFormatter(getY(d))}`, diff --git a/app/charts/combo/combo-line-column-state.tsx b/app/charts/combo/combo-line-column-state.tsx index ed0a6651e..a47804a3d 100644 --- a/app/charts/combo/combo-line-column-state.tsx +++ b/app/charts/combo/combo-line-column-state.tsx @@ -204,7 +204,7 @@ const useComboLineColumnState = ( datum: { label: "", value: "0", color: schemeCategory10[0] }, xAnchor: xScaled, yAnchor, - xValue: timeFormatUnit(x, variables.xTimeUnit as TimeUnit), + value: timeFormatUnit(x, variables.xTimeUnit as TimeUnit), placement, values, } as TooltipInfo; diff --git a/app/charts/combo/combo-line-dual-state.tsx b/app/charts/combo/combo-line-dual-state.tsx index 4a84dd264..15e0ed245 100644 --- a/app/charts/combo/combo-line-dual-state.tsx +++ b/app/charts/combo/combo-line-dual-state.tsx @@ -159,12 +159,12 @@ const useComboLineDualState = ( bottom, top: topMarginAxisTitleAdjustment, }); - + const bounds = useChartBounds(width, margins, height, { leftLabel: variables.y.left.label, rightLabel: variables.y.right.label, }); - + const { chartWidth, chartHeight } = bounds; const xScales = [xScale, xScaleTimeRange]; const yScales = [yScale, yScaleLeft, yScaleRight]; @@ -210,7 +210,7 @@ const useComboLineDualState = ( datum: { label: "", value: "0", color: schemeCategory10[0] }, xAnchor: xScaled, yAnchor: yAnchor, - xValue: timeFormatUnit(x, xDimension.timeUnit), + value: timeFormatUnit(x, xDimension.timeUnit), placement, values, } as TooltipInfo; diff --git a/app/charts/combo/combo-line-single-state.tsx b/app/charts/combo/combo-line-single-state.tsx index 91837aa7d..11518fa00 100644 --- a/app/charts/combo/combo-line-single-state.tsx +++ b/app/charts/combo/combo-line-single-state.tsx @@ -153,7 +153,7 @@ const useComboLineSingleState = ( datum: { label: "", value: "0", color: schemeCategory10[0] }, xAnchor: xScaled, yAnchor: yAnchor, - xValue: timeFormatUnit(x, xDimension.timeUnit), + value: timeFormatUnit(x, xDimension.timeUnit), placement, values, } as TooltipInfo; diff --git a/app/charts/line/lines-state.tsx b/app/charts/line/lines-state.tsx index 24f219675..312a38563 100644 --- a/app/charts/line/lines-state.tsx +++ b/app/charts/line/lines-state.tsx @@ -281,7 +281,7 @@ const useLinesState = ( xAnchor, yAnchor, placement, - xValue: timeFormatUnit(getX(datum), xDimension.timeUnit), + value: timeFormatUnit(getX(datum), xDimension.timeUnit), datum: { label: fields.segment && getSegmentAbbreviationOrLabel(datum), value: yValueFormatter(getY(datum)), diff --git a/app/charts/pie/pie-state.tsx b/app/charts/pie/pie-state.tsx index 892ae3dfe..2e6b7b8de 100644 --- a/app/charts/pie/pie-state.tsx +++ b/app/charts/pie/pie-state.tsx @@ -240,7 +240,7 @@ const usePieState = ( xAnchor, yAnchor, placement: { x: xPlacement, y: yPlacement }, - xValue: getSegmentAbbreviationOrLabel(datum), + value: getSegmentAbbreviationOrLabel(datum), datum: { value: valueFormatter(getY(datum)), color: colors(getSegment(datum)) as string, diff --git a/app/charts/scatterplot/scatterplot-state.tsx b/app/charts/scatterplot/scatterplot-state.tsx index 6459b6b74..5eeebb8b2 100644 --- a/app/charts/scatterplot/scatterplot-state.tsx +++ b/app/charts/scatterplot/scatterplot-state.tsx @@ -211,7 +211,7 @@ const useScatterplotState = ( xAnchor, yAnchor, placement, - xValue: formatNumber(getX(datum)), + value: formatNumber(getX(datum)), tooltipContent: ( <TooltipScatterplot firstLine={fields.segment && getSegmentAbbreviationOrLabel(datum)} diff --git a/app/charts/shared/interaction/ruler.tsx b/app/charts/shared/interaction/ruler.tsx index 0d669ecc0..5e440f0f5 100644 --- a/app/charts/shared/interaction/ruler.tsx +++ b/app/charts/shared/interaction/ruler.tsx @@ -39,12 +39,12 @@ const RulerInner = (props: RulerInnerProps) => { | ComboLineSingleState | ComboLineDualState | ComboLineColumnState; - const { xAnchor, xValue, datum, placement, values } = getAnnotationInfo(d); + const { xAnchor, value, datum, placement, values } = getAnnotationInfo(d); return ( <RulerContent rotate={rotate} - xValue={xValue} + xValue={value} values={values} chartHeight={bounds.chartHeight} margins={bounds.margins} diff --git a/app/charts/shared/interaction/tooltip.tsx b/app/charts/shared/interaction/tooltip.tsx index 07a118879..43ca2c508 100644 --- a/app/charts/shared/interaction/tooltip.tsx +++ b/app/charts/shared/interaction/tooltip.tsx @@ -1,6 +1,5 @@ import { ReactNode } from "react"; -import { BarsState } from "@/charts/bar/bars-state"; import { LinesState } from "@/charts/line/lines-state"; import { PieState } from "@/charts/pie/pie-state"; import { useChartState } from "@/charts/shared/chart-state"; @@ -10,9 +9,7 @@ import { } from "@/charts/shared/interaction/tooltip-box"; import { TooltipMultiple, - TooltipMultipleInverted, TooltipSingle, - TooltipSingleInverted, } from "@/charts/shared/interaction/tooltip-content"; import { LegendSymbol } from "@/charts/shared/legend-color"; import { useInteraction } from "@/charts/shared/use-interaction"; @@ -20,7 +17,6 @@ import { Observation } from "@/domain/data"; export const Tooltip = ({ type = "single", - inverted = false, }: { type: TooltipType; inverted?: boolean; @@ -28,17 +24,7 @@ export const Tooltip = ({ const [state] = useInteraction(); const { visible, d } = state.interaction; - return ( - <> - {visible && - d && - (inverted ? ( - <TooltipInnerInverted d={d} type={type} /> - ) : ( - <TooltipInner d={d} type={type} /> - ))} - </> - ); + return <>{visible && d && <TooltipInner d={d} type={type} />}</>; }; export type { TooltipPlacement }; @@ -57,23 +43,13 @@ export interface TooltipInfo { xAnchor: number; yAnchor: number | undefined; placement: TooltipPlacement; - xValue: string; + value: string; tooltipContent?: ReactNode; datum: TooltipValue; values: TooltipValue[] | undefined; withTriangle?: boolean; } -export interface TooltipInfoInverted { - xAnchor: number; - yAnchor: number | undefined; - placement: TooltipPlacement; - yValue: string; - tooltipContent?: ReactNode; - datum: TooltipValue; - values: TooltipValue[] | undefined; -} - const TooltipInner = ({ d, type }: { d: Observation; type: TooltipType }) => { const { bounds, getAnnotationInfo } = useChartState() as | LinesState @@ -83,7 +59,7 @@ const TooltipInner = ({ d, type }: { d: Observation; type: TooltipType }) => { xAnchor, yAnchor, placement, - xValue, + value, tooltipContent, datum, values, @@ -105,10 +81,10 @@ const TooltipInner = ({ d, type }: { d: Observation; type: TooltipType }) => { {tooltipContent ? ( tooltipContent ) : type === "multiple" && values ? ( - <TooltipMultiple xValue={xValue} segmentValues={values} /> + <TooltipMultiple xValue={value} segmentValues={values} /> ) : ( <TooltipSingle - xValue={xValue} + xValue={value} segment={datum.label} yValue={datum.value} yError={datum.error} @@ -117,37 +93,3 @@ const TooltipInner = ({ d, type }: { d: Observation; type: TooltipType }) => { </TooltipBox> ); }; - -const TooltipInnerInverted = ({ - d, - type, -}: { - d: Observation; - type: TooltipType; -}) => { - const { bounds, getAnnotationInfo } = useChartState() as BarsState; - const { margins } = bounds; - const { xAnchor, yAnchor, placement, yValue, tooltipContent, datum, values } = - getAnnotationInfo(d as any); - - if (Number.isNaN(yAnchor)) { - return null; - } - - return ( - <TooltipBox x={xAnchor} y={yAnchor} placement={placement} margins={margins}> - {tooltipContent ? ( - tooltipContent - ) : type === "multiple" && values ? ( - <TooltipMultipleInverted yValue={yValue} segmentValues={values} /> - ) : ( - <TooltipSingleInverted - yValue={yValue} - segment={datum.label} - xValue={datum.value} - xError={datum.error} - /> - )} - </TooltipBox> - ); -}; From 893470de23ffac7e6c52841dfeb93c8fd0b7d20e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joa=CC=83o=20Sobral?= <joao.tiago.sobral@gmail.com> Date: Thu, 5 Dec 2024 21:05:19 +0000 Subject: [PATCH 26/54] =?UTF-8?q?refactor=20=E2=99=BB=EF=B8=8F:=20got=20ri?= =?UTF-8?q?d=20of=20getWideDataInverted?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/charts/area/areas-state.tsx | 6 +- app/charts/bar/bars-stacked-state-props.ts | 6 +- app/charts/bar/bars-stacked-state.tsx | 10 +- .../column/columns-stacked-state-props.ts | 6 +- app/charts/column/columns-stacked-state.tsx | 6 +- app/charts/line/lines-state.tsx | 6 +- app/charts/shared/chart-helpers.tsx | 200 +++--------------- app/charts/shared/imputation.tsx | 53 ----- 8 files changed, 55 insertions(+), 238 deletions(-) diff --git a/app/charts/area/areas-state.tsx b/app/charts/area/areas-state.tsx index f4fc35d7a..2816dcc1f 100644 --- a/app/charts/area/areas-state.tsx +++ b/app/charts/area/areas-state.tsx @@ -205,9 +205,9 @@ const useAreasState = ( const chartWideData = useMemo(() => { return getWideData({ - dataGroupedByX: chartDataGroupedByX, - xKey, - getY, + dataGrouped: chartDataGroupedByX, + key: xKey, + getAxisValue: getY, getSegment, allSegments: segments, imputationType: fields.y.imputationType, diff --git a/app/charts/bar/bars-stacked-state-props.ts b/app/charts/bar/bars-stacked-state-props.ts index eb0f105ea..2de24ff6b 100644 --- a/app/charts/bar/bars-stacked-state-props.ts +++ b/app/charts/bar/bars-stacked-state-props.ts @@ -139,9 +139,9 @@ export const useBarsStackedStateData = ( const { sortedPlottableData, plottableDataWide } = useMemo(() => { const plottableDataByY = group(plottableData, getY); const plottableDataWide = getWideData({ - dataGroupedByX: plottableDataByY, - xKey: y.componentId, - getY: getX, + dataGrouped: plottableDataByY, + key: y.componentId, + getAxisValue: getX, getSegment, }); diff --git a/app/charts/bar/bars-stacked-state.tsx b/app/charts/bar/bars-stacked-state.tsx index 5ea229cb9..1b6dafffb 100644 --- a/app/charts/bar/bars-stacked-state.tsx +++ b/app/charts/bar/bars-stacked-state.tsx @@ -31,7 +31,7 @@ import { useChartPadding, } from "@/charts/shared/chart-dimensions"; import { - getWideDataInverted, + getWideData, normalizeDataInverted, useGetIdentityX, } from "@/charts/shared/chart-helpers"; @@ -200,10 +200,10 @@ const useBarsStackedState = ( }, [chartData, getX, sumsByY, getY, xMeasure.id, normalize]); const chartWideData = useMemo(() => { - return getWideDataInverted({ - dataGroupedByY: chartDataGroupedByY, - yKey, - getX, + return getWideData({ + dataGrouped: chartDataGroupedByY, + key: yKey, + getAxisValue: getX, getSegment, allSegments: segments, imputationType: "zeros", diff --git a/app/charts/column/columns-stacked-state-props.ts b/app/charts/column/columns-stacked-state-props.ts index 008f01587..c99f23d5d 100644 --- a/app/charts/column/columns-stacked-state-props.ts +++ b/app/charts/column/columns-stacked-state-props.ts @@ -139,9 +139,9 @@ export const useColumnsStackedStateData = ( const { sortedPlottableData, plottableDataWide } = useMemo(() => { const plottableDataByX = group(plottableData, getX); const plottableDataWide = getWideData({ - dataGroupedByX: plottableDataByX, - xKey: x.componentId, - getY, + dataGrouped: plottableDataByX, + key: x.componentId, + getAxisValue: getY, getSegment, }); diff --git a/app/charts/column/columns-stacked-state.tsx b/app/charts/column/columns-stacked-state.tsx index 16daeb5a1..be5fc2e14 100644 --- a/app/charts/column/columns-stacked-state.tsx +++ b/app/charts/column/columns-stacked-state.tsx @@ -202,9 +202,9 @@ const useColumnsStackedState = ( const chartWideData = useMemo(() => { return getWideData({ - dataGroupedByX: chartDataGroupedByX, - xKey, - getY, + dataGrouped: chartDataGroupedByX, + key: xKey, + getAxisValue: getY, getSegment, allSegments: segments, imputationType: "zeros", diff --git a/app/charts/line/lines-state.tsx b/app/charts/line/lines-state.tsx index 312a38563..db6ba4f5a 100644 --- a/app/charts/line/lines-state.tsx +++ b/app/charts/line/lines-state.tsx @@ -122,9 +122,9 @@ const useLinesState = ( ); const chartWideData = getWideData({ - dataGroupedByX: preparedDataGroupedByX, - xKey, - getY, + dataGrouped: preparedDataGroupedByX, + key: xKey, + getAxisValue: getY, getSegment, }); diff --git a/app/charts/shared/chart-helpers.tsx b/app/charts/shared/chart-helpers.tsx index 08390a218..75a09aecd 100644 --- a/app/charts/shared/chart-helpers.tsx +++ b/app/charts/shared/chart-helpers.tsx @@ -6,7 +6,6 @@ import { useCallback, useMemo } from "react"; import { useMaybeAbbreviations } from "@/charts/shared/abbreviations"; import { imputeTemporalLinearSeries, - imputeTemporalLinearSeriesInverted, interpolateZerosValue, } from "@/charts/shared/imputation"; import { useObservationLabels } from "@/charts/shared/observation-labels"; @@ -406,16 +405,16 @@ export const stackOffsetDivergingPositiveZeros = ( // Helper to pivot a dataset to a wider format. // Currently, imputation is only applicable to temporal charts (specifically, stacked area charts). export const getWideData = ({ - dataGroupedByX, - xKey, - getY, + dataGrouped, + key, + getAxisValue, allSegments, getSegment, imputationType = "none", }: { - dataGroupedByX: InternMap<string, Array<Observation>>; - xKey: string; - getY: (d: Observation) => number | null; + dataGrouped: InternMap<string, Array<Observation>>; + key: string; + getAxisValue: (d: Observation) => number | null; allSegments?: Array<string>; getSegment: (d: Observation) => string; imputationType?: ImputationType; @@ -423,38 +422,38 @@ export const getWideData = ({ switch (imputationType) { case "linear": if (allSegments) { - const dataGroupedByXEntries = [...dataGroupedByX.entries()]; - const dataGroupedByXWithImputedValues: Array<{ + const dataGroupedEntries = [...dataGrouped.entries()]; + const dataGroupedWithImputedValues: Array<{ [key: string]: number; - }> = Array.from({ length: dataGroupedByX.size }, () => ({})); + }> = Array.from({ length: dataGrouped.size }, () => ({})); for (const segment of allSegments) { const imputedSeriesValues = imputeTemporalLinearSeries({ - dataSortedByX: dataGroupedByXEntries.map(([date, values]) => { + dataSortedByX: dataGroupedEntries.map(([date, values]) => { const observation = values.find((d) => getSegment(d) === segment); return { date: new Date(date), - value: observation ? getY(observation) : null, + value: observation ? getAxisValue(observation) : null, }; }), }); for (let i = 0; i < imputedSeriesValues.length; i++) { - dataGroupedByXWithImputedValues[i][segment] = + dataGroupedWithImputedValues[i][segment] = imputedSeriesValues[i].value; } } return getBaseWideData({ - dataGroupedByX, - xKey, - getY, + dataGrouped, + key, + getAxisValue, getSegment, getOptionalObservationProps: (i) => { return allSegments.map((d) => { return { - [d]: dataGroupedByXWithImputedValues[i][d], + [d]: dataGroupedWithImputedValues[i][d], }; }); }, @@ -463,9 +462,9 @@ export const getWideData = ({ case "zeros": if (allSegments) { return getBaseWideData({ - dataGroupedByX, - xKey, - getY, + dataGrouped, + key, + getAxisValue, getSegment, getOptionalObservationProps: () => { return allSegments.map((d) => { @@ -479,180 +478,51 @@ export const getWideData = ({ case "none": default: return getBaseWideData({ - dataGroupedByX, - xKey, - getY, - getSegment, - }); - } -}; - -export const getWideDataInverted = ({ - dataGroupedByY, - yKey, - getX, - allSegments, - getSegment, - imputationType = "none", -}: { - dataGroupedByY: InternMap<string, Array<Observation>>; - yKey: string; - getX: (d: Observation) => number | null; - allSegments?: Array<string>; - getSegment: (d: Observation) => string; - imputationType?: ImputationType; -}) => { - switch (imputationType) { - case "linear": - if (allSegments) { - const dataGroupedByYEntries = [...dataGroupedByY.entries()]; - const dataGroupedByYWithImputedValues: Array<{ - [key: string]: number; - }> = Array.from({ length: dataGroupedByY.size }, () => ({})); - - for (const segment of allSegments) { - const imputedSeriesValues = imputeTemporalLinearSeriesInverted({ - dataSortedByY: dataGroupedByYEntries.map(([date, values]) => { - const observation = values.find((d) => getSegment(d) === segment); - - return { - date: new Date(date), - value: observation ? getX(observation) : null, - }; - }), - }); - - for (let i = 0; i < imputedSeriesValues.length; i++) { - dataGroupedByYWithImputedValues[i][segment] = - imputedSeriesValues[i].value; - } - } - - return getBaseWideDataInverted({ - dataGroupedByY, - yKey, - getX, - getSegment, - getOptionalObservationProps: (i) => { - return allSegments.map((d) => { - return { - [d]: dataGroupedByYWithImputedValues[i][d], - }; - }); - }, - }); - } - case "zeros": - if (allSegments) { - return getBaseWideDataInverted({ - dataGroupedByY, - yKey, - getX, - getSegment, - getOptionalObservationProps: () => { - return allSegments.map((d) => { - return { - [d]: interpolateZerosValue(), - }; - }); - }, - }); - } - case "none": - default: - return getBaseWideDataInverted({ - dataGroupedByY, - yKey, - getX, + dataGrouped, + key, + getAxisValue, getSegment, }); } }; const getBaseWideData = ({ - dataGroupedByX, - xKey, - getY, + dataGrouped, + key, + getAxisValue, getSegment, getOptionalObservationProps = () => [], }: { - dataGroupedByX: InternMap<string, Array<Observation>>; - xKey: string; - getY: (d: Observation) => number | null; + dataGrouped: InternMap<string, Array<Observation>>; + key: string; + getAxisValue: (d: Observation) => number | null; getSegment: (d: Observation) => string; getOptionalObservationProps?: ( datumIndex: number ) => Array<{ [key: string]: number }>; }): Array<Observation> => { const wideData = []; - const dataGroupedByXEntries = [...dataGroupedByX.entries()]; + const dataGroupedByXEntries = [...dataGrouped.entries()]; - for (let i = 0; i < dataGroupedByX.size; i++) { + for (let i = 0; i < dataGrouped.size; i++) { const [k, v] = dataGroupedByXEntries[i]; const observation: Observation = Object.assign( { - [xKey]: k, - [`${xKey}/__iri__`]: v[0][`${xKey}/__iri__`], - total: sum(v, getY), - }, - ...getOptionalObservationProps(i), - ...v - // Sorting the values in case of multiple values for the same segment - // (desired behavior for getting the domain when time slider is active). - .sort((a, b) => { - return (getY(a) ?? 0) - (getY(b) ?? 0); - }) - .map((d) => { - return { - [getSegment(d)]: getY(d), - }; - }) - ); - - wideData.push(observation); - } - - return wideData; -}; - -const getBaseWideDataInverted = ({ - dataGroupedByY, - yKey, - getX, - getSegment, - getOptionalObservationProps = () => [], -}: { - dataGroupedByY: InternMap<string, Array<Observation>>; - yKey: string; - getX: (d: Observation) => number | null; - getSegment: (d: Observation) => string; - getOptionalObservationProps?: ( - datumIndex: number - ) => Array<{ [key: string]: number }>; -}): Array<Observation> => { - const wideData = []; - const dataGroupedByYEntries = [...dataGroupedByY.entries()]; - - for (let i = 0; i < dataGroupedByY.size; i++) { - const [k, v] = dataGroupedByYEntries[i]; - - const observation: Observation = Object.assign( - { - [yKey]: k, - [`${yKey}/__iri__`]: v[0][`${yKey}/__iri__`], - total: sum(v, getX), + [key]: k, + [`${key}/__iri__`]: v[0][`${key}/__iri__`], + total: sum(v, getAxisValue), }, ...getOptionalObservationProps(i), ...v // Sorting the values in case of multiple values for the same segment // (desired behavior for getting the domain when time slider is active). .sort((a, b) => { - return (getX(a) ?? 0) - (getX(b) ?? 0); + return (getAxisValue(a) ?? 0) - (getAxisValue(b) ?? 0); }) .map((d) => { return { - [getSegment(d)]: getX(d), + [getSegment(d)]: getAxisValue(d), }; }) ); diff --git a/app/charts/shared/imputation.tsx b/app/charts/shared/imputation.tsx index d1d69c3cd..25bc1e71e 100644 --- a/app/charts/shared/imputation.tsx +++ b/app/charts/shared/imputation.tsx @@ -88,59 +88,6 @@ export const imputeTemporalLinearSeries = ({ return dataSortedByX as Array<TemporalSeriesAfterImputationEntry>; }; -export const imputeTemporalLinearSeriesInverted = ({ - dataSortedByY, -}: { - dataSortedByY: Array<TemporalSeriesBeforeImputationEntry>; -}): Array<TemporalSeriesAfterImputationEntry> => { - const presentDataIndexes = []; - const missingDataIndexes = []; - - for (let i = 0; i < dataSortedByY.length; i++) { - if (dataSortedByY[i].value !== null) { - presentDataIndexes.push(i); - } else { - missingDataIndexes.push(i); - } - } - - for (const missingDataIndex of missingDataIndexes) { - const nextPresentDataIndex = presentDataIndexes.findIndex( - (d) => d > missingDataIndex - ); - - if (nextPresentDataIndex) { - const previousPresentDataIndex = nextPresentDataIndex - 1; - - if (previousPresentDataIndex >= 0) { - const previous = - dataSortedByY[presentDataIndexes[previousPresentDataIndex]]; - const next = dataSortedByY[presentDataIndexes[nextPresentDataIndex]]; - - dataSortedByY[missingDataIndex] = { - date: dataSortedByY[missingDataIndex].date, - value: interpolateTemporalLinearValue({ - previousValue: previous.value!, - nextValue: next.value!, - previousTime: previous.date.getTime(), - nextTime: next.date.getTime(), - currentTime: dataSortedByY[missingDataIndex].date.getTime(), - }), - }; - - continue; - } - } - - dataSortedByY[missingDataIndex] = { - date: dataSortedByY[missingDataIndex].date, - value: 0, - }; - } - - return dataSortedByY as Array<TemporalSeriesAfterImputationEntry>; -}; - export const isUsingImputation = (chartConfig: ChartConfig): boolean => { if (isAreaConfig(chartConfig)) { const imputationType = chartConfig.fields.y.imputationType || ""; From 8cf0207e457c22cd2a034025fea04c1a7d6fec7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joa=CC=83o=20Sobral?= <joao.tiago.sobral@gmail.com> Date: Thu, 5 Dec 2024 21:06:15 +0000 Subject: [PATCH 27/54] =?UTF-8?q?refactor=20=E2=99=BB=EF=B8=8F:=20got=20ri?= =?UTF-8?q?d=20of=20unused=20code?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../shared/interaction/tooltip-content.tsx | 68 ------------------- app/charts/shared/interaction/tooltip.tsx | 7 +- 2 files changed, 1 insertion(+), 74 deletions(-) diff --git a/app/charts/shared/interaction/tooltip-content.tsx b/app/charts/shared/interaction/tooltip-content.tsx index 9bd4d017d..ac31f967b 100644 --- a/app/charts/shared/interaction/tooltip-content.tsx +++ b/app/charts/shared/interaction/tooltip-content.tsx @@ -41,43 +41,6 @@ export const TooltipSingle = ({ ); }; -export const TooltipSingleInverted = ({ - yValue, - segment, - xValue, - xError, -}: { - yValue?: string; - segment?: string; - xValue?: string; - xError?: string; -}) => { - return ( - <Box> - {yValue && ( - <Typography - component="div" - variant="caption" - sx={{ fontWeight: "bold" }} - > - {yValue} - </Typography> - )} - {segment && ( - <Typography component="div" variant="caption"> - {segment} - </Typography> - )} - {xValue && ( - <Typography component="div" variant="caption"> - {xValue} - {xError ? <> ± {xError}</> : null} - </Typography> - )} - </Box> - ); -}; - export const TooltipMultiple = ({ xValue, segmentValues, @@ -109,37 +72,6 @@ export const TooltipMultiple = ({ ); }; -export const TooltipMultipleInverted = ({ - yValue, - segmentValues, -}: { - yValue?: string; - segmentValues: TooltipValue[]; -}) => { - return ( - <Box> - {yValue && ( - <Typography - component="div" - variant="caption" - sx={{ fontWeight: "bold" }} - > - {yValue} - </Typography> - )} - {segmentValues.map((d, i) => ( - <LegendItem - key={i} - item={`${d.label}: ${d.value}${d.error ? ` ± ${d.error}` : ""}`} - color={d.color} - symbol={d.symbol ?? "square"} - usage="tooltip" - /> - ))} - </Box> - ); -}; - // Chart Specific export const TooltipScatterplot = ({ firstLine, diff --git a/app/charts/shared/interaction/tooltip.tsx b/app/charts/shared/interaction/tooltip.tsx index 43ca2c508..44d9f336d 100644 --- a/app/charts/shared/interaction/tooltip.tsx +++ b/app/charts/shared/interaction/tooltip.tsx @@ -15,12 +15,7 @@ import { LegendSymbol } from "@/charts/shared/legend-color"; import { useInteraction } from "@/charts/shared/use-interaction"; import { Observation } from "@/domain/data"; -export const Tooltip = ({ - type = "single", -}: { - type: TooltipType; - inverted?: boolean; -}) => { +export const Tooltip = ({ type = "single" }: { type: TooltipType }) => { const [state] = useInteraction(); const { visible, d } = state.interaction; From adc84a8190eda66d726d8479ec62c5673cc16e31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joa=CC=83o=20Sobral?= <joao.tiago.sobral@gmail.com> Date: Thu, 5 Dec 2024 21:12:19 +0000 Subject: [PATCH 28/54] =?UTF-8?q?refactor=20=E2=99=BB=EF=B8=8F:=20got=20ri?= =?UTF-8?q?d=20of=20normalizeDataInverted?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/charts/area/areas-state.tsx | 4 +-- app/charts/bar/bars-stacked-state.tsx | 8 ++--- app/charts/column/columns-stacked-state.tsx | 4 +-- app/charts/shared/chart-helpers.tsx | 38 ++++----------------- 4 files changed, 15 insertions(+), 39 deletions(-) diff --git a/app/charts/area/areas-state.tsx b/app/charts/area/areas-state.tsx index 2816dcc1f..b87ce2630 100644 --- a/app/charts/area/areas-state.tsx +++ b/app/charts/area/areas-state.tsx @@ -190,8 +190,8 @@ const useAreasState = ( if (normalize) { return group( normalizeData(chartData, { - yKey: yMeasure.id, - getY, + key: yMeasure.id, + getAxisValue: getY, getTotalGroupValue: (d) => { return sumsByX[getXAsString(d)]; }, diff --git a/app/charts/bar/bars-stacked-state.tsx b/app/charts/bar/bars-stacked-state.tsx index 1b6dafffb..71a7c6612 100644 --- a/app/charts/bar/bars-stacked-state.tsx +++ b/app/charts/bar/bars-stacked-state.tsx @@ -32,7 +32,7 @@ import { } from "@/charts/shared/chart-dimensions"; import { getWideData, - normalizeDataInverted, + normalizeData, useGetIdentityX, } from "@/charts/shared/chart-helpers"; import { @@ -187,9 +187,9 @@ const useBarsStackedState = ( const chartDataGroupedByY = useMemo(() => { if (normalize) { return group( - normalizeDataInverted(chartData, { - xKey: xMeasure.id, - getX, + normalizeData(chartData, { + key: xMeasure.id, + getAxisValue: getX, getTotalGroupValue: (d) => sumsByY[getY(d)], }), getY diff --git a/app/charts/column/columns-stacked-state.tsx b/app/charts/column/columns-stacked-state.tsx index be5fc2e14..d69cfddcc 100644 --- a/app/charts/column/columns-stacked-state.tsx +++ b/app/charts/column/columns-stacked-state.tsx @@ -189,8 +189,8 @@ const useColumnsStackedState = ( if (normalize) { return group( normalizeData(chartData, { - yKey: yMeasure.id, - getY, + key: yMeasure.id, + getAxisValue: getY, getTotalGroupValue: (d) => sumsByX[getX(d)], }), getX diff --git a/app/charts/shared/chart-helpers.tsx b/app/charts/shared/chart-helpers.tsx index 75a09aecd..c2005f077 100644 --- a/app/charts/shared/chart-helpers.tsx +++ b/app/charts/shared/chart-helpers.tsx @@ -554,47 +554,23 @@ export const useGetIdentityX = (id: string) => { export const normalizeData = ( sortedData: Observation[], { - yKey, - getY, - getTotalGroupValue, - }: { - yKey: string; - getY: (d: Observation) => number | null; - getTotalGroupValue: (d: Observation) => number; - } -): Observation[] => { - return sortedData.map((d) => { - const totalGroupValue = getTotalGroupValue(d); - const y = getY(d); - - return { - ...d, - [yKey]: 100 * (y ? y / totalGroupValue : y ?? 0), - [getIdentityId(yKey)]: y, - }; - }); -}; - -export const normalizeDataInverted = ( - sortedData: Observation[], - { - xKey, - getX, + key, + getAxisValue, getTotalGroupValue, }: { - xKey: string; - getX: (d: Observation) => number | null; + key: string; + getAxisValue: (d: Observation) => number | null; getTotalGroupValue: (d: Observation) => number; } ): Observation[] => { return sortedData.map((d) => { const totalGroupValue = getTotalGroupValue(d); - const x = getX(d); + const axisValue = getAxisValue(d); return { ...d, - [xKey]: 100 * (x ? x / totalGroupValue : x ?? 0), - [getIdentityId(xKey)]: x, + [key]: 100 * (axisValue ? axisValue / totalGroupValue : axisValue ?? 0), + [getIdentityId(key)]: axisValue, }; }); }; From b40eb694fb96419c87a64095e8a22312511bef35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joa=CC=83o=20Sobral?= <joao.tiago.sobral@gmail.com> Date: Thu, 5 Dec 2024 21:15:42 +0000 Subject: [PATCH 29/54] =?UTF-8?q?refactor=20=E2=99=BB=EF=B8=8F:=20got=20ri?= =?UTF-8?q?d=20of=20getStackedTooltipValueFormatterInverted?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/charts/area/areas-state.tsx | 4 +- app/charts/bar/bars-stacked-state.tsx | 8 ++-- app/charts/column/columns-stacked-state.tsx | 4 +- app/charts/shared/stacked-helpers.spec.ts | 4 +- app/charts/shared/stacked-helpers.ts | 45 ++++----------------- 5 files changed, 17 insertions(+), 48 deletions(-) diff --git a/app/charts/area/areas-state.tsx b/app/charts/area/areas-state.tsx index b87ce2630..2246fef23 100644 --- a/app/charts/area/areas-state.tsx +++ b/app/charts/area/areas-state.tsx @@ -372,8 +372,8 @@ const useAreasState = ( }); const yValueFormatter = getStackedTooltipValueFormatter({ normalize, - yMeasureId: yMeasure.id, - yMeasureUnit: yMeasure.unit, + measureId: yMeasure.id, + measureUnit: yMeasure.unit, formatters, formatNumber, }); diff --git a/app/charts/bar/bars-stacked-state.tsx b/app/charts/bar/bars-stacked-state.tsx index 71a7c6612..b9949c460 100644 --- a/app/charts/bar/bars-stacked-state.tsx +++ b/app/charts/bar/bars-stacked-state.tsx @@ -46,7 +46,7 @@ import { MOBILE_TOOLTIP_PLACEMENT, } from "@/charts/shared/interaction/tooltip-box"; import { - getStackedTooltipValueFormatterInverted, + getStackedTooltipValueFormatter, getStackedXScale, } from "@/charts/shared/stacked-helpers"; import useChartFormatters from "@/charts/shared/use-chart-formatters"; @@ -430,10 +430,10 @@ const useBarsStackedState = ( getCategory: getSegment, sortingOrder: "asc", }); - const xValueFormatter = getStackedTooltipValueFormatterInverted({ + const xValueFormatter = getStackedTooltipValueFormatter({ normalize, - xMeasureId: xMeasure.id, - xMeasureUnit: xMeasure.unit, + measureId: xMeasure.id, + measureUnit: xMeasure.unit, formatters, formatNumber, }); diff --git a/app/charts/column/columns-stacked-state.tsx b/app/charts/column/columns-stacked-state.tsx index d69cfddcc..e87ec0887 100644 --- a/app/charts/column/columns-stacked-state.tsx +++ b/app/charts/column/columns-stacked-state.tsx @@ -438,8 +438,8 @@ const useColumnsStackedState = ( }); const yValueFormatter = getStackedTooltipValueFormatter({ normalize, - yMeasureId: yMeasure.id, - yMeasureUnit: yMeasure.unit, + measureId: yMeasure.id, + measureUnit: yMeasure.unit, formatters, formatNumber, }); diff --git a/app/charts/shared/stacked-helpers.spec.ts b/app/charts/shared/stacked-helpers.spec.ts index a4f03a28d..b8f453da5 100644 --- a/app/charts/shared/stacked-helpers.spec.ts +++ b/app/charts/shared/stacked-helpers.spec.ts @@ -69,8 +69,8 @@ describe("getStackedYScales", () => { describe("getStackedTooltipValueFormatter", () => { const commonStackedTooltipFormatProps = { - yMeasureId: "y", - yMeasureUnit: "ABC", + measureId: "y", + measureUnit: "ABC", formatters: {}, formatNumber: (d: NumberValue | null | undefined) => `${d}`, }; diff --git a/app/charts/shared/stacked-helpers.ts b/app/charts/shared/stacked-helpers.ts index 444a8c865..fd55ada76 100644 --- a/app/charts/shared/stacked-helpers.ts +++ b/app/charts/shared/stacked-helpers.ts @@ -90,14 +90,14 @@ export const getStackedXScale = ( export const getStackedTooltipValueFormatter = ({ normalize, - yMeasureId, - yMeasureUnit, + measureId, + measureUnit, formatters, formatNumber, }: { normalize: boolean; - yMeasureId: string; - yMeasureUnit: NumericalMeasure["unit"]; + measureId: string; + measureUnit: NumericalMeasure["unit"]; formatters: { [k: string]: (s: any) => string }; formatNumber: (d: NumberValue | null | undefined) => string; }) => { @@ -106,46 +106,15 @@ export const getStackedTooltipValueFormatter = ({ return "-"; } - const format = formatters[yMeasureId] ?? formatNumber; + const format = formatters[measureId] ?? formatNumber; if (normalize) { const rounded = Math.round(d as number); - const fValue = formatNumberWithUnit(dIdentity, format, yMeasureUnit); + const fValue = formatNumberWithUnit(dIdentity, format, measureUnit); return `${rounded}% (${fValue})`; } - return formatNumberWithUnit(d, format, yMeasureUnit); - }; -}; - -export const getStackedTooltipValueFormatterInverted = ({ - normalize, - xMeasureId, - xMeasureUnit, - formatters, - formatNumber, -}: { - normalize: boolean; - xMeasureId: string; - xMeasureUnit: NumericalMeasure["unit"]; - formatters: { [k: string]: (s: any) => string }; - formatNumber: (d: NumberValue | null | undefined) => string; -}) => { - return (d: number | null, dIdentity: number | null) => { - if (d === null && dIdentity === null) { - return "-"; - } - - const format = formatters[xMeasureId] ?? formatNumber; - - if (normalize) { - const rounded = Math.round(d as number); - const fValue = formatNumberWithUnit(dIdentity, format, xMeasureUnit); - - return `${rounded}% (${fValue})`; - } - - return formatNumberWithUnit(d, format, xMeasureUnit); + return formatNumberWithUnit(d, format, measureUnit); }; }; From 98d0981d95a81334ff10970e924aae23ae750a4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joa=CC=83o=20Sobral?= <joao.tiago.sobral@gmail.com> Date: Mon, 9 Dec 2024 09:17:11 +0100 Subject: [PATCH 30/54] =?UTF-8?q?refactor=20=E2=99=BB=EF=B8=8F:=20got=20ri?= =?UTF-8?q?d=20of=20useBarChartData?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/charts/area/areas-state-props.ts | 2 +- app/charts/bar/bars-grouped-state-props.ts | 2 +- app/charts/bar/bars-stacked-state-props.ts | 2 +- app/charts/bar/bars-state-props.ts | 6 +- .../column/columns-grouped-state-props.ts | 2 +- .../column/columns-stacked-state-props.ts | 2 +- app/charts/column/columns-state-props.ts | 2 +- .../combo/combo-line-column-state-props.ts | 2 +- .../combo/combo-line-dual-state-props.ts | 2 +- .../combo/combo-line-single-state-props.ts | 2 +- app/charts/line/lines-state-props.ts | 2 +- app/charts/shared/chart-state.ts | 185 +----------------- 12 files changed, 18 insertions(+), 193 deletions(-) diff --git a/app/charts/area/areas-state-props.ts b/app/charts/area/areas-state-props.ts index 4ae4ae3f8..f8017abad 100644 --- a/app/charts/area/areas-state-props.ts +++ b/app/charts/area/areas-state-props.ts @@ -93,7 +93,7 @@ export const useAreasStateData = ( const data = useChartData(sortedPlottableData, { chartConfig, timeRangeDimensionId: xDimension.id, - getXAsDate: getX, + getAxisValueAsDate: getX, getSegmentAbbreviationOrLabel, getTimeRangeDate, }); diff --git a/app/charts/bar/bars-grouped-state-props.ts b/app/charts/bar/bars-grouped-state-props.ts index 7424d1f06..201aecb2a 100644 --- a/app/charts/bar/bars-grouped-state-props.ts +++ b/app/charts/bar/bars-grouped-state-props.ts @@ -145,7 +145,7 @@ export const useBarsGroupedStateData = ( const data = useChartData(sortedPlottableData, { chartConfig, timeRangeDimensionId: yDimension.id, - getXAsDate: getYAsDate, + getAxisValueAsDate: getYAsDate, getSegmentAbbreviationOrLabel, getTimeRangeDate, }); diff --git a/app/charts/bar/bars-stacked-state-props.ts b/app/charts/bar/bars-stacked-state-props.ts index 2de24ff6b..dcffa2a00 100644 --- a/app/charts/bar/bars-stacked-state-props.ts +++ b/app/charts/bar/bars-stacked-state-props.ts @@ -155,7 +155,7 @@ export const useBarsStackedStateData = ( const data = useChartData(sortedPlottableData, { chartConfig, timeRangeDimensionId: yDimension.id, - getXAsDate: getYAsDate, + getAxisValueAsDate: getYAsDate, getSegmentAbbreviationOrLabel, getTimeRangeDate, }); diff --git a/app/charts/bar/bars-state-props.ts b/app/charts/bar/bars-state-props.ts index e4f7d2490..0a9c40906 100644 --- a/app/charts/bar/bars-state-props.ts +++ b/app/charts/bar/bars-state-props.ts @@ -12,8 +12,8 @@ import { RenderingVariables, SortingVariables, useBandYVariables, - useBarChartData, useBaseVariables, + useChartData, useInteractiveFiltersVariables, useNumericalXErrorVariables, useNumericalXVariables, @@ -122,10 +122,10 @@ export const useBarsStateData = ( const sortedPlottableData = useMemo(() => { return sortData(plottableData); }, [sortData, plottableData]); - const data = useBarChartData(sortedPlottableData, { + const data = useChartData(sortedPlottableData, { chartConfig, timeRangeDimensionId: yDimension.id, - getYAsDate, + getAxisValueAsDate: getYAsDate, getTimeRangeDate, }); diff --git a/app/charts/column/columns-grouped-state-props.ts b/app/charts/column/columns-grouped-state-props.ts index 11642228c..ccf4aa85a 100644 --- a/app/charts/column/columns-grouped-state-props.ts +++ b/app/charts/column/columns-grouped-state-props.ts @@ -145,7 +145,7 @@ export const useColumnsGroupedStateData = ( const data = useChartData(sortedPlottableData, { chartConfig, timeRangeDimensionId: xDimension.id, - getXAsDate, + getAxisValueAsDate: getXAsDate, getSegmentAbbreviationOrLabel, getTimeRangeDate, }); diff --git a/app/charts/column/columns-stacked-state-props.ts b/app/charts/column/columns-stacked-state-props.ts index c99f23d5d..678910678 100644 --- a/app/charts/column/columns-stacked-state-props.ts +++ b/app/charts/column/columns-stacked-state-props.ts @@ -155,7 +155,7 @@ export const useColumnsStackedStateData = ( const data = useChartData(sortedPlottableData, { chartConfig, timeRangeDimensionId: xDimension.id, - getXAsDate, + getAxisValueAsDate: getXAsDate, getSegmentAbbreviationOrLabel, getTimeRangeDate, }); diff --git a/app/charts/column/columns-state-props.ts b/app/charts/column/columns-state-props.ts index 82e8f7617..428f22f62 100644 --- a/app/charts/column/columns-state-props.ts +++ b/app/charts/column/columns-state-props.ts @@ -125,7 +125,7 @@ export const useColumnsStateData = ( const data = useChartData(sortedPlottableData, { chartConfig, timeRangeDimensionId: xDimension.id, - getXAsDate, + getAxisValueAsDate: getXAsDate, getTimeRangeDate, }); diff --git a/app/charts/combo/combo-line-column-state-props.ts b/app/charts/combo/combo-line-column-state-props.ts index d4e53abc9..355bd5118 100644 --- a/app/charts/combo/combo-line-column-state-props.ts +++ b/app/charts/combo/combo-line-column-state-props.ts @@ -172,7 +172,7 @@ export const useComboLineColumnStateData = ( const data = useChartData(sortedPlottableData, { chartConfig, timeRangeDimensionId: xDimension.id, - getXAsDate, + getAxisValueAsDate: getXAsDate, getTimeRangeDate, }); diff --git a/app/charts/combo/combo-line-dual-state-props.ts b/app/charts/combo/combo-line-dual-state-props.ts index 0394e5a39..337401642 100644 --- a/app/charts/combo/combo-line-dual-state-props.ts +++ b/app/charts/combo/combo-line-dual-state-props.ts @@ -136,7 +136,7 @@ export const useComboLineDualStateData = ( const data = useChartData(sortedPlottableData, { chartConfig, timeRangeDimensionId: xDimension.id, - getXAsDate: getX, + getAxisValueAsDate: getX, getTimeRangeDate, }); diff --git a/app/charts/combo/combo-line-single-state-props.ts b/app/charts/combo/combo-line-single-state-props.ts index 86a3b2e14..135da612f 100644 --- a/app/charts/combo/combo-line-single-state-props.ts +++ b/app/charts/combo/combo-line-single-state-props.ts @@ -110,7 +110,7 @@ export const useComboLineSingleStateData = ( const data = useChartData(sortedPlottableData, { chartConfig, timeRangeDimensionId: xDimension.id, - getXAsDate: getX, + getAxisValueAsDate: getX, getTimeRangeDate, }); diff --git a/app/charts/line/lines-state-props.ts b/app/charts/line/lines-state-props.ts index d16e71da6..bf17083fc 100644 --- a/app/charts/line/lines-state-props.ts +++ b/app/charts/line/lines-state-props.ts @@ -111,7 +111,7 @@ export const useLinesStateData = ( const data = useChartData(sortedPlottableData, { chartConfig, timeRangeDimensionId: xDimension.id, - getXAsDate: getX, + getAxisValueAsDate: getX, getSegmentAbbreviationOrLabel, getTimeRangeDate, }); diff --git a/app/charts/shared/chart-state.ts b/app/charts/shared/chart-state.ts index 74a3949d8..4be5277bc 100644 --- a/app/charts/shared/chart-state.ts +++ b/app/charts/shared/chart-state.ts @@ -742,13 +742,13 @@ export const useChartData = ( { chartConfig, timeRangeDimensionId, - getXAsDate, + getAxisValueAsDate, getSegmentAbbreviationOrLabel, getTimeRangeDate, }: { chartConfig: ChartConfig; timeRangeDimensionId: string | undefined; - getXAsDate?: (d: Observation) => Date; + getAxisValueAsDate?: (d: Observation) => Date; getSegmentAbbreviationOrLabel?: (d: Observation) => string; getTimeRangeDate?: (d: Observation) => Date; } @@ -785,7 +785,7 @@ export const useChartData = ( const { potentialTimeRangeFilterIds } = useDashboardInteractiveFilters(); const interactiveTimeRangeFilters = useMemo(() => { const interactiveTimeRangeFilter: ValuePredicate | null = - getXAsDate && + getAxisValueAsDate && interactiveFromTime && interactiveToTime && (interactiveTimeRange?.active || @@ -793,189 +793,14 @@ export const useChartData = ( timeRangeDimensionId && potentialTimeRangeFilterIds.includes(timeRangeDimensionId))) ? (d: Observation) => { - const time = getXAsDate(d).getTime(); + const time = getAxisValueAsDate(d).getTime(); return time >= interactiveFromTime && time <= interactiveToTime; } : null; return interactiveTimeRangeFilter ? [interactiveTimeRangeFilter] : []; }, [ - getXAsDate, - interactiveFromTime, - interactiveToTime, - interactiveTimeRange?.active, - dashboardFilters?.timeRange.active, - timeRangeDimensionId, - potentialTimeRangeFilterIds, - ]); - - // interactive time slider - const animationField = getAnimationField(chartConfig); - const dynamicScales = animationField?.dynamicScales ?? true; - const animationComponentId = animationField?.componentId ?? ""; - const getAnimationDate = useTemporalVariable(animationComponentId); - const getAnimationOrdinalDate = useStringVariable(animationComponentId); - const interactiveTimeSliderFilters = useMemo(() => { - const interactiveTimeSliderFilter: ValuePredicate | null = - animationField?.componentId && timeSlider.value - ? (d: Observation) => { - if (timeSlider.type === "interval") { - return ( - getAnimationDate(d).getTime() === timeSlider.value!.getTime() - ); - } - - const ordinalDate = getAnimationOrdinalDate(d); - return ordinalDate === timeSlider.value!; - } - : null; - - return interactiveTimeSliderFilter ? [interactiveTimeSliderFilter] : []; - }, [ - animationField?.componentId, - timeSlider.type, - timeSlider.value, - getAnimationDate, - getAnimationOrdinalDate, - ]); - - // interactive legend - const interactiveLegendFilters = useMemo(() => { - const legendItems = Object.keys(categories); - const interactiveLegendFilter: ValuePredicate | null = - interactiveFiltersConfig?.legend?.active && getSegmentAbbreviationOrLabel - ? (d: Observation) => { - return !legendItems.includes(getSegmentAbbreviationOrLabel(d)); - } - : null; - - return interactiveLegendFilter ? [interactiveLegendFilter] : []; - }, [ - categories, - getSegmentAbbreviationOrLabel, - interactiveFiltersConfig?.legend?.active, - ]); - - const chartData = useMemo(() => { - return observations.filter( - overEvery([ - ...interactiveLegendFilters, - ...interactiveTimeRangeFilters, - ...interactiveTimeSliderFilters, - ]) - ); - }, [ - observations, - interactiveLegendFilters, - interactiveTimeRangeFilters, - interactiveTimeSliderFilters, - ]); - - const scalesData = useMemo(() => { - if (dynamicScales) { - return chartData; - } else { - return observations.filter( - overEvery([...interactiveLegendFilters, ...interactiveTimeRangeFilters]) - ); - } - }, [ - dynamicScales, - chartData, - observations, - interactiveLegendFilters, - interactiveTimeRangeFilters, - ]); - - const segmentData = useMemo(() => { - return observations.filter(overEvery(interactiveTimeRangeFilters)); - }, [observations, interactiveTimeRangeFilters]); - - const timeRangeData = useMemo(() => { - return observations.filter(overEvery(timeRangeFilters)); - }, [observations, timeRangeFilters]); - - const paddingData = useMemo(() => { - if (dynamicScales) { - return chartData; - } else { - return observations.filter(overEvery(interactiveLegendFilters)); - } - }, [dynamicScales, chartData, observations, interactiveLegendFilters]); - - return { - chartData, - scalesData, - segmentData, - timeRangeData, - paddingData, - }; -}; - -export const useBarChartData = ( - observations: Observation[], - { - chartConfig, - timeRangeDimensionId, - getYAsDate, - getSegmentAbbreviationOrLabel, - getTimeRangeDate, - }: { - chartConfig: ChartConfig; - timeRangeDimensionId: string | undefined; - getYAsDate?: (d: Observation) => Date; - getSegmentAbbreviationOrLabel?: (d: Observation) => string; - getTimeRangeDate?: (d: Observation) => Date; - } -): Omit<ChartStateData, "allData"> => { - const { interactiveFiltersConfig } = chartConfig; - const categories = useChartInteractiveFilters((d) => d.categories); - const timeRange = useChartInteractiveFilters((d) => d.timeRange); - const timeSlider = useChartInteractiveFilters((d) => d.timeSlider); - - // time range - const interactiveTimeRange = interactiveFiltersConfig?.timeRange; - const timeRangeFromTime = interactiveTimeRange?.presets.from - ? parseDate(interactiveTimeRange?.presets.from).getTime() - : undefined; - const timeRangeToTime = interactiveTimeRange?.presets.to - ? parseDate(interactiveTimeRange?.presets.to).getTime() - : undefined; - const timeRangeFilters = useMemo(() => { - const timeRangeFilter: ValuePredicate | null = - getTimeRangeDate && timeRangeFromTime && timeRangeToTime - ? (d: Observation) => { - const time = getTimeRangeDate(d).getTime(); - return time >= timeRangeFromTime && time <= timeRangeToTime; - } - : null; - - return timeRangeFilter ? [timeRangeFilter] : []; - }, [timeRangeFromTime, timeRangeToTime, getTimeRangeDate]); - - // interactive time range - const interactiveFromTime = timeRange.from?.getTime(); - const interactiveToTime = timeRange.to?.getTime(); - const [{ dashboardFilters }] = useConfiguratorState(hasChartConfigs); - const { potentialTimeRangeFilterIds } = useDashboardInteractiveFilters(); - const interactiveTimeRangeFilters = useMemo(() => { - const interactiveTimeRangeFilter: ValuePredicate | null = - getYAsDate && - interactiveFromTime && - interactiveToTime && - (interactiveTimeRange?.active || - (dashboardFilters?.timeRange.active && - timeRangeDimensionId && - potentialTimeRangeFilterIds.includes(timeRangeDimensionId))) - ? (d: Observation) => { - const time = getYAsDate(d).getTime(); - return time >= interactiveFromTime && time <= interactiveToTime; - } - : null; - - return interactiveTimeRangeFilter ? [interactiveTimeRangeFilter] : []; - }, [ - getYAsDate, + getAxisValueAsDate, interactiveFromTime, interactiveToTime, interactiveTimeRange?.active, From f70052e1043d727ea62f9ea95f48631110c4f4d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joa=CC=83o=20Sobral?= <joao.tiago.sobral@gmail.com> Date: Mon, 9 Dec 2024 16:45:48 +0100 Subject: [PATCH 31/54] =?UTF-8?q?feat=20=E2=9A=A1=EF=B8=8F:=20adjust=20mar?= =?UTF-8?q?gins=20and=20scroll=20on=20bar=20charts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/charts/bar/bars-state.tsx | 33 +++++++++++++++++++++++--------- app/charts/shared/containers.tsx | 26 +++++++++++++++++++------ 2 files changed, 44 insertions(+), 15 deletions(-) diff --git a/app/charts/bar/bars-state.tsx b/app/charts/bar/bars-state.tsx index b158cd749..c0c93bac5 100644 --- a/app/charts/bar/bars-state.tsx +++ b/app/charts/bar/bars-state.tsx @@ -48,6 +48,8 @@ import { useIsMobile } from "@/utils/use-is-mobile"; import { ChartProps } from "../shared/ChartProps"; +export const MIN_BAR_HEIGHT = 16; + export type BarsState = CommonChartState & BarsStateVariables & InteractiveYTimeRangeState & { @@ -128,7 +130,6 @@ const useBarsState = ( .paddingInner(PADDING_INNER) .paddingOuter(PADDING_OUTER); const yScaleInteraction = scaleBand() - //NOTE: not sure if this is the right way to go here .domain(bandDomain.reverse()) .paddingInner(0) .paddingOuter(0); @@ -204,18 +205,29 @@ const useBarsState = ( const margins = { top: 0, right, - bottom, - //NOTE: hardcoded for the moment - left: 50 + left, + /** + * Here we have to switch the left and bottom margins because the margins are calculated + * based on the "regular" positioning of the axis + * */ + bottom: left, + left: bottom, }; - const bounds = useChartBounds(width, margins, height); + const barCount = yScale.domain().length; + + // Here we adjust the height to make sure the bars have a minimum height and are legible + const adjustedHeight = + barCount * MIN_BAR_HEIGHT > height + ? barCount * MIN_BAR_HEIGHT + : height - margins.bottom; + + const bounds = useChartBounds(width, margins, adjustedHeight); const { chartWidth, chartHeight } = bounds; xScale.range([0, chartWidth]); - yScaleInteraction.range([0, chartHeight]); - yScaleTimeRange.range([0, chartHeight]); - yScale.range([chartHeight, 0]); + yScaleInteraction.range([0, adjustedHeight]); + yScaleTimeRange.range([0, adjustedHeight]); + yScale.range([adjustedHeight, 0]); const isMobile = useIsMobile(); @@ -261,7 +273,10 @@ const useBarsState = ( return { chartType: "bar", - bounds, + bounds: { + ...bounds, + chartHeight: adjustedHeight, + }, chartData, allData, xScale, diff --git a/app/charts/shared/containers.tsx b/app/charts/shared/containers.tsx index 652cc960c..1f26bdd04 100644 --- a/app/charts/shared/containers.tsx +++ b/app/charts/shared/containers.tsx @@ -33,7 +33,10 @@ export const ChartContainer = ({ children }: { children: ReactNode }) => { ref={ref} aria-hidden="true" className={classes.chartContainer} - style={{ height: isFreeCanvas ? "initial" : bounds.height }} + style={{ + height: isFreeCanvas ? "initial" : bounds.height, + overflow: "scroll", + }} > {children} </div> @@ -44,23 +47,34 @@ export const ChartSvg = ({ children }: { children: ReactNode }) => { const ref = useRef<SVGSVGElement>(null); const enableTransition = useTransitionStore((state) => state.enable); const transitionDuration = useTransitionStore((state) => state.duration); - const { bounds, interactiveFiltersConfig } = useChartState(); - const { width, height, margins } = bounds; + const chartState = useChartState(); + const { bounds, interactiveFiltersConfig } = chartState; + + const { width, margins, chartHeight } = bounds; useEffect(() => { if (ref.current) { // Initialize height on mount. if (!ref.current.getAttribute("height")) { - ref.current.setAttribute("height", height.toString()); + ref.current.setAttribute( + "height", + `${chartHeight + margins.bottom + margins.top}` + ); } const sel = select(ref.current); (enableTransition ? sel.transition().duration(transitionDuration) : sel - ).attr("height", height); + ).attr("height", `${chartHeight + margins.bottom + margins.top}`); } - }, [height, enableTransition, transitionDuration]); + }, [ + chartHeight, + margins.bottom, + margins.top, + enableTransition, + transitionDuration, + ]); return ( <svg From 987131f0622d3000fed0caf7c9905043776cf140 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joa=CC=83o=20Sobral?= <joao.tiago.sobral@gmail.com> Date: Mon, 9 Dec 2024 17:20:39 +0100 Subject: [PATCH 32/54] =?UTF-8?q?feat=20=E2=9A=A1=EF=B8=8F:=20align=20left?= =?UTF-8?q?=20margin=20when=20axes=20are=20flipped?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/charts/bar/bars-state.tsx | 9 +++------ app/charts/shared/chart-dimensions.tsx | 12 ++++++++++-- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/app/charts/bar/bars-state.tsx b/app/charts/bar/bars-state.tsx index c0c93bac5..c532a17ab 100644 --- a/app/charts/bar/bars-state.tsx +++ b/app/charts/bar/bars-state.tsx @@ -200,17 +200,14 @@ const useBarsState = ( bandDomain: yTimeRangeDomainLabels.every((d) => d === undefined) ? yScale.domain() : yTimeRangeDomainLabels, + isFlipped: true, }); const right = 40; const margins = { top: 0, right, - /** - * Here we have to switch the left and bottom margins because the margins are calculated - * based on the "regular" positioning of the axis - * */ - bottom: left, - left: bottom, + bottom, + left, }; const barCount = yScale.domain().length; diff --git a/app/charts/shared/chart-dimensions.tsx b/app/charts/shared/chart-dimensions.tsx index b9ee42edc..ea8bc8188 100644 --- a/app/charts/shared/chart-dimensions.tsx +++ b/app/charts/shared/chart-dimensions.tsx @@ -27,6 +27,8 @@ type ComputeChartPaddingProps = { formatNumber: (n: number) => string; bandDomain?: string[]; normalize?: boolean; + //Chart is flipped in the case of bar charts where the position of the axes is inverted + isFlipped?: boolean; }; const computeChartPadding = ( @@ -43,6 +45,7 @@ const computeChartPadding = ( bandDomain, normalize, dashboardFilters, + isFlipped, } = props; // Fake ticks to compute maximum tick length as @@ -67,7 +70,9 @@ const computeChartPadding = ( !!interactiveFiltersConfig?.timeRange.active) || animationPresent ? BRUSH_BOTTOM_SPACE - : 48; + : isFlipped + ? 15 // Eyeballed value + : 48; if (bandDomain?.length) { bottom += @@ -75,7 +80,7 @@ const computeChartPadding = ( 70; } - return { left, bottom }; + return isFlipped ? { bottom: left, left: bottom } : { left, bottom }; }; export const useChartPadding = (props: ComputeChartPaddingProps) => { @@ -88,6 +93,7 @@ export const useChartPadding = (props: ComputeChartPaddingProps) => { formatNumber, bandDomain, normalize, + isFlipped, } = props; const [{ dashboardFilters }] = useConfiguratorState(hasChartConfigs); return useMemo(() => { @@ -101,6 +107,7 @@ export const useChartPadding = (props: ComputeChartPaddingProps) => { bandDomain, normalize, dashboardFilters, + isFlipped, }); }, [ yScale, @@ -112,6 +119,7 @@ export const useChartPadding = (props: ComputeChartPaddingProps) => { bandDomain, normalize, dashboardFilters, + isFlipped, ]); }; From c8995262fc9c6aedd245a2f9c31b1127c2214382 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joa=CC=83o=20Sobral?= <joao.tiago.sobral@gmail.com> Date: Tue, 10 Dec 2024 09:58:41 +0100 Subject: [PATCH 33/54] =?UTF-8?q?feat=20=E2=9A=A1=EF=B8=8F:=20add=20missin?= =?UTF-8?q?g=20types?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/config-types.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/config-types.ts b/app/config-types.ts index 8153cf130..8f905c312 100644 --- a/app/config-types.ts +++ b/app/config-types.ts @@ -1139,6 +1139,7 @@ type ComboLineDualAdjusters = BaseAdjusters<ComboLineDualConfig> & { ComboLineDualConfig, | AreaFields | ColumnFields + | BarFields | LineFields | MapFields | PieFields @@ -1157,6 +1158,7 @@ type ComboLineColumnAdjusters = BaseAdjusters<ComboLineColumnConfig> & { ComboLineColumnConfig, | AreaFields | ColumnFields + | BarFields | LineFields | MapFields | PieFields From befb5f5b0ec1ae591f68e24f5682afa4fe2f972b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joa=CC=83o=20Sobral?= <joao.tiago.sobral@gmail.com> Date: Tue, 10 Dec 2024 10:31:14 +0100 Subject: [PATCH 34/54] =?UTF-8?q?fix=20=F0=9F=90=9B:=20animate=20the=20cor?= =?UTF-8?q?rect=20axis?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/charts/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/charts/index.ts b/app/charts/index.ts index 2415a5458..4d68fb484 100644 --- a/app/charts/index.ts +++ b/app/charts/index.ts @@ -1105,7 +1105,7 @@ const chartConfigsAdjusters: ChartConfigsAdjusters = { return produce(newChartConfig, (draft) => { // Temporal dimension could be used as X axis, in this case we need to // remove the animation. - if (newChartConfig.fields.x.componentId !== oldValue?.componentId) { + if (newChartConfig.fields.y.componentId !== oldValue?.componentId) { draft.fields.animation = oldValue; } }); From ba2822413153b9a3ee0ac13d88c1fe0e4ec9a08e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joa=CC=83o=20Sobral?= <joao.tiago.sobral@gmail.com> Date: Tue, 10 Dec 2024 10:46:46 +0100 Subject: [PATCH 35/54] =?UTF-8?q?fix=20=F0=9F=90=9B:=20remove=20unwanted?= =?UTF-8?q?=20animation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/charts/shared/axis-height-band.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/app/charts/shared/axis-height-band.tsx b/app/charts/shared/axis-height-band.tsx index c079c05eb..ec96e8047 100644 --- a/app/charts/shared/axis-height-band.tsx +++ b/app/charts/shared/axis-height-band.tsx @@ -55,7 +55,6 @@ export const AxisHeightBand = () => { hasNegativeValues ? gridColor : domainColor ); g.selectAll(".tick text") - .attr("x", -fontSize) .attr("font-size", fontSize) .attr("font-family", fontFamily) .attr("fill", labelColor) From 468ff64ae644c1d20779cbf13b40464f4a471297 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joa=CC=83o=20Sobral?= <joao.tiago.sobral@gmail.com> Date: Tue, 10 Dec 2024 10:46:56 +0100 Subject: [PATCH 36/54] =?UTF-8?q?fix=20=F0=9F=90=9B:=20label=20change?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/charts/shared/axis-height-band.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/charts/shared/axis-height-band.tsx b/app/charts/shared/axis-height-band.tsx index ec96e8047..05abcb064 100644 --- a/app/charts/shared/axis-height-band.tsx +++ b/app/charts/shared/axis-height-band.tsx @@ -41,7 +41,7 @@ export const AxisHeightBand = () => { id: "axis-height-band", transform: `translate(${margins.left} ${margins.top})`, transition: { enable: enableTransition, duration: transitionDuration }, - render: (g) => g.attr("data-testid", "axis-width-band").call(axis), + render: (g) => g.attr("data-testid", "axis-height-band").call(axis), renderUpdate: (g, opts) => maybeTransition(g, { transition: opts.transition, From 1b90124617a3ed6f82349b34b8cc4fbf5a1e0389 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joa=CC=83o=20Sobral?= <joao.tiago.sobral@gmail.com> Date: Tue, 10 Dec 2024 14:34:10 +0100 Subject: [PATCH 37/54] =?UTF-8?q?feat=20=E2=9A=A1=EF=B8=8F:=20add=20i18n?= =?UTF-8?q?=20to=20bar=20sorting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/configurator/components/field-i18n.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/configurator/components/field-i18n.ts b/app/configurator/components/field-i18n.ts index 0716dbc99..b931f7d8d 100644 --- a/app/configurator/components/field-i18n.ts +++ b/app/configurator/components/field-i18n.ts @@ -297,6 +297,7 @@ export function getFieldLabel(field: string): string { case "line..byDimensionLabel.asc": case "sorting.byDimensionLabel.asc": return i18n._(fieldLabels["controls.sorting.byDimensionLabel.ascending"]); + case "bar..byAuto.asc": case "bar.stacked.byAuto.asc": case "bar.grouped.byAuto.asc": case "column..byAuto.asc": @@ -306,6 +307,7 @@ export function getFieldLabel(field: string): string { case "line..byAuto.asc": case "area..byAuto.asc": return i18n._(fieldLabels["controls.sorting.byAuto.ascending"]); + case "bar..byAuto.desc": case "bar.stacked.byAuto.desc": case "bar.grouped.byAuto.desc": case "column..byAuto.desc": From efc90bd9c07a6dde33c7bcf0e19a90a362e06650 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joa=CC=83o=20Sobral?= <joao.tiago.sobral@gmail.com> Date: Tue, 10 Dec 2024 16:07:17 +0100 Subject: [PATCH 38/54] =?UTF-8?q?refactor=20=E2=99=BB=EF=B8=8F:=20extracte?= =?UTF-8?q?d=20MIN=5FBAR=5FHEIGHT=20into=20constants=20file?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/charts/bar/bars-state.tsx | 8 +++++--- app/charts/bar/constants.ts | 1 + 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/app/charts/bar/bars-state.tsx b/app/charts/bar/bars-state.tsx index c532a17ab..bfba5a1d4 100644 --- a/app/charts/bar/bars-state.tsx +++ b/app/charts/bar/bars-state.tsx @@ -14,7 +14,11 @@ import { useBarsStateData, useBarsStateVariables, } from "@/charts/bar/bars-state-props"; -import { PADDING_INNER, PADDING_OUTER } from "@/charts/bar/constants"; +import { + MIN_BAR_HEIGHT, + PADDING_INNER, + PADDING_OUTER, +} from "@/charts/bar/constants"; import { useChartBounds, useChartPadding, @@ -48,8 +52,6 @@ import { useIsMobile } from "@/utils/use-is-mobile"; import { ChartProps } from "../shared/ChartProps"; -export const MIN_BAR_HEIGHT = 16; - export type BarsState = CommonChartState & BarsStateVariables & InteractiveYTimeRangeState & { diff --git a/app/charts/bar/constants.ts b/app/charts/bar/constants.ts index ecad19d48..f55bb0072 100644 --- a/app/charts/bar/constants.ts +++ b/app/charts/bar/constants.ts @@ -1,3 +1,4 @@ export const PADDING_OUTER = 0; export const PADDING_INNER = 0.1; export const PADDING_WITHIN = 0.1; +export const MIN_BAR_HEIGHT = 16; From cb28a3a9efe73ad4a96f80a9b8e0b6800f741822 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joa=CC=83o=20Sobral?= <joao.tiago.sobral@gmail.com> Date: Tue, 10 Dec 2024 16:08:28 +0100 Subject: [PATCH 39/54] =?UTF-8?q?feat=20=E2=9A=A1=EF=B8=8F:=20adjust=20sta?= =?UTF-8?q?cked=20bars=20height?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/charts/bar/bars-stacked-state.tsx | 33 +++++++++++++++++++-------- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/app/charts/bar/bars-stacked-state.tsx b/app/charts/bar/bars-stacked-state.tsx index b9949c460..d37f9e6a8 100644 --- a/app/charts/bar/bars-stacked-state.tsx +++ b/app/charts/bar/bars-stacked-state.tsx @@ -25,7 +25,11 @@ import { useBarsStackedStateData, useBarsStackedStateVariables, } from "@/charts/bar/bars-stacked-state-props"; -import { PADDING_INNER, PADDING_OUTER } from "@/charts/bar/constants"; +import { + MIN_BAR_HEIGHT, + PADDING_INNER, + PADDING_OUTER, +} from "@/charts/bar/constants"; import { useChartBounds, useChartPadding, @@ -387,7 +391,6 @@ const useBarsStackedState = ( /** Chart dimensions */ const { left, bottom } = useChartPadding({ - //TODO: This is wrong, need to fix yScale: paddingXScale, width, height, @@ -398,20 +401,29 @@ const useBarsStackedState = ( ? yScale.domain() : yTimeRangeDomainLabels, normalize, + isFlipped: true, }); const right = 40; const margins = { top: 0, right, - bottom, - left: 50 + left, + bottom: bottom + 30, + left, }; - const bounds = useChartBounds(width, margins, height); + + const barCount = yScale.domain().length; + // Here we adjust the height to make sure the bars have a minimum height and are legible + const adjustedHeight = + barCount * MIN_BAR_HEIGHT > height + ? barCount * MIN_BAR_HEIGHT + : height - margins.bottom; + + const bounds = useChartBounds(width, margins, adjustedHeight); const { chartWidth, chartHeight } = bounds; - yScale.range([0, chartHeight]); - yScaleInteraction.range([0, chartHeight]); - yScaleTimeRange.range([0, chartHeight]); + yScale.range([0, adjustedHeight]); + yScaleInteraction.range([0, adjustedHeight]); + yScaleTimeRange.range([0, adjustedHeight]); xScale.range([0, chartWidth]); const isMobile = useIsMobile(); @@ -494,7 +506,10 @@ const useBarsStackedState = ( return { chartType: "bar", - bounds, + bounds: { + ...bounds, + chartHeight: adjustedHeight, + }, chartData, allData, xScale, From 90f3132bea46c3f81f697f08a4b8e044e2ad21af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joa=CC=83o=20Sobral?= <joao.tiago.sobral@gmail.com> Date: Tue, 10 Dec 2024 16:09:04 +0100 Subject: [PATCH 40/54] =?UTF-8?q?feat=20=E2=9A=A1=EF=B8=8F:=20adjust=20bot?= =?UTF-8?q?tom=20margin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/charts/bar/bars-state.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/charts/bar/bars-state.tsx b/app/charts/bar/bars-state.tsx index bfba5a1d4..7cd5e64af 100644 --- a/app/charts/bar/bars-state.tsx +++ b/app/charts/bar/bars-state.tsx @@ -208,7 +208,7 @@ const useBarsState = ( const margins = { top: 0, right, - bottom, + bottom: bottom + 30, left, }; From 5d2b5fbfc261ca7f2f2b110a070ea5d9e3d3941b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joa=CC=83o=20Sobral?= <joao.tiago.sobral@gmail.com> Date: Tue, 10 Dec 2024 16:11:33 +0100 Subject: [PATCH 41/54] =?UTF-8?q?feat=20=E2=9A=A1=EF=B8=8F:=20adjust=20hor?= =?UTF-8?q?izontal=20whiskers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/charts/bar/bars-grouped.tsx | 6 +++--- app/charts/bar/bars.tsx | 6 +++--- app/charts/shared/rendering-utils.ts | 28 ++++++++++++++-------------- 3 files changed, 20 insertions(+), 20 deletions(-) diff --git a/app/charts/bar/bars-grouped.tsx b/app/charts/bar/bars-grouped.tsx index 8c55d9445..2cf32933f 100644 --- a/app/charts/bar/bars-grouped.tsx +++ b/app/charts/bar/bars-grouped.tsx @@ -37,14 +37,14 @@ export const ErrorWhiskers = () => { .flatMap(([segment, observations]) => observations.map((d) => { const y0 = yScaleIn(getSegment(d)) as number; - const barWidth = Math.min(bandwidth, 15); + const barHeight = Math.min(bandwidth, 15); const [x1, x2] = getXErrorRange(d); return { key: `${segment}-${getSegment(d)}`, - y: (yScale(segment) as number) + y0 + bandwidth / 2 - barWidth / 2, + y: (yScale(segment) as number) + y0 + bandwidth / 2 - barHeight / 2, x1: xScale(x1), x2: xScale(x2), - width: barWidth, + height: barHeight, } as RenderHorizontalWhiskerDatum; }) ); diff --git a/app/charts/bar/bars.tsx b/app/charts/bar/bars.tsx index 39bb341e9..201197744 100644 --- a/app/charts/bar/bars.tsx +++ b/app/charts/bar/bars.tsx @@ -35,14 +35,14 @@ export const ErrorWhiskers = () => { const bandwidth = yScale.bandwidth(); return chartData.filter(getXErrorPresent).map((d, i) => { const y0 = yScale(getY(d)) as number; - const barWidth = Math.min(bandwidth, 15); + const barHeight = Math.min(bandwidth, 15); const [x1, x2] = getXErrorRange(d); return { key: `${i}`, - y: y0 + bandwidth / 2 - barWidth / 2, + y: y0 + bandwidth / 2 - barHeight / 2, x1: xScale(x1), x2: xScale(x2), - width: barWidth, + height: barHeight, } as RenderHorizontalWhiskerDatum; }); // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/app/charts/shared/rendering-utils.ts b/app/charts/shared/rendering-utils.ts index c3f55f6c5..dd30bf7ff 100644 --- a/app/charts/shared/rendering-utils.ts +++ b/app/charts/shared/rendering-utils.ts @@ -168,7 +168,7 @@ export type RenderHorizontalWhiskerDatum = { y: number; x1: number; x2: number; - width: number; + height: number; fill?: string; renderMiddleCircle?: boolean; }; @@ -305,8 +305,8 @@ export const renderHorizontalWhisker = ( .attr("class", "top") .attr("y", (d) => d.y) .attr("x", (d) => d.x2) - .attr("width", (d) => d.width) - .attr("height", ERROR_WHISKER_SIZE) + .attr("width", ERROR_WHISKER_SIZE) + .attr("height", (d) => d.height) .attr("fill", (d) => d.fill ?? "black") .attr("stroke", "none") ) @@ -314,10 +314,10 @@ export const renderHorizontalWhisker = ( g .append("rect") .attr("class", "middle") - .attr("y", (d) => d.y + (d.width - ERROR_WHISKER_SIZE) / 2) - .attr("x", (d) => d.x2) - .attr("width", ERROR_WHISKER_SIZE) - .attr("height", (d) => Math.max(0, d.x1 - d.x2)) + .attr("y", (d) => d.y + (d.height - ERROR_WHISKER_SIZE) / 2) + .attr("x", (d) => d.x1) + .attr("width", (d) => Math.abs(Math.min(0, d.x1 - d.x2))) + .attr("height", ERROR_WHISKER_SIZE) .attr("fill", (d) => d.fill ?? "black") .attr("stroke", "none") ) @@ -327,8 +327,8 @@ export const renderHorizontalWhisker = ( .attr("class", "bottom") .attr("y", (d) => d.y) .attr("x", (d) => d.x1) - .attr("width", (d) => d.width) - .attr("height", ERROR_WHISKER_SIZE) + .attr("width", ERROR_WHISKER_SIZE) + .attr("height", (d) => d.height) .attr("fill", (d) => d.fill ?? "black") .attr("stroke", "none") ) @@ -359,15 +359,15 @@ export const renderHorizontalWhisker = ( .select(".top") .attr("y", (d) => d.y) .attr("x", (d) => d.x2) - .attr("width", (d) => d.width) + .attr("height", (d) => d.height) .attr("fill", (d) => d.fill ?? "black") ) .call((g) => g .select(".middle") - .attr("y", (d) => d.y + (d.width - ERROR_WHISKER_SIZE) / 2) - .attr("x", (d) => d.x2) - .attr("height", (d) => Math.max(0, d.x1 - d.x2)) + .attr("y", (d) => d.y + (d.height - ERROR_WHISKER_SIZE) / 2) + .attr("x", (d) => d.x1) + .attr("width", (d) => Math.abs(Math.min(0, d.x1 - d.x2))) .attr("fill", (d) => d.fill ?? "black") ) .call((g) => @@ -375,7 +375,7 @@ export const renderHorizontalWhisker = ( .select(".bottom") .attr("y", (d) => d.y) .attr("x", (d) => d.x1) - .attr("width", (d) => d.width) + .attr("height", (d) => d.height) .attr("fill", (d) => d.fill ?? "black") ) .call((g) => From f2919948b0a82b58bc7b96e3a247d7508f17e23f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joa=CC=83o=20Sobral?= <joao.tiago.sobral@gmail.com> Date: Tue, 10 Dec 2024 16:32:10 +0100 Subject: [PATCH 42/54] =?UTF-8?q?feat=20=E2=9A=A1=EF=B8=8F:=20adjust=20xSc?= =?UTF-8?q?ale=20on=20grouped=20bar=20charts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/charts/bar/bars-grouped-state.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/charts/bar/bars-grouped-state.tsx b/app/charts/bar/bars-grouped-state.tsx index e5dbec0ff..23ffb9cb1 100644 --- a/app/charts/bar/bars-grouped-state.tsx +++ b/app/charts/bar/bars-grouped-state.tsx @@ -250,7 +250,7 @@ const useBarsGroupedState = ( ) ?? 0, 0 ); - const xScale = scaleLinear().domain([maxValue, minValue]).nice(); + const xScale = scaleLinear().domain([minValue, maxValue]).nice(); const minPaddingValue = getMinX(paddingData, (d) => getXErrorRange ? getXErrorRange(d)[0] : getX(d) @@ -352,7 +352,7 @@ const useBarsGroupedState = ( yScaleInteraction.range([0, chartHeight]); yScaleIn.range([0, yScale.bandwidth()]); yScaleTimeRange.range([0, chartHeight]); - xScale.range([chartWidth, 0]); + xScale.range([0, chartWidth]); const isMobile = useIsMobile(); From f3ae19c1bb2257c22df79cfbdcdf9b598de3c760 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joa=CC=83o=20Sobral?= <joao.tiago.sobral@gmail.com> Date: Tue, 10 Dec 2024 17:35:41 +0100 Subject: [PATCH 43/54] =?UTF-8?q?feat=20=E2=9A=A1=EF=B8=8F:=20adjust=20mar?= =?UTF-8?q?gins=20on=20grouped=20bar=20chart?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/charts/bar/bars-grouped-state.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/charts/bar/bars-grouped-state.tsx b/app/charts/bar/bars-grouped-state.tsx index 23ffb9cb1..c4feaaed7 100644 --- a/app/charts/bar/bars-grouped-state.tsx +++ b/app/charts/bar/bars-grouped-state.tsx @@ -336,13 +336,14 @@ const useBarsGroupedState = ( bandDomain: yTimeRangeDomainLabels.every((d) => d === undefined) ? yScale.domain() : yTimeRangeDomainLabels, + isFlipped: true, }); const right = 40; const margins = { top: 0, right, - bottom, - left: 50 + left, + bottom: bottom + 30, + left, }; const bounds = useChartBounds(width, margins, height); const { chartWidth, chartHeight } = bounds; From 43ec60bf296f1902409321d44c738855ea1e4b8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joa=CC=83o=20Sobral?= <joao.tiago.sobral@gmail.com> Date: Tue, 10 Dec 2024 17:38:22 +0100 Subject: [PATCH 44/54] =?UTF-8?q?feat=20=E2=9A=A1=EF=B8=8F:=20adjust=20bot?= =?UTF-8?q?tom=20axis=20according=20to=20design?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/charts/shared/axis-width-linear.tsx | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/app/charts/shared/axis-width-linear.tsx b/app/charts/shared/axis-width-linear.tsx index ee53f38fb..aa8a5626d 100644 --- a/app/charts/shared/axis-width-linear.tsx +++ b/app/charts/shared/axis-width-linear.tsx @@ -17,9 +17,8 @@ import { getTextWidth } from "@/utils/get-text-width"; export const AxisWidthLinear = () => { const formatNumber = useFormatNumber(); - const { xScale, bounds, xAxisLabel, xMeasure } = useChartState() as - | ScatterplotState - | BarsState; + const { xScale, bounds, xAxisLabel, xMeasure, chartType } = + useChartState() as ScatterplotState | BarsState; const { chartWidth, chartHeight, margins } = bounds; const { labelColor, @@ -41,7 +40,7 @@ export const AxisWidthLinear = () => { const tickValues = xScale.ticks(ticks); const axis = axisBottom(xScale) .tickValues(tickValues) - .tickSizeInner(-chartHeight) + .tickSizeInner(-chartHeight - 10) .tickSizeOuter(-chartHeight) .tickFormat(formatNumber); const g = renderContainer(ref.current, { @@ -57,17 +56,21 @@ export const AxisWidthLinear = () => { }); g.selectAll(".tick line") - .attr("stroke", gridColor) + .attr("y1", 10) + .attr("stroke", (_, i) => (i === 0 ? "#000000" : gridColor)) .attr("stroke-width", 1); g.selectAll(".tick text") .attr("font-size", labelFontSize) .attr("font-family", fontFamily) .attr("fill", labelColor) - .attr("dy", labelFontSize) + .attr("dy", labelFontSize + 10) .attr("text-anchor", "middle"); - g.select("path.domain").attr("stroke", gridColor); + g.select("path.domain") + .attr("stroke", gridColor) + .attr("opacity", chartType === "bar" ? 0 : 1); } }, [ + chartType, bounds.chartWidth, chartHeight, enableTransition, @@ -93,7 +96,7 @@ export const AxisWidthLinear = () => { <> <foreignObject x={margins.left} - y={margins.top + chartHeight + 24} + y={margins.top + chartHeight + 34} width={chartWidth} height={height} style={{ display: "flex", textAlign: "right" }} From b479cd17c72e78e8d950aedb0f2661f46c1ffc43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joa=CC=83o=20Sobral?= <joao.tiago.sobral@gmail.com> Date: Wed, 11 Dec 2024 10:45:01 +0100 Subject: [PATCH 45/54] =?UTF-8?q?fix=20=F0=9F=90=9B:=20animation=20on=20ba?= =?UTF-8?q?r=20chart=20coming=20from=20pie?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/charts/index.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/app/charts/index.ts b/app/charts/index.ts index 4d68fb484..d6ef4c986 100644 --- a/app/charts/index.ts +++ b/app/charts/index.ts @@ -1102,8 +1102,15 @@ const chartConfigsAdjusters: ChartConfigsAdjusters = { }); }, animation: ({ oldValue, newChartConfig }) => { + if (newChartConfig.chartType !== "bar") { + return produce(newChartConfig, (draft) => { + if (newChartConfig.fields.x.componentId !== oldValue?.componentId) { + draft.fields.animation = oldValue; + } + }); + } return produce(newChartConfig, (draft) => { - // Temporal dimension could be used as X axis, in this case we need to + // Temporal dimension could be used as Y axis, in this case we need to // remove the animation. if (newChartConfig.fields.y.componentId !== oldValue?.componentId) { draft.fields.animation = oldValue; From 49f7756a18615b06bf76f92be9bac768c96ae654 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joa=CC=83o=20Sobral?= <joao.tiago.sobral@gmail.com> Date: Wed, 11 Dec 2024 10:48:23 +0100 Subject: [PATCH 46/54] =?UTF-8?q?fix=20=F0=9F=90=9B:=20remove=20middle=20c?= =?UTF-8?q?ircle=20from=20horizontal=20bar=20whiskers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/charts/shared/rendering-utils.ts | 20 -------------------- 1 file changed, 20 deletions(-) diff --git a/app/charts/shared/rendering-utils.ts b/app/charts/shared/rendering-utils.ts index dd30bf7ff..840c594e3 100644 --- a/app/charts/shared/rendering-utils.ts +++ b/app/charts/shared/rendering-utils.ts @@ -332,17 +332,6 @@ export const renderHorizontalWhisker = ( .attr("fill", (d) => d.fill ?? "black") .attr("stroke", "none") ) - .call((g) => - g - .filter((d) => d.renderMiddleCircle ?? false) - .append("circle") - .attr("class", "middle-circle") - .attr("cy", (d) => d.y + d.width / 2) - .attr("cx", (d) => (d.x1 + d.x2) / 2) - .attr("r", ERROR_WHISKER_MIDDLE_CIRCLE_RADIUS) - .attr("fill", (d) => d.fill ?? "black") - .attr("stroke", "none") - ) .call((enter) => maybeTransition(enter, { s: (g) => g.attr("opacity", 1), @@ -377,15 +366,6 @@ export const renderHorizontalWhisker = ( .attr("x", (d) => d.x1) .attr("height", (d) => d.height) .attr("fill", (d) => d.fill ?? "black") - ) - .call((g) => - g - .select(".middle-circle") - .attr("cy", (d) => d.y + d.width / 2) - .attr("cx", (d) => (d.x1 + d.x2) / 2) - .attr("r", ERROR_WHISKER_MIDDLE_CIRCLE_RADIUS) - .attr("fill", (d) => d.fill ?? "black") - .attr("stroke", "none") ), transition, }), From 52cd631e12b4e6da3894564bfd9c3478c264e4ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joa=CC=83o=20Sobral?= <joao.tiago.sobral@gmail.com> Date: Wed, 11 Dec 2024 11:52:03 +0100 Subject: [PATCH 47/54] =?UTF-8?q?refactor=20=E2=99=BB=EF=B8=8F:=20rename?= =?UTF-8?q?=20vars?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/charts/shared/rendering-utils.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/charts/shared/rendering-utils.ts b/app/charts/shared/rendering-utils.ts index 840c594e3..62c39fbe8 100644 --- a/app/charts/shared/rendering-utils.ts +++ b/app/charts/shared/rendering-utils.ts @@ -302,7 +302,7 @@ export const renderHorizontalWhisker = ( .call((g) => g .append("rect") - .attr("class", "top") + .attr("class", "right") .attr("y", (d) => d.y) .attr("x", (d) => d.x2) .attr("width", ERROR_WHISKER_SIZE) @@ -324,7 +324,7 @@ export const renderHorizontalWhisker = ( .call((g) => g .append("rect") - .attr("class", "bottom") + .attr("class", "left") .attr("y", (d) => d.y) .attr("x", (d) => d.x1) .attr("width", ERROR_WHISKER_SIZE) @@ -345,7 +345,7 @@ export const renderHorizontalWhisker = ( .attr("opacity", 1) .call((g) => g - .select(".top") + .select(".right") .attr("y", (d) => d.y) .attr("x", (d) => d.x2) .attr("height", (d) => d.height) @@ -361,7 +361,7 @@ export const renderHorizontalWhisker = ( ) .call((g) => g - .select(".bottom") + .select(".left") .attr("y", (d) => d.y) .attr("x", (d) => d.x1) .attr("height", (d) => d.height) From 19f50a02376b044f545582d144689b5e43f0cb63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joa=CC=83o=20Sobral?= <joao.tiago.sobral@gmail.com> Date: Wed, 11 Dec 2024 11:53:02 +0100 Subject: [PATCH 48/54] =?UTF-8?q?fix=20=F0=9F=90=9B:=20adjust=20error=20wh?= =?UTF-8?q?iskers=20position?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/charts/bar/bars.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/charts/bar/bars.tsx b/app/charts/bar/bars.tsx index 201197744..ae997fc58 100644 --- a/app/charts/bar/bars.tsx +++ b/app/charts/bar/bars.tsx @@ -35,7 +35,7 @@ export const ErrorWhiskers = () => { const bandwidth = yScale.bandwidth(); return chartData.filter(getXErrorPresent).map((d, i) => { const y0 = yScale(getY(d)) as number; - const barHeight = Math.min(bandwidth, 15); + const barHeight = Math.min(bandwidth, 16); const [x1, x2] = getXErrorRange(d); return { key: `${i}`, @@ -55,7 +55,7 @@ export const ErrorWhiskers = () => { xScale, yScale, width, - height, + bounds.chartHeight, ]); useEffect(() => { From f938a52ca58cd5195782b0be8d037f1643242c3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joa=CC=83o=20Sobral?= <joao.tiago.sobral@gmail.com> Date: Wed, 11 Dec 2024 11:57:59 +0100 Subject: [PATCH 49/54] =?UTF-8?q?fix=20=F0=9F=90=9B:=20var=20import?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/charts/bar/bars.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/charts/bar/bars.tsx b/app/charts/bar/bars.tsx index ae997fc58..18d7e8042 100644 --- a/app/charts/bar/bars.tsx +++ b/app/charts/bar/bars.tsx @@ -23,7 +23,7 @@ export const ErrorWhiskers = () => { showXUncertainty, bounds, } = useChartState() as BarsState; - const { margins, width, height } = bounds; + const { margins, width, chartHeight } = bounds; const ref = useRef<SVGGElement>(null); const enableTransition = useTransitionStore((state) => state.enable); const transitionDuration = useTransitionStore((state) => state.duration); @@ -55,7 +55,7 @@ export const ErrorWhiskers = () => { xScale, yScale, width, - bounds.chartHeight, + chartHeight, ]); useEffect(() => { From 7695a010dc8c9b3e1648f1513f2dbcc4130beb05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joa=CC=83o=20Sobral?= <joao.tiago.sobral@gmail.com> Date: Wed, 11 Dec 2024 16:08:18 +0100 Subject: [PATCH 50/54] =?UTF-8?q?fix=20=F0=9F=90=9B:=20min=20x=20value=20c?= =?UTF-8?q?ame=20from=20the=20data=20instead=20of=200?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/charts/shared/chart-state.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/charts/shared/chart-state.ts b/app/charts/shared/chart-state.ts index 4be5277bc..69a8ccd91 100644 --- a/app/charts/shared/chart-state.ts +++ b/app/charts/shared/chart-state.ts @@ -324,8 +324,9 @@ export const useNumericalXVariables = ( const getMinX = useCallback( (data: Observation[], _getX: NumericalValueGetter) => { switch (chartType) { - case "scatterplot": case "bar": + return Math.min(0, min(data, _getX) ?? 0); + case "scatterplot": return shouldUseDynamicMinScaleValue(xMeasure.scaleType) ? min(data, _getX) ?? 0 : Math.min(0, min(data, _getX) ?? 0); From 7cdff4ffb5b017dd91215d29de33189efe2dd75f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joa=CC=83o=20Sobral?= <joao.tiago.sobral@gmail.com> Date: Wed, 11 Dec 2024 16:12:29 +0100 Subject: [PATCH 51/54] =?UTF-8?q?feat=20=E2=9A=A1=EF=B8=8F:=20add=20missin?= =?UTF-8?q?g=20bar=20fields=20on=2018n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/configurator/components/field-i18n.ts | 50 +++++++++++++++-------- 1 file changed, 34 insertions(+), 16 deletions(-) diff --git a/app/configurator/components/field-i18n.ts b/app/configurator/components/field-i18n.ts index b931f7d8d..c08e34abf 100644 --- a/app/configurator/components/field-i18n.ts +++ b/app/configurator/components/field-i18n.ts @@ -40,6 +40,14 @@ const fieldLabels = { id: "controls.column.grouped", message: "Grouped", }), + "controls.bar.stacked": defineMessage({ + id: "controls.bar.stacked", + message: "Stacked", + }), + "controls.bar.grouped": defineMessage({ + id: "controls.bar.grouped", + message: "Grouped", + }), "chart.map.layers.base": defineMessage({ id: "chart.map.layers.base", message: "Map Display", @@ -214,6 +222,7 @@ export function getFieldLabel(field: string): string { switch (field) { // Visual encodings (left column) case "column.x": + case "bar.x": case "line.x": case "area.x": case "scatterplot.x": @@ -228,6 +237,7 @@ export function getFieldLabel(field: string): string { return i18n._(fieldLabels["controls.measure"]); case "scatterplot.y": case "column.y": + case "bar.y": case "line.y": case "area.y": case "bar.y": @@ -236,8 +246,8 @@ export function getFieldLabel(field: string): string { case "comboLineColumn.y": case "y": return i18n._(fieldLabels["controls.axis.vertical"]); - case "bar.animation": case "column.animation": + case "bar.animation": case "line.animation": case "area.animation": case "scatterplot.animation": @@ -245,8 +255,8 @@ export function getFieldLabel(field: string): string { case "map.animation": case "animation": return i18n._(fieldLabels["controls.animation"]); - case "bar.segment": case "column.segment": + case "bar.segment": case "line.segment": case "area.segment": case "scatterplot.segment": @@ -285,11 +295,12 @@ export function getFieldLabel(field: string): string { case "percent": return i18n._(fieldLabels["controls.calculation.percent"]); - case "bar.stacked.byDimensionLabel.asc": - case "bar.grouped.byDimensionLabel.asc": case "column..byDimensionLabel.asc": case "column.stacked.byDimensionLabel.asc": case "column.grouped.byDimensionLabel.asc": + case "bar..byDimensionLabel.asc": + case "bar.stacked.byDimensionLabel.asc": + case "bar.grouped.byDimensionLabel.asc": case "area..byDimensionLabel.asc": // for existing charts compatibility case "area.stacked.byDimensionLabel.asc": @@ -297,31 +308,32 @@ export function getFieldLabel(field: string): string { case "line..byDimensionLabel.asc": case "sorting.byDimensionLabel.asc": return i18n._(fieldLabels["controls.sorting.byDimensionLabel.ascending"]); - case "bar..byAuto.asc": - case "bar.stacked.byAuto.asc": - case "bar.grouped.byAuto.asc": case "column..byAuto.asc": case "column.stacked.byAuto.asc": case "column.grouped.byAuto.asc": + case "bar..byAuto.asc": + case "bar.stacked.byAuto.asc": + case "bar.grouped.byAuto.asc": case "pie..byAuto.asc": case "line..byAuto.asc": case "area..byAuto.asc": return i18n._(fieldLabels["controls.sorting.byAuto.ascending"]); - case "bar..byAuto.desc": - case "bar.stacked.byAuto.desc": - case "bar.grouped.byAuto.desc": case "column..byAuto.desc": case "column.stacked.byAuto.desc": case "column.grouped.byAuto.desc": + case "bar..byAuto.desc": + case "bar.stacked.byAuto.desc": + case "bar.grouped.byAuto.desc": case "pie..byAuto.desc": case "line..byAuto.desc": case "area..byAuto.desc": return i18n._(fieldLabels["controls.sorting.byAuto.descending"]); - case "bar.stacked.byDimensionLabel.desc": - case "bar.grouped.byDimensionLabel.desc": case "column..byDimensionLabel.desc": case "column.stacked.byDimensionLabel.desc": case "column.grouped.byDimensionLabel.desc": + case "bar..byDimensionLabel.desc": + case "bar.stacked.byDimensionLabel.desc": + case "bar.grouped.byDimensionLabel.desc": case "area..byDimensionLabel.desc": // for existing charts compatibility case "area.stacked.byDimensionLabel.desc": @@ -331,33 +343,39 @@ export function getFieldLabel(field: string): string { return i18n._( fieldLabels["controls.sorting.byDimensionLabel.descending"] ); - case "bar.stacked.byTotalSize.desc": - case "bar.grouped.byTotalSize.desc": case "column.grouped.byTotalSize.asc": + case "bar.grouped.byTotalSize.asc": return i18n._(fieldLabels["controls.sorting.byTotalSize.ascending"]); case "column.grouped.byTotalSize.desc": - case "bar.stacked.byTotalSize.asc": - case "bar.grouped.byTotalSize.asc": + case "bar.grouped.byTotalSize.desc": return i18n._(fieldLabels["controls.sorting.byTotalSize.largestFirst"]); case "area..byTotalSize.asc": // for existing charts compatibility case "area.stacked.byTotalSize.asc": case "column.stacked.byTotalSize.asc": + case "bar.stacked.byTotalSize.asc": return i18n._(fieldLabels["controls.sorting.byTotalSize.largestTop"]); case "area..byTotalSize.desc": // for existing charts compatibility case "area.stacked.byTotalSize.desc": case "column.stacked.byTotalSize.desc": + case "bar.stacked.byTotalSize.desc": return i18n._(fieldLabels["controls.sorting.byTotalSize.largestBottom"]); case "column..byMeasure.asc": case "column.stacked.byMeasure.asc": case "column.grouped.byMeasure.asc": + case "bar..byMeasure.asc": + case "bar.stacked.byMeasure.asc": + case "bar.grouped.byMeasure.asc": case "pie..byMeasure.asc": case "sorting.byMeasure.asc": return i18n._(fieldLabels["controls.sorting.byMeasure.ascending"]); case "column..byMeasure.desc": case "column.stacked.byMeasure.desc": case "column.grouped.byMeasure.desc": + case "bar..byMeasure.desc": + case "bar.stacked.byMeasure.desc": + case "bar.grouped.byMeasure.desc": case "pie..byMeasure.desc": case "sorting.byMeasure.desc": return i18n._(fieldLabels["controls.sorting.byMeasure.descending"]); From 9c902df94976ffd8419c861e93c6fbe1346b7459 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joa=CC=83o=20Sobral?= <joao.tiago.sobral@gmail.com> Date: Wed, 11 Dec 2024 16:38:43 +0100 Subject: [PATCH 52/54] =?UTF-8?q?feat=20=E2=9A=A1=EF=B8=8F:=20remove=20bar?= =?UTF-8?q?=20instead=20of=20hiding=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/charts/shared/axis-width-linear.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/app/charts/shared/axis-width-linear.tsx b/app/charts/shared/axis-width-linear.tsx index aa8a5626d..5a22fe3bf 100644 --- a/app/charts/shared/axis-width-linear.tsx +++ b/app/charts/shared/axis-width-linear.tsx @@ -65,9 +65,10 @@ export const AxisWidthLinear = () => { .attr("fill", labelColor) .attr("dy", labelFontSize + 10) .attr("text-anchor", "middle"); - g.select("path.domain") - .attr("stroke", gridColor) - .attr("opacity", chartType === "bar" ? 0 : 1); + g.select("path.domain").attr("stroke", gridColor); + if (chartType === "bar") { + g.select("path.domain").remove(); + } } }, [ chartType, From a3ef3e30047edc740ca7a723ae1a5243316c3b92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joa=CC=83o=20Sobral?= <joao.tiago.sobral@gmail.com> Date: Wed, 11 Dec 2024 17:01:39 +0100 Subject: [PATCH 53/54] =?UTF-8?q?fix=20=F0=9F=90=9B:=20bars=20not=20adjust?= =?UTF-8?q?ing=20when=20width=20changed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/charts/bar/bars.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/charts/bar/bars.tsx b/app/charts/bar/bars.tsx index 18d7e8042..5522a0134 100644 --- a/app/charts/bar/bars.tsx +++ b/app/charts/bar/bars.tsx @@ -112,7 +112,9 @@ export const Bars = () => { color, }; }); + // eslint-disable-next-line react-hooks/exhaustive-deps }, [ + bounds.width, chartData, bandwidth, getX, From e7e620a39ea3292be8e83be6725b14af235b111b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joa=CC=83o=20Sobral?= <joao.tiago.sobral@gmail.com> Date: Wed, 11 Dec 2024 17:15:37 +0100 Subject: [PATCH 54/54] =?UTF-8?q?fix=20=F0=9F=90=9B:=20don't=20reverse=20y?= =?UTF-8?q?Scale?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/charts/bar/bars-state.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/charts/bar/bars-state.tsx b/app/charts/bar/bars-state.tsx index 7cd5e64af..e85ad2b5f 100644 --- a/app/charts/bar/bars-state.tsx +++ b/app/charts/bar/bars-state.tsx @@ -132,7 +132,7 @@ const useBarsState = ( .paddingInner(PADDING_INNER) .paddingOuter(PADDING_OUTER); const yScaleInteraction = scaleBand() - .domain(bandDomain.reverse()) + .domain(bandDomain) .paddingInner(0) .paddingOuter(0); @@ -226,7 +226,7 @@ const useBarsState = ( xScale.range([0, chartWidth]); yScaleInteraction.range([0, adjustedHeight]); yScaleTimeRange.range([0, adjustedHeight]); - yScale.range([adjustedHeight, 0]); + yScale.range([0, adjustedHeight]); const isMobile = useIsMobile();