diff --git a/CHANGELOG.md b/CHANGELOG.md index dc324c66b..5abd09a5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,8 @@ You can also check the [release page](https://github.com/visualize-admin/visuali ## Unreleased -Nothing yet. +- Features + - Added a new group of charts – Combo charts – that includes multi-measure line, dual-axis line and column-line charts # [3.22.8] - 2023-09-29 diff --git a/app/charts/area/areas-state.tsx b/app/charts/area/areas-state.tsx index 5b677fc58..868c9596e 100644 --- a/app/charts/area/areas-state.tsx +++ b/app/charts/area/areas-state.tsx @@ -68,6 +68,7 @@ export type AreasState = CommonChartState & yScale: ScaleLinear; segments: string[]; colors: ScaleOrdinal; + getColorLabel: (segment: string) => string; chartWideData: ArrayLike; series: $FixMe[]; getAnnotationInfo: (d: Observation) => TooltipInfo; @@ -89,6 +90,7 @@ const useAreasState = ( segmentsByAbbreviationOrLabel, getSegment, getSegmentAbbreviationOrLabel, + getSegmentLabel, } = variables; const getIdentityY = useGetIdentityY(yMeasure.iri); const { @@ -230,15 +232,14 @@ const useAreasState = ( }, [chartWideData, segmentSortingOrder, segmentSortingType, segments]); /** Scales */ - const { colors, xScale, interactiveXTimeRangeScale } = useMemo(() => { + const { colors, xScale, xScaleTimeRange } = useMemo(() => { const xDomain = extent(scalesData, (d) => getX(d)) as [Date, Date]; const xScale = scaleTime().domain(xDomain); - const interactiveXTimeRangeDomain = extent(timeRangeData, (d) => - getX(d) - ) as [Date, Date]; - const interactiveXTimeRangeScale = scaleTime().domain( - interactiveXTimeRangeDomain - ); + const xScaleTimeRangeDomain = extent(timeRangeData, (d) => getX(d)) as [ + Date, + Date + ]; + const xScaleTimeRange = scaleTime().domain(xScaleTimeRangeDomain); const colors = scaleOrdinal(); if (segmentDimension && fields.segment?.colorMapping) { @@ -266,7 +267,7 @@ const useAreasState = ( return { colors, xScale, - interactiveXTimeRangeScale, + xScaleTimeRange, }; }, [ fields.segment?.palette, @@ -339,7 +340,7 @@ const useAreasState = ( /** Adjust scales according to dimensions */ xScale.range([0, chartWidth]); - interactiveXTimeRangeScale.range([0, chartWidth]); + xScaleTimeRange.range([0, chartWidth]); yScale.range([chartHeight, 0]); /** Tooltip */ @@ -372,7 +373,7 @@ const useAreasState = ( placement: getCenteredTooltipPlacement({ chartWidth, xAnchor, - segment: !!fields.segment, + topAnchor: !fields.segment, }), xValue: timeFormatUnit(getX(datum), xDimension.timeUnit), datum: { @@ -419,10 +420,11 @@ const useAreasState = ( chartData, allData, xScale, - interactiveXTimeRangeScale, + xScaleTimeRange, yScale, segments, colors, + getColorLabel: getSegmentLabel, chartWideData, series, getAnnotationInfo, diff --git a/app/charts/chart-config-ui-options.ts b/app/charts/chart-config-ui-options.ts index eed888d9f..88d813b61 100644 --- a/app/charts/chart-config-ui-options.ts +++ b/app/charts/chart-config-ui-options.ts @@ -19,6 +19,9 @@ import { ColorScaleType, ColumnConfig, ColumnSegmentField, + ComboLineColumnConfig, + ComboLineDualConfig, + ComboLineSingleConfig, ComponentType, GenericField, LineConfig, @@ -216,6 +219,8 @@ export interface EncodingSpec { field: EncodingFieldType; optional: boolean; componentTypes: ComponentType[]; + /** If true, won't use the ChartFieldOption component, but a custom one. Needs to be handled then in ChartOptionsSelector. */ + customComponent?: boolean; /** If false, using a dimension in this encoding will not prevent it to be used in an other encoding. Default: true */ exclusive?: boolean; filters: boolean; @@ -256,6 +261,9 @@ interface ChartSpecs { pie: ChartSpec; scatterplot: ChartSpec; table: ChartSpec; + comboLineSingle: ChartSpec; + comboLineDual: ChartSpec; + comboLineColumn: ChartSpec; } const SEGMENT_COMPONENT_TYPES: ComponentType[] = [ @@ -872,6 +880,64 @@ const chartConfigOptionsUISpec: ChartSpecs = { encodings: [], interactiveFilters: [], }, + comboLineSingle: { + chartType: "comboLineSingle", + encodings: [ + { + field: "y", + optional: false, + // TODO: maybe we should even create the components here? + customComponent: true, + componentTypes: ["NumericalMeasure"], + filters: false, + }, + { + field: "x", + optional: false, + componentTypes: ["TemporalDimension"], + filters: true, + }, + ], + interactiveFilters: [], + }, + comboLineDual: { + chartType: "comboLineDual", + encodings: [ + { + field: "y", + optional: false, + customComponent: true, + componentTypes: ["NumericalMeasure"], + filters: false, + }, + { + field: "x", + optional: false, + componentTypes: ["TemporalDimension"], + filters: true, + }, + ], + interactiveFilters: [], + }, + comboLineColumn: { + chartType: "comboLineColumn", + encodings: [ + { + field: "y", + optional: false, + customComponent: true, + componentTypes: ["NumericalMeasure"], + filters: false, + }, + { + field: "x", + optional: false, + componentTypes: ["TemporalDimension"], + filters: true, + }, + ], + interactiveFilters: [], + }, }; export const getChartFieldChangeSideEffect = ( diff --git a/app/charts/column/columns-grouped-state.tsx b/app/charts/column/columns-grouped-state.tsx index c26cb5598..57d658c2d 100644 --- a/app/charts/column/columns-grouped-state.tsx +++ b/app/charts/column/columns-grouped-state.tsx @@ -63,6 +63,7 @@ export type GroupedColumnsState = CommonChartState & yScale: ScaleLinear; segments: string[]; colors: ScaleOrdinal; + getColorLabel: (segment: string) => string; grouped: [string, Observation[]][]; getAnnotationInfo: (d: Observation) => TooltipInfo; }; @@ -89,6 +90,7 @@ const useColumnsGroupedState = ( segmentsByAbbreviationOrLabel, getSegment, getSegmentAbbreviationOrLabel, + getSegmentLabel, } = variables; const { chartData, @@ -178,7 +180,7 @@ const useColumnsGroupedState = ( colors, yScale, paddingYScale, - interactiveXTimeRangeScale, + xScaleTimeRange, xScale, xScaleIn, xScaleInteraction, @@ -232,12 +234,10 @@ const useColumnsGroupedState = ( .paddingOuter(0); const xScaleIn = scaleBand().domain(segments).padding(PADDING_WITHIN); - const interactiveXTimeRangeDomain = extent(timeRangeData, (d) => + const xScaleTimeRangeDomain = extent(timeRangeData, (d) => getXAsDate(d) ) as [Date, Date]; - const interactiveXTimeRangeScale = scaleTime().domain( - interactiveXTimeRangeDomain - ); + const xScaleTimeRange = scaleTime().domain(xScaleTimeRangeDomain); // y const minValue = Math.min( @@ -274,7 +274,7 @@ const useColumnsGroupedState = ( colors, yScale, paddingYScale, - interactiveXTimeRangeScale, + xScaleTimeRange, xScale, xScaleIn, xScaleInteraction, @@ -352,7 +352,7 @@ const useColumnsGroupedState = ( xScale.range([0, chartWidth]); xScaleInteraction.range([0, chartWidth]); xScaleIn.range([0, xScale.bandwidth()]); - interactiveXTimeRangeScale.range([0, chartWidth]); + xScaleTimeRange.range([0, chartWidth]); yScale.range([chartHeight, 0]); // Tooltip @@ -383,7 +383,7 @@ const useColumnsGroupedState = ( const placement = getCenteredTooltipPlacement({ chartWidth, xAnchor: xAnchorRaw, - segment: !!fields.segment, + topAnchor: !fields.segment, }); const getError = (d: Observation) => { @@ -424,10 +424,11 @@ const useColumnsGroupedState = ( xScale, xScaleInteraction, xScaleIn, - interactiveXTimeRangeScale, + xScaleTimeRange, yScale, segments, colors, + getColorLabel: getSegmentLabel, grouped, getAnnotationInfo, ...variables, diff --git a/app/charts/column/columns-stacked-state.tsx b/app/charts/column/columns-stacked-state.tsx index f28526093..3565e5135 100644 --- a/app/charts/column/columns-stacked-state.tsx +++ b/app/charts/column/columns-stacked-state.tsx @@ -71,6 +71,7 @@ export type StackedColumnsState = CommonChartState & yScale: ScaleLinear; segments: string[]; colors: ScaleOrdinal; + getColorLabel: (segment: string) => string; chartWideData: ArrayLike; series: $FixMe[]; getAnnotationInfo: ( @@ -97,6 +98,7 @@ const useColumnsStackedState = ( segmentsByAbbreviationOrLabel, getSegment, getSegmentAbbreviationOrLabel, + getSegmentLabel, } = variables; const getIdentityY = useGetIdentityY(yMeasure.iri); const { @@ -210,7 +212,7 @@ const useColumnsStackedState = ( xScale, xTimeRangeDomainLabels, xScaleInteraction, - interactiveXTimeRangeScale, + xScaleTimeRange, } = useMemo(() => { const colors = scaleOrdinal(); @@ -271,18 +273,16 @@ const useColumnsStackedState = ( .paddingInner(0) .paddingOuter(0); - const interactiveXTimeRangeDomain = extent(timeRangeData, (d) => + const xScaleTimeRangeDomain = extent(timeRangeData, (d) => getXAsDate(d) ) as [Date, Date]; - const interactiveXTimeRangeScale = scaleTime().domain( - interactiveXTimeRangeDomain - ); + const xScaleTimeRange = scaleTime().domain(xScaleTimeRangeDomain); return { colors, xScale, xTimeRangeDomainLabels, - interactiveXTimeRangeScale, + xScaleTimeRange, xScaleInteraction, }; }, [ @@ -400,7 +400,7 @@ const useColumnsStackedState = ( xScale.range([0, chartWidth]); xScaleInteraction.range([0, chartWidth]); - interactiveXTimeRangeScale.range([0, chartWidth]); + xScaleTimeRange.range([0, chartWidth]); yScale.range([chartHeight, 0]); // Tooltips @@ -430,7 +430,7 @@ const useColumnsStackedState = ( const placement = getCenteredTooltipPlacement({ chartWidth, xAnchor: xAnchorRaw, - segment: !!fields.segment, + topAnchor: !fields.segment, }); return { @@ -479,10 +479,11 @@ const useColumnsStackedState = ( allData, xScale, xScaleInteraction, - interactiveXTimeRangeScale, + xScaleTimeRange, yScale, segments, colors, + getColorLabel: getSegmentLabel, chartWideData, series, getAnnotationInfo, diff --git a/app/charts/column/columns-state.tsx b/app/charts/column/columns-state.tsx index 5358d5dce..8e62f67cd 100644 --- a/app/charts/column/columns-state.tsx +++ b/app/charts/column/columns-state.tsx @@ -100,7 +100,7 @@ const useColumnsState = ( xScale, yScale, paddingYScale, - interactiveXTimeRangeScale, + xScaleTimeRange, xScaleInteraction, xTimeRangeDomainLabels, } = useMemo(() => { @@ -129,13 +129,11 @@ const useColumnsState = ( .paddingInner(0) .paddingOuter(0); - const interactiveXTimeRangeDomain = extent(timeRangeData, (d) => + const xScaleTimeRangeDomain = extent(timeRangeData, (d) => getXAsDate(d) ) as [Date, Date]; - const interactiveXTimeRangeScale = scaleTime().domain( - interactiveXTimeRangeDomain - ); + const xScaleTimeRange = scaleTime().domain(xScaleTimeRangeDomain); const minValue = Math.min( min(scalesData, (d) => @@ -171,7 +169,7 @@ const useColumnsState = ( xScale, yScale, paddingYScale, - interactiveXTimeRangeScale, + xScaleTimeRange, xScaleInteraction, xTimeRangeDomainLabels, }; @@ -211,7 +209,7 @@ const useColumnsState = ( xScale.range([0, chartWidth]); xScaleInteraction.range([0, chartWidth]); - interactiveXTimeRangeScale.range([0, chartWidth]); + xScaleTimeRange.range([0, chartWidth]); yScale.range([chartHeight, 0]); // Tooltip @@ -241,7 +239,7 @@ const useColumnsState = ( placement: getCenteredTooltipPlacement({ chartWidth, xAnchor, - segment: !!fields.segment, + topAnchor: !fields.segment, }), xValue: xTimeUnit ? timeFormatUnit(xLabel, xTimeUnit) : xLabel, datum: { @@ -260,7 +258,7 @@ const useColumnsState = ( chartData, allData, xScale, - interactiveXTimeRangeScale, + xScaleTimeRange, xScaleInteraction, yScale, getAnnotationInfo, diff --git a/app/charts/column/overlay-columns.tsx b/app/charts/column/overlay-columns.tsx index da85ba825..222ec72fb 100644 --- a/app/charts/column/overlay-columns.tsx +++ b/app/charts/column/overlay-columns.tsx @@ -1,4 +1,5 @@ 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"; @@ -6,8 +7,9 @@ import { Observation } from "@/domain/data"; export const InteractionColumns = () => { const [, dispatch] = useInteraction(); - const { chartData, bounds, getX, xScaleInteraction } = - useChartState() as ColumnsState; + const { chartData, bounds, getX, xScaleInteraction } = useChartState() as + | ColumnsState + | ComboLineColumnState; const { margins, chartHeight } = bounds; const showTooltip = (d: Observation) => { diff --git a/app/charts/combo/axis-height-linear-dual.tsx b/app/charts/combo/axis-height-linear-dual.tsx new file mode 100644 index 000000000..68ef19225 --- /dev/null +++ b/app/charts/combo/axis-height-linear-dual.tsx @@ -0,0 +1,68 @@ +import { alpha } from "@mui/material"; +import React from "react"; + +import { ComboLineColumnState } from "@/charts/combo/combo-line-column-state"; +import { ComboLineDualState } from "@/charts/combo/combo-line-dual-state"; +import { + TICK_PADDING, + useRenderAxisHeightLinear, +} from "@/charts/shared/axis-height-linear"; +import { useChartState } from "@/charts/shared/chart-state"; +import { useChartTheme } from "@/charts/shared/use-chart-theme"; +import { OpenMetadataPanelWrapper } from "@/components/metadata-panel"; +import { getTextWidth } from "@/utils/get-text-width"; + +type AxisHeightLinearDualProps = { + orientation?: "left" | "right"; +}; + +export const AxisHeightLinearDual = (props: AxisHeightLinearDualProps) => { + const { orientation = "left" } = props; + const leftAligned = orientation === "left"; + const { axisLabelFontSize } = useChartTheme(); + const [ref, setRef] = React.useState(null); + const { y, yOrientationScales, colors, bounds, maxRightTickWidth } = + useChartState() as ComboLineDualState | ComboLineColumnState; + const yScale = yOrientationScales[orientation]; + const { margins } = bounds; + const axisTitle = y[orientation].label; + const axisTitleWidth = + getTextWidth(axisTitle, { fontSize: axisLabelFontSize }) + TICK_PADDING; + const color = colors(axisTitle); + + useRenderAxisHeightLinear(ref, { + id: `axis-height-linear-${orientation}`, + orientation: orientation, + scale: yScale, + width: bounds.chartWidth, + height: bounds.chartHeight, + margins: bounds.margins, + lineColor: alpha(color, 0.1), + textColor: color, + }); + + return ( + <> + + + {axisTitle} + + + setRef(newRef)} /> + + ); +}; diff --git a/app/charts/combo/chart-combo-line-column.tsx b/app/charts/combo/chart-combo-line-column.tsx new file mode 100644 index 000000000..15349658c --- /dev/null +++ b/app/charts/combo/chart-combo-line-column.tsx @@ -0,0 +1,103 @@ +import React from "react"; + +import { ChartLoadingWrapper } from "@/charts/chart-loading-wrapper"; +import { InteractionColumns } from "@/charts/column/overlay-columns"; +import { AxisHeightLinearDual } from "@/charts/combo/axis-height-linear-dual"; +import { ComboLineColumn } from "@/charts/combo/combo-line-column"; +import { ComboLineColumnChart } from "@/charts/combo/combo-line-column-state"; +import { AxisWidthBand } from "@/charts/shared/axis-width-band"; +import { BrushTime } from "@/charts/shared/brush"; +import { extractComponentIris } from "@/charts/shared/chart-helpers"; +import { ChartContainer, ChartSvg } from "@/charts/shared/containers"; +import { HoverDotMultiple } from "@/charts/shared/interaction/hover-dots-multiple"; +import { Ruler } from "@/charts/shared/interaction/ruler"; +import { Tooltip } from "@/charts/shared/interaction/tooltip"; +import { + ComboLineColumnConfig, + DataSource, + QueryFilters, +} from "@/config-types"; +import { + useComponentsQuery, + useDataCubeMetadataQuery, + useDataCubeObservationsQuery, +} from "@/graphql/query-hooks"; +import { useLocale } from "@/locales/use-locale"; + +import { ChartProps } from "../shared/ChartProps"; + +type ChartComboLineColumnVisualizationProps = { + dataSetIri: string; + dataSource: DataSource; + chartConfig: ComboLineColumnConfig; + queryFilters: QueryFilters; + published: boolean; +}; + +export const ChartComboLineColumnVisualization = ( + props: ChartComboLineColumnVisualizationProps +) => { + const { dataSetIri, dataSource, chartConfig, queryFilters, published } = + props; + const locale = useLocale(); + const componentIris = published + ? extractComponentIris(chartConfig) + : undefined; + const commonQueryVariables = { + iri: dataSetIri, + sourceType: dataSource.type, + sourceUrl: dataSource.url, + locale, + }; + const [metadataQuery] = useDataCubeMetadataQuery({ + variables: commonQueryVariables, + }); + const [componentsQuery] = useComponentsQuery({ + variables: { + ...commonQueryVariables, + componentIris, + }, + }); + const [observationsQuery] = useDataCubeObservationsQuery({ + variables: { + ...commonQueryVariables, + componentIris, + filters: queryFilters, + }, + }); + + return ( + + ); +}; + +export const ChartComboLineColumn = React.memo( + (props: ChartProps) => { + const { chartConfig } = props; + const { interactiveFiltersConfig } = chartConfig; + + return ( + + + + + + + + + {interactiveFiltersConfig?.timeRange.active && } + + + + + + + ); + } +); diff --git a/app/charts/combo/chart-combo-line-dual.tsx b/app/charts/combo/chart-combo-line-dual.tsx new file mode 100644 index 000000000..d24eceab6 --- /dev/null +++ b/app/charts/combo/chart-combo-line-dual.tsx @@ -0,0 +1,99 @@ +import React from "react"; + +import { ChartLoadingWrapper } from "@/charts/chart-loading-wrapper"; +import { AxisHeightLinearDual } from "@/charts/combo/axis-height-linear-dual"; +import { ComboLineDual } from "@/charts/combo/combo-line-dual"; +import { ComboLineDualChart } from "@/charts/combo/combo-line-dual-state"; +import { AxisTime, AxisTimeDomain } from "@/charts/shared/axis-width-time"; +import { BrushTime } from "@/charts/shared/brush"; +import { extractComponentIris } from "@/charts/shared/chart-helpers"; +import { ChartContainer, ChartSvg } from "@/charts/shared/containers"; +import { HoverDotMultiple } from "@/charts/shared/interaction/hover-dots-multiple"; +import { Ruler } from "@/charts/shared/interaction/ruler"; +import { Tooltip } from "@/charts/shared/interaction/tooltip"; +import { InteractionHorizontal } from "@/charts/shared/overlay-horizontal"; +import { ComboLineDualConfig, DataSource, QueryFilters } from "@/config-types"; +import { + useComponentsQuery, + useDataCubeMetadataQuery, + useDataCubeObservationsQuery, +} from "@/graphql/query-hooks"; +import { useLocale } from "@/locales/use-locale"; + +import { ChartProps } from "../shared/ChartProps"; + +type ChartComboLineDualVisualizationProps = { + dataSetIri: string; + dataSource: DataSource; + chartConfig: ComboLineDualConfig; + queryFilters: QueryFilters; + published: boolean; +}; + +export const ChartComboLineDualVisualization = ( + props: ChartComboLineDualVisualizationProps +) => { + const { dataSetIri, dataSource, chartConfig, queryFilters, published } = + props; + const locale = useLocale(); + const componentIris = published + ? extractComponentIris(chartConfig) + : undefined; + const commonQueryVariables = { + iri: dataSetIri, + sourceType: dataSource.type, + sourceUrl: dataSource.url, + locale, + }; + const [metadataQuery] = useDataCubeMetadataQuery({ + variables: commonQueryVariables, + }); + const [componentsQuery] = useComponentsQuery({ + variables: { + ...commonQueryVariables, + componentIris, + }, + }); + const [observationsQuery] = useDataCubeObservationsQuery({ + variables: { + ...commonQueryVariables, + componentIris, + filters: queryFilters, + }, + }); + + return ( + + ); +}; + +export const ChartComboLineDual = React.memo( + (props: ChartProps) => { + const { chartConfig } = props; + const { interactiveFiltersConfig } = chartConfig; + + return ( + + + + + + + + + {interactiveFiltersConfig?.timeRange.active && } + + + + + + + ); + } +); diff --git a/app/charts/combo/chart-combo-line-single.tsx b/app/charts/combo/chart-combo-line-single.tsx new file mode 100644 index 000000000..4697b63c4 --- /dev/null +++ b/app/charts/combo/chart-combo-line-single.tsx @@ -0,0 +1,109 @@ +import React from "react"; + +import { ChartLoadingWrapper } from "@/charts/chart-loading-wrapper"; +import { ComboLineSingle } from "@/charts/combo/combo-line-single"; +import { ComboLineSingleChart } from "@/charts/combo/combo-line-single-state"; +import { AxisHeightLinear } from "@/charts/shared/axis-height-linear"; +import { AxisTime, AxisTimeDomain } from "@/charts/shared/axis-width-time"; +import { BrushTime } from "@/charts/shared/brush"; +import { extractComponentIris } from "@/charts/shared/chart-helpers"; +import { + ChartContainer, + ChartControlsContainer, + ChartSvg, +} from "@/charts/shared/containers"; +import { HoverDotMultiple } from "@/charts/shared/interaction/hover-dots-multiple"; +import { Ruler } from "@/charts/shared/interaction/ruler"; +import { Tooltip } from "@/charts/shared/interaction/tooltip"; +import { LegendColor } from "@/charts/shared/legend-color"; +import { InteractionHorizontal } from "@/charts/shared/overlay-horizontal"; +import { + ComboLineSingleConfig, + DataSource, + QueryFilters, +} from "@/config-types"; +import { + useComponentsQuery, + useDataCubeMetadataQuery, + useDataCubeObservationsQuery, +} from "@/graphql/query-hooks"; +import { useLocale } from "@/locales/use-locale"; + +import { ChartProps } from "../shared/ChartProps"; + +type ChartComboLineSingleVisualizationProps = { + dataSetIri: string; + dataSource: DataSource; + chartConfig: ComboLineSingleConfig; + queryFilters: QueryFilters; + published: boolean; +}; + +export const ChartComboLineSingleVisualization = ( + props: ChartComboLineSingleVisualizationProps +) => { + const { dataSetIri, dataSource, chartConfig, queryFilters, published } = + props; + const locale = useLocale(); + const componentIris = published + ? extractComponentIris(chartConfig) + : undefined; + const commonQueryVariables = { + iri: dataSetIri, + sourceType: dataSource.type, + sourceUrl: dataSource.url, + locale, + }; + const [metadataQuery] = useDataCubeMetadataQuery({ + variables: commonQueryVariables, + }); + const [componentsQuery] = useComponentsQuery({ + variables: { + ...commonQueryVariables, + componentIris, + }, + }); + const [observationsQuery] = useDataCubeObservationsQuery({ + variables: { + ...commonQueryVariables, + componentIris, + filters: queryFilters, + }, + }); + + return ( + + ); +}; + +export const ChartComboLineSingle = React.memo( + (props: ChartProps) => { + const { chartConfig } = props; + const { interactiveFiltersConfig } = chartConfig; + + return ( + + + + + + + {interactiveFiltersConfig?.timeRange.active && } + + + + + + + + + + ); + } +); diff --git a/app/charts/combo/combo-line-column-state-props.ts b/app/charts/combo/combo-line-column-state-props.ts new file mode 100644 index 000000000..34807c87a --- /dev/null +++ b/app/charts/combo/combo-line-column-state-props.ts @@ -0,0 +1,145 @@ +import React from "react"; + +import { BaseYGetter, sortData } from "@/charts/combo/combo-state-props"; +import { + getLabelWithUnit, + usePlottableData, +} from "@/charts/shared/chart-helpers"; +import { + BandXVariables, + BaseVariables, + ChartStateData, + RenderingVariables, + useBandXVariables, + useBaseVariables, + useChartData, +} from "@/charts/shared/chart-state"; +import { useRenderingKeyVariable } from "@/charts/shared/rendering-utils"; +import { ComboLineColumnConfig } from "@/configurator"; + +import { ChartProps } from "../shared/ChartProps"; + +type NumericalYComboLineColumnVariables = { + y: { + left: YGetter; + right: YGetter; + }; +}; + +type YGetter = BaseYGetter & { + chartType: "line" | "column"; + orientation: "left" | "right"; +}; + +export type ComboLineColumnStateVariables = BaseVariables & + BandXVariables & + NumericalYComboLineColumnVariables & + RenderingVariables; + +export const useComboLineColumnStateVariables = ( + props: ChartProps & { aspectRatio: number } +): ComboLineColumnStateVariables => { + const { + chartConfig, + dimensions, + dimensionsByIri, + measuresByIri, + observations, + } = props; + const { fields, filters, interactiveFiltersConfig } = chartConfig; + const { x } = fields; + + const baseVariables = useBaseVariables(chartConfig); + const bandXVariables = useBandXVariables(x, { + dimensionsByIri, + observations, + }); + + const lineIri = chartConfig.fields.y.lineComponentIri; + const lineAxisOrientation = chartConfig.fields.y.lineAxisOrientation; + const columnIri = chartConfig.fields.y.columnComponentIri; + let numericalYVariables: NumericalYComboLineColumnVariables; + const lineYGetter: YGetter = { + chartType: "line", + orientation: lineAxisOrientation, + dimension: measuresByIri[lineIri], + iri: lineIri, + label: getLabelWithUnit(measuresByIri[lineIri]), + getY: (d) => { + return d[lineIri] !== null ? Number(d[lineIri]) : null; + }, + }; + const columnYGetter: YGetter = { + chartType: "column", + orientation: lineAxisOrientation === "left" ? "right" : "left", + dimension: measuresByIri[columnIri], + iri: columnIri, + label: getLabelWithUnit(measuresByIri[columnIri]), + getY: (d) => { + return d[columnIri] !== null ? Number(d[columnIri]) : null; + }, + }; + + numericalYVariables = + lineAxisOrientation === "left" + ? { + y: { + left: lineYGetter, + right: columnYGetter, + }, + } + : { + y: { + left: columnYGetter, + right: lineYGetter, + }, + }; + + const getRenderingKey = useRenderingKeyVariable( + dimensions, + filters, + interactiveFiltersConfig, + undefined + ); + + return { + ...baseVariables, + ...bandXVariables, + ...numericalYVariables, + getRenderingKey, + }; +}; + +export const useComboLineColumnStateData = ( + chartProps: ChartProps & { aspectRatio: number }, + variables: ComboLineColumnStateVariables +): ChartStateData => { + const { chartConfig, observations } = chartProps; + const { getX, getXAsDate, y } = variables; + const plottableData = usePlottableData(observations, { + getX, + getY: (d) => { + for (const { getY } of [y.left, y.right]) { + const y = getY(d); + + if (y !== null) { + return y; + } + } + }, + }); + const sortedPlottableData = React.useMemo(() => { + return sortData(plottableData, { + getX: getXAsDate, + }); + }, [plottableData, getXAsDate]); + const data = useChartData(sortedPlottableData, { + chartConfig, + getXAsDate, + }); + + return { + ...data, + allData: sortedPlottableData, + }; +}; diff --git a/app/charts/combo/combo-line-column-state.tsx b/app/charts/combo/combo-line-column-state.tsx new file mode 100644 index 000000000..c5a21bdc6 --- /dev/null +++ b/app/charts/combo/combo-line-column-state.tsx @@ -0,0 +1,239 @@ +import * as d3 from "d3"; +import React from "react"; + +import { PADDING_INNER, PADDING_OUTER } from "@/charts/column/constants"; +import { + ComboLineColumnStateVariables, + useComboLineColumnStateData, + useComboLineColumnStateVariables, +} from "@/charts/combo/combo-line-column-state-props"; +import { + adjustScales, + getMargins, + useCommonComboState, + useYScales, +} from "@/charts/combo/combo-state"; +import { TICK_PADDING } from "@/charts/shared/axis-height-linear"; +import { + getChartBounds, + 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 } from "@/charts/shared/interaction/tooltip-box"; +import { getTickNumber } from "@/charts/shared/ticks"; +import { TICK_FONT_SIZE } from "@/charts/shared/use-chart-theme"; +import { InteractionProvider } from "@/charts/shared/use-interaction"; +import { Observer } from "@/charts/shared/use-width"; +import { ComboLineColumnConfig } from "@/configurator"; +import { Observation } from "@/domain/data"; +import { useFormatFullDateAuto } from "@/formatters"; +import { TimeUnit } from "@/graphql/resolver-types"; +import { getTimeInterval } from "@/intervals"; +import { getTextWidth } from "@/utils/get-text-width"; + +import { ChartProps } from "../shared/ChartProps"; + +export type ComboLineColumnState = CommonChartState & + ComboLineColumnStateVariables & + InteractiveXTimeRangeState & { + chartType: "comboLineColumn"; + xKey: string; + xScale: d3.ScaleBand; + xScaleTime: d3.ScaleTime; + xScaleInteraction: d3.ScaleBand; + yScale: d3.ScaleLinear; + yOrientationScales: { + left: d3.ScaleLinear; + right: d3.ScaleLinear; + }; + colors: d3.ScaleOrdinal; + getColorLabel: (label: string) => string; + chartWideData: ArrayLike; + getAnnotationInfo: (d: Observation) => TooltipInfo; + maxRightTickWidth: number; + }; + +const useComboLineColumnState = ( + chartProps: ChartProps & { aspectRatio: number }, + variables: ComboLineColumnStateVariables, + data: ChartStateData +): ComboLineColumnState => { + const { chartConfig, aspectRatio } = chartProps; + const { getX, getXAsDate } = variables; + const { chartData, scalesData, timeRangeData, paddingData, allData } = data; + const { fields, interactiveFiltersConfig } = chartConfig; + const xKey = fields.x.componentIri; + const { + width, + formatNumber, + timeFormatUnit, + chartWideData, + xScaleTime, + xScaleTimeRange, + colors, + } = useCommonComboState({ + chartData, + timeRangeData, + getXAsDate, + getXAsString: getX, + xKey, + yGetters: [variables.y.left, variables.y.right], + computeTotal: false, + }); + + // x + // We can only use TemporalDimension in ComboLineColumn chart (see UI encodings). + const interval = getTimeInterval(variables.xTimeUnit as TimeUnit); + const formatDate = useFormatFullDateAuto(); + const [xMin, xMax] = xScaleTime.domain() as [Date, Date]; + const xDomain = interval.range(xMin, xMax).concat(xMax).map(formatDate); + const xScale = d3 + .scaleBand() + .domain(xDomain) + .paddingInner(PADDING_INNER) + .paddingOuter(PADDING_OUTER); + + // y + const { yScale: yScaleLeft, paddingYScale: paddingLeftYScale } = useYScales({ + scalesData, + paddingData, + getY: variables.y.left.getY, + startAtZero: variables.y.left.chartType === "column", + }); + const { yScale: yScaleRight, paddingYScale: paddingRightYScale } = useYScales( + { + scalesData, + paddingData, + getY: variables.y.right.getY, + startAtZero: variables.y.right.chartType === "column", + } + ); + const [minLeftValue, maxLeftValue] = yScaleLeft.domain(); + const [minRightValue, maxRightValue] = yScaleRight.domain(); + const minValue = d3.min([minLeftValue, minRightValue]) ?? 0; + const maxValue = d3.max([maxLeftValue, maxRightValue]) ?? 0; + const yScale = d3.scaleLinear().domain([minValue, maxValue]).nice(); + const yOrientationScales = { + left: yScaleLeft, + right: yScaleRight, + }; + + // Dimensions + const { left, bottom } = useChartPadding({ + yScale: paddingLeftYScale, + width, + aspectRatio, + interactiveFiltersConfig, + formatNumber, + bandDomain: xDomain, + }); + const fakeRightTicks = paddingRightYScale.ticks( + getTickNumber(width * aspectRatio) + ); + const maxRightTickWidth = Math.max( + ...fakeRightTicks.map( + (d) => + getTextWidth(formatNumber(d), { fontSize: TICK_FONT_SIZE }) + + TICK_PADDING + ) + ); + const right = Math.max(maxRightTickWidth, 40); + const margins = getMargins({ left, right, bottom }); + const bounds = getChartBounds(width, margins, aspectRatio); + const { chartWidth, chartHeight } = bounds; + const xScales = [xScale, xScaleTime, xScaleTimeRange]; + const yScales = [yScale, yScaleLeft, yScaleRight]; + adjustScales(xScales, yScales, { chartWidth, chartHeight }); + + // Tooltip + const getAnnotationInfo = (d: Observation): TooltipInfo => { + const x = getX(d); + const xScaled = (xScale(x) as number) + xScale.bandwidth() * 0.5; + + return { + datum: { label: "", value: "0", color: "#006699" }, + xAnchor: xScaled, + yAnchor: + [variables.y.left, variables.y.right] + .map(({ orientation, getY }) => + yOrientationScales[orientation](getY(d) ?? 0) + ) + .reduce((a, b) => a + b, 0) * 0.5, + xValue: timeFormatUnit(x, variables.xTimeUnit as TimeUnit), + placement: getCenteredTooltipPlacement({ + chartWidth, + xAnchor: xScaled, + topAnchor: false, + }), + values: [variables.y.left, variables.y.right].map( + ({ orientation, getY, label, chartType }) => { + const y = getY(d); + + return { + label, + value: `${y}`, + color: colors(label), + hide: y === null, + yPos: yOrientationScales[orientation](y ?? 0), + symbol: chartType === "line" ? "line" : "square", + }; + } + ), + } as TooltipInfo; + }; + + return { + chartType: "comboLineColumn", + xKey, + bounds, + maxRightTickWidth, + chartData, + allData, + xScale, + xScaleInteraction: xScale.copy().padding(0), + xScaleTime, + xScaleTimeRange, + yScale, + yOrientationScales, + colors, + getColorLabel: (label) => label, + chartWideData, + getAnnotationInfo, + ...variables, + }; +}; + +const ComboLineColumnChartProvider = ( + props: React.PropsWithChildren< + ChartProps & { aspectRatio: number } + > +) => { + const { children, ...chartProps } = props; + const variables = useComboLineColumnStateVariables(chartProps); + const data = useComboLineColumnStateData(chartProps, variables); + const state = useComboLineColumnState(chartProps, variables, data); + + return ( + {children} + ); +}; + +export const ComboLineColumnChart = ( + props: React.PropsWithChildren< + ChartProps & { aspectRatio: number } + > +) => { + return ( + + + + + + ); +}; diff --git a/app/charts/combo/combo-line-column.tsx b/app/charts/combo/combo-line-column.tsx new file mode 100644 index 000000000..805cbcb90 --- /dev/null +++ b/app/charts/combo/combo-line-column.tsx @@ -0,0 +1,129 @@ +import * as d3 from "d3"; +import React from "react"; + +import { + RenderColumnDatum, + renderColumns, +} from "@/charts/column/rendering-utils"; +import { ComboLineColumnState } from "@/charts/combo/combo-line-column-state"; +import { useChartState } from "@/charts/shared/chart-state"; +import { renderContainer } from "@/charts/shared/rendering-utils"; +import { Observation } from "@/domain/data"; +import { useTransitionStore } from "@/stores/transition"; + +const Columns = () => { + const { + chartData, + bounds, + xScale, + getX, + y, + yOrientationScales, + colors, + getRenderingKey, + } = useChartState() as ComboLineColumnState; + const { margins } = bounds; + const ref = React.useRef(null); + const enableTransition = useTransitionStore((state) => state.enable); + const transitionDuration = useTransitionStore((state) => state.duration); + const bandwidth = xScale.bandwidth(); + const yColumn = y.left.chartType === "column" ? y.left : y.right; + const yScale = + y.left.chartType === "column" + ? yOrientationScales.left + : yOrientationScales.right; + const y0 = yScale(0); + const renderData: RenderColumnDatum[] = React.useMemo(() => { + return chartData.map((d) => { + const key = getRenderingKey(d); + const xScaled = xScale(getX(d)) as number; + const y = yColumn.getY(d) ?? NaN; + const yScaled = yScale(y); + const yRender = yScale(Math.max(y, 0)); + const height = Math.abs(yScaled - y0); + const color = colors(yColumn.label); + + return { + key, + x: xScaled, + y: yRender, + width: bandwidth, + height, + color, + }; + }); + }, [ + chartData, + getRenderingKey, + xScale, + getX, + yColumn, + yScale, + y0, + colors, + bandwidth, + ]); + + React.useEffect(() => { + if (ref.current) { + renderContainer(ref.current, { + id: "columns", + transform: `translate(${margins.left} ${margins.top})`, + transition: { enable: enableTransition, duration: transitionDuration }, + render: (g, opts) => renderColumns(g, renderData, { ...opts, y0 }), + }); + } + }, [ + enableTransition, + margins.left, + margins.top, + renderData, + transitionDuration, + y0, + ]); + + return ; +}; + +const Lines = () => { + const { chartData, xScale, getX, yOrientationScales, y, colors, bounds } = + useChartState() as ComboLineColumnState; + const yLine = y.left.chartType === "line" ? y.left : y.right; + const yScale = + y.left.chartType === "line" + ? yOrientationScales.left + : yOrientationScales.right; + const line = d3 + .line() + // FIXME: add missing observations basing on the time interval, so we can + // properly indicate the missing data. + .defined((d) => yLine.getY(d) !== null) + .x((d) => (xScale(getX(d)) as number) + xScale.bandwidth() * 0.5) + .y((d) => yScale(yLine.getY(d) as number)); + + return ( + + + + ); +}; + +type LineProps = { + path: string; + color: string; +}; + +const Line = React.memo(function Line(props: LineProps) { + const { path, color } = props; + + return ; +}); + +export const ComboLineColumn = () => { + return ( + <> + + + + ); +}; diff --git a/app/charts/combo/combo-line-dual-state-props.ts b/app/charts/combo/combo-line-dual-state-props.ts new file mode 100644 index 000000000..dc8a20d54 --- /dev/null +++ b/app/charts/combo/combo-line-dual-state-props.ts @@ -0,0 +1,107 @@ +import React from "react"; + +import { BaseYGetter, sortData } from "@/charts/combo/combo-state-props"; +import { + getLabelWithUnit, + usePlottableData, +} from "@/charts/shared/chart-helpers"; +import { + BaseVariables, + ChartStateData, + TemporalXVariables, + useBaseVariables, + useChartData, + useTemporalXVariables, +} from "@/charts/shared/chart-state"; +import { ComboLineDualConfig } from "@/configurator"; + +import { ChartProps } from "../shared/ChartProps"; + +type NumericalYComboLineDualVariables = { + y: { + left: BaseYGetter & { orientation: "left" }; + right: BaseYGetter & { orientation: "right" }; + }; +}; + +export type ComboLineDualStateVariables = BaseVariables & + TemporalXVariables & + NumericalYComboLineDualVariables; + +export const useComboLineDualStateVariables = ( + props: ChartProps & { aspectRatio: number } +): ComboLineDualStateVariables => { + const { chartConfig, dimensionsByIri, measuresByIri } = props; + const { fields } = chartConfig; + const { x } = fields; + + const baseVariables = useBaseVariables(chartConfig); + const temporalXVariables = useTemporalXVariables(x, { + dimensionsByIri, + }); + + const leftIri = chartConfig.fields.y.leftAxisComponentIri; + const rightIri = chartConfig.fields.y.rightAxisComponentIri; + const numericalYVariables: NumericalYComboLineDualVariables = { + y: { + left: { + orientation: "left", + dimension: measuresByIri[leftIri], + iri: leftIri, + label: getLabelWithUnit(measuresByIri[leftIri]), + getY: (d) => { + return d[leftIri] !== null ? Number(d[leftIri]) : null; + }, + }, + right: { + orientation: "right", + dimension: measuresByIri[rightIri], + iri: rightIri, + label: getLabelWithUnit(measuresByIri[rightIri]), + getY: (d) => { + return d[rightIri] !== null ? Number(d[rightIri]) : null; + }, + }, + }, + }; + + return { + ...baseVariables, + ...temporalXVariables, + ...numericalYVariables, + }; +}; + +export const useComboLineDualStateData = ( + chartProps: ChartProps & { aspectRatio: number }, + variables: ComboLineDualStateVariables +): ChartStateData => { + const { chartConfig, observations } = chartProps; + const { getX, y } = variables; + const plottableData = usePlottableData(observations, { + getX, + getY: (d) => { + for (const { getY } of [y.left, y.right]) { + const y = getY(d); + + if (y !== null) { + return y; + } + } + }, + }); + const sortedPlottableData = React.useMemo(() => { + return sortData(plottableData, { + getX, + }); + }, [plottableData, getX]); + const data = useChartData(sortedPlottableData, { + chartConfig, + getXAsDate: getX, + }); + + return { + ...data, + allData: sortedPlottableData, + }; +}; diff --git a/app/charts/combo/combo-line-dual-state.tsx b/app/charts/combo/combo-line-dual-state.tsx new file mode 100644 index 000000000..37d797958 --- /dev/null +++ b/app/charts/combo/combo-line-dual-state.tsx @@ -0,0 +1,217 @@ +import * as d3 from "d3"; +import React from "react"; + +import { + ComboLineDualStateVariables, + useComboLineDualStateData, + useComboLineDualStateVariables, +} from "@/charts/combo/combo-line-dual-state-props"; +import { + adjustScales, + getMargins, + useCommonComboState, + useYScales, +} from "@/charts/combo/combo-state"; +import { TICK_PADDING } from "@/charts/shared/axis-height-linear"; +import { + getChartBounds, + 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 } from "@/charts/shared/interaction/tooltip-box"; +import { getTickNumber } from "@/charts/shared/ticks"; +import { TICK_FONT_SIZE } from "@/charts/shared/use-chart-theme"; +import { InteractionProvider } from "@/charts/shared/use-interaction"; +import { Observer } from "@/charts/shared/use-width"; +import { ComboLineDualConfig } from "@/configurator"; +import { Observation } from "@/domain/data"; +import { getTextWidth } from "@/utils/get-text-width"; + +import { ChartProps } from "../shared/ChartProps"; + +export type ComboLineDualState = CommonChartState & + ComboLineDualStateVariables & + InteractiveXTimeRangeState & { + chartType: "comboLineDual"; + xKey: string; + xScale: d3.ScaleTime; + yScale: d3.ScaleLinear; + yOrientationScales: { + left: d3.ScaleLinear; + right: d3.ScaleLinear; + }; + colors: d3.ScaleOrdinal; + getColorLabel: (label: string) => string; + chartWideData: ArrayLike; + getAnnotationInfo: (d: Observation) => TooltipInfo; + maxRightTickWidth: number; + }; + +const useComboLineDualState = ( + chartProps: ChartProps & { aspectRatio: number }, + variables: ComboLineDualStateVariables, + data: ChartStateData +): ComboLineDualState => { + const { chartConfig, aspectRatio } = chartProps; + const { xDimension, getX, getXAsString } = variables; + const { chartData, scalesData, timeRangeData, paddingData, allData } = data; + const { fields, interactiveFiltersConfig } = chartConfig; + const xKey = fields.x.componentIri; + const { + width, + formatNumber, + timeFormatUnit, + chartWideData, + xScaleTime: xScale, + xScaleTimeRange, + colors, + } = useCommonComboState({ + chartData, + timeRangeData, + getXAsDate: getX, + getXAsString, + xKey, + yGetters: [variables.y.left, variables.y.right], + computeTotal: false, + }); + + // y + const { yScale: yScaleLeft, paddingYScale: paddingLeftYScale } = useYScales({ + scalesData, + paddingData, + getY: variables.y.left.getY, + }); + const { yScale: yScaleRight, paddingYScale: paddingRightYScale } = useYScales( + { + scalesData, + paddingData, + getY: variables.y.right.getY, + } + ); + + const [minLeftValue, maxLeftValue] = yScaleLeft.domain(); + const [minRightValue, maxRightValue] = yScaleRight.domain(); + const minValue = d3.min([minLeftValue, minRightValue]) ?? 0; + const maxValue = d3.max([maxLeftValue, maxRightValue]) ?? 0; + const yScale = d3.scaleLinear().domain([minValue, maxValue]).nice(); + const yOrientationScales = { + left: yScaleLeft, + right: yScaleRight, + }; + + // Dimensions + const { left, bottom } = useChartPadding({ + yScale: paddingLeftYScale, + width, + aspectRatio, + interactiveFiltersConfig, + formatNumber, + }); + const fakeRightTicks = paddingRightYScale.ticks( + getTickNumber(width * aspectRatio) + ); + const maxRightTickWidth = Math.max( + ...fakeRightTicks.map( + (d) => + getTextWidth(formatNumber(d), { fontSize: TICK_FONT_SIZE }) + + TICK_PADDING + ) + ); + const right = Math.max(maxRightTickWidth, 40); + const margins = getMargins({ left, right, bottom }); + const bounds = getChartBounds(width, margins, aspectRatio); + const { chartWidth, chartHeight } = bounds; + const xScales = [xScale, xScaleTimeRange]; + const yScales = [yScale, yScaleLeft, yScaleRight]; + adjustScales(xScales, yScales, { chartWidth, chartHeight }); + + // Tooltip + const getAnnotationInfo = (d: Observation): TooltipInfo => { + const x = getX(d); + const xScaled = xScale(x); + + return { + datum: { label: "", value: "0", color: "#006699" }, + xAnchor: xScaled, + yAnchor: + [variables.y.left, variables.y.right] + .map(({ orientation, getY }) => + yOrientationScales[orientation](getY(d) ?? 0) + ) + .reduce((a, b) => a + b, 0) * 0.5, + xValue: timeFormatUnit(x, xDimension.timeUnit), + placement: getCenteredTooltipPlacement({ + chartWidth, + xAnchor: xScaled, + topAnchor: false, + }), + values: [variables.y.left, variables.y.right].map( + ({ orientation, getY, label }) => { + const y = getY(d); + + return { + label, + value: `${y}`, + color: colors(label), + hide: y === null, + yPos: yOrientationScales[orientation](y ?? 0), + symbol: "line", + }; + } + ), + } as TooltipInfo; + }; + + return { + chartType: "comboLineDual", + xKey, + bounds, + maxRightTickWidth, + chartData, + allData, + xScale, + xScaleTimeRange, + yScale, + yOrientationScales, + colors, + getColorLabel: (label) => label, + chartWideData, + getAnnotationInfo, + ...variables, + }; +}; + +const ComboLineDualChartProvider = ( + props: React.PropsWithChildren< + ChartProps & { aspectRatio: number } + > +) => { + const { children, ...chartProps } = props; + const variables = useComboLineDualStateVariables(chartProps); + const data = useComboLineDualStateData(chartProps, variables); + const state = useComboLineDualState(chartProps, variables, data); + + return ( + {children} + ); +}; + +export const ComboLineDualChart = ( + props: React.PropsWithChildren< + ChartProps & { aspectRatio: number } + > +) => { + return ( + + + + + + ); +}; diff --git a/app/charts/combo/combo-line-dual.tsx b/app/charts/combo/combo-line-dual.tsx new file mode 100644 index 000000000..7c8dcce41 --- /dev/null +++ b/app/charts/combo/combo-line-dual.tsx @@ -0,0 +1,42 @@ +import * as d3 from "d3"; +import React from "react"; + +import { ComboLineDualState } from "@/charts/combo/combo-line-dual-state"; +import { useChartState } from "@/charts/shared/chart-state"; +import { Observation } from "@/domain/data"; + +export const ComboLineDual = () => { + const { chartData, xScale, getX, yOrientationScales, y, colors, bounds } = + useChartState() as ComboLineDualState; + + return ( + + {[y.left, y.right].map(({ orientation, iri, label, getY }) => { + const line = d3 + .line() + .defined((d) => getY(d) !== null) + .x((d) => xScale(getX(d))) + .y((d) => yOrientationScales[orientation](getY(d) as number)); + + return ( + + ); + })} + + ); +}; + +type LineProps = { + path: string; + color: string; +}; + +const Line = React.memo(function Line(props: LineProps) { + const { path, color } = props; + + return ; +}); diff --git a/app/charts/combo/combo-line-single-state-props.ts b/app/charts/combo/combo-line-single-state-props.ts new file mode 100644 index 000000000..3b0d8476f --- /dev/null +++ b/app/charts/combo/combo-line-single-state-props.ts @@ -0,0 +1,91 @@ +import React from "react"; + +import { BaseYGetter, sortData } from "@/charts/combo/combo-state-props"; +import { + BaseVariables, + ChartStateData, + TemporalXVariables, + useBaseVariables, + useChartData, + useTemporalXVariables, +} from "@/charts/shared/chart-state"; +import { ComboLineSingleConfig } from "@/configurator"; + +import { ChartProps } from "../shared/ChartProps"; +import { usePlottableData } from "../shared/chart-helpers"; + +type NumericalYComboLineSingleVariables = { + y: { + lines: BaseYGetter[]; + }; +}; + +export type ComboLineSingleStateVariables = BaseVariables & + TemporalXVariables & + NumericalYComboLineSingleVariables; + +export const useComboLineSingleStateVariables = ( + props: ChartProps & { aspectRatio: number } +): ComboLineSingleStateVariables => { + const { chartConfig, dimensionsByIri, measuresByIri } = props; + const { fields } = chartConfig; + const { x } = fields; + + const baseVariables = useBaseVariables(chartConfig); + const temporalXVariables = useTemporalXVariables(x, { + dimensionsByIri, + }); + + const numericalYVariables: NumericalYComboLineSingleVariables = { + y: { + lines: chartConfig.fields.y.componentIris.map((iri) => ({ + dimension: measuresByIri[iri], + iri, + label: measuresByIri[iri].label, + getY: (d) => { + return d[iri] !== null ? Number(d[iri]) : null; + }, + })), + }, + }; + + return { + ...baseVariables, + ...temporalXVariables, + ...numericalYVariables, + }; +}; + +export const useComboLineSingleStateData = ( + chartProps: ChartProps & { aspectRatio: number }, + variables: ComboLineSingleStateVariables +): ChartStateData => { + const { chartConfig, observations } = chartProps; + const { getX, y } = variables; + const plottableData = usePlottableData(observations, { + getX, + getY: (d) => { + for (const { getY } of y.lines) { + const y = getY(d); + + if (y !== null) { + return y; + } + } + }, + }); + const sortedPlottableData = React.useMemo(() => { + return sortData(plottableData, { + getX, + }); + }, [plottableData, getX]); + const data = useChartData(sortedPlottableData, { + chartConfig, + getXAsDate: getX, + }); + + return { + ...data, + allData: sortedPlottableData, + }; +}; diff --git a/app/charts/combo/combo-line-single-state.tsx b/app/charts/combo/combo-line-single-state.tsx new file mode 100644 index 000000000..11b2dbe40 --- /dev/null +++ b/app/charts/combo/combo-line-single-state.tsx @@ -0,0 +1,194 @@ +import * as d3 from "d3"; +import React from "react"; + +import { + ComboLineSingleStateVariables, + useComboLineSingleStateData, + useComboLineSingleStateVariables, +} from "@/charts/combo/combo-line-single-state-props"; +import { + adjustScales, + getMargins, + useCommonComboState, + useYScales, +} from "@/charts/combo/combo-state"; +import { + getChartBounds, + 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 } from "@/charts/shared/interaction/tooltip-box"; +import { InteractionProvider } from "@/charts/shared/use-interaction"; +import { Observer } from "@/charts/shared/use-width"; +import { ComboLineSingleConfig } from "@/configurator"; +import { Observation } from "@/domain/data"; + +import { ChartProps } from "../shared/ChartProps"; + +export type ComboLineSingleState = CommonChartState & + ComboLineSingleStateVariables & + InteractiveXTimeRangeState & { + chartType: "comboLineSingle"; + xKey: string; + xScale: d3.ScaleTime; + yScale: d3.ScaleLinear; + yAxisLabel: string; + colors: d3.ScaleOrdinal; + getColorLabel: (label: string) => string; + chartWideData: ArrayLike; + getAnnotationInfo: (d: Observation) => TooltipInfo; + }; + +const useComboLineSingleState = ( + chartProps: ChartProps & { aspectRatio: number }, + variables: ComboLineSingleStateVariables, + data: ChartStateData +): ComboLineSingleState => { + const { chartConfig, aspectRatio, measuresByIri } = chartProps; + const { xDimension, getX, getXAsString } = variables; + const { chartData, scalesData, timeRangeData, paddingData, allData } = data; + const { fields, interactiveFiltersConfig } = chartConfig; + + const yUnits = Array.from( + new Set( + variables.y.lines.map((d) => { + return measuresByIri[d.iri].unit; + }) + ) + ); + + if (yUnits.length > 1) { + throw new Error( + "Multiple units are not supported in ComboLineSingle chart!" + ); + } + + const yAxisLabel = yUnits[0] ?? ""; + + const xKey = fields.x.componentIri; + const { + width, + formatNumber, + timeFormatUnit, + chartWideData, + xScaleTime: xScale, + xScaleTimeRange, + colors, + } = useCommonComboState({ + chartData, + timeRangeData, + xKey, + getXAsDate: getX, + getXAsString, + yGetters: variables.y.lines, + computeTotal: true, + }); + + // y + const { yScale, paddingYScale } = useYScales({ + scalesData, + paddingData, + getY: variables.y.lines.map((d) => d.getY), + }); + + // Dimensions + const { left, bottom } = useChartPadding({ + yScale: paddingYScale, + width, + aspectRatio, + interactiveFiltersConfig, + formatNumber, + }); + const margins = getMargins({ left, bottom }); + const bounds = getChartBounds(width, margins, aspectRatio); + const { chartWidth, chartHeight } = bounds; + const xScales = [xScale, xScaleTimeRange]; + const yScales = [yScale]; + adjustScales(xScales, yScales, { chartWidth, chartHeight }); + + // Tooltip + const getAnnotationInfo = (d: Observation): TooltipInfo => { + const x = getX(d); + const xScaled = xScale(x); + + return { + datum: { label: "", value: "0", color: "#006699" }, + xAnchor: xScaled, + yAnchor: yScale( + variables.y.lines + .map(({ getY }) => getY(d) ?? 0) + .reduce((a, b) => a + b, 0) / variables.y.lines.length + ), + xValue: timeFormatUnit(x, xDimension.timeUnit), + placement: getCenteredTooltipPlacement({ + chartWidth, + xAnchor: xScaled, + topAnchor: false, + }), + values: variables.y.lines.map(({ getY, label }) => { + const y = getY(d); + + return { + label, + value: `${y}`, + color: colors(label), + hide: y === null, + yPos: yScale(y ?? 0), + symbol: "line", + }; + }), + } as TooltipInfo; + }; + + return { + chartType: "comboLineSingle", + xKey, + bounds, + chartData, + allData, + xScale, + xScaleTimeRange, + yScale, + yAxisLabel, + colors, + getColorLabel: (label) => label, + chartWideData, + getAnnotationInfo, + ...variables, + }; +}; + +const ComboLineSingleChartProvider = ( + props: React.PropsWithChildren< + ChartProps & { aspectRatio: number } + > +) => { + const { children, ...chartProps } = props; + const variables = useComboLineSingleStateVariables(chartProps); + const data = useComboLineSingleStateData(chartProps, variables); + const state = useComboLineSingleState(chartProps, variables, data); + + return ( + {children} + ); +}; + +export const ComboLineSingleChart = ( + props: React.PropsWithChildren< + ChartProps & { aspectRatio: number } + > +) => { + return ( + + + + + + ); +}; diff --git a/app/charts/combo/combo-line-single.tsx b/app/charts/combo/combo-line-single.tsx new file mode 100644 index 000000000..5753afc74 --- /dev/null +++ b/app/charts/combo/combo-line-single.tsx @@ -0,0 +1,42 @@ +import * as d3 from "d3"; +import React from "react"; + +import { ComboLineSingleState } from "@/charts/combo/combo-line-single-state"; +import { useChartState } from "@/charts/shared/chart-state"; +import { Observation } from "@/domain/data"; + +export const ComboLineSingle = () => { + const { chartData, xScale, getX, yScale, y, colors, bounds } = + useChartState() as ComboLineSingleState; + + return ( + + {y.lines.map(({ iri, label, getY }) => { + const line = d3 + .line() + .defined((d) => getY(d) !== null) + .x((d) => xScale(getX(d))) + .y((d) => yScale(getY(d) as number)); + + return ( + + ); + })} + + ); +}; + +type LineProps = { + path: string; + color: string; +}; + +const Line = React.memo(function Line(props: LineProps) { + const { path, color } = props; + + return ; +}); diff --git a/app/charts/combo/combo-state-props.ts b/app/charts/combo/combo-state-props.ts new file mode 100644 index 000000000..ccfc91b8c --- /dev/null +++ b/app/charts/combo/combo-state-props.ts @@ -0,0 +1,21 @@ +import { ascending } from "d3"; + +import { TemporalValueGetter } from "@/charts/shared/chart-state"; +import { Observation } from "@/domain/data"; +import { DimensionMetadataFragment } from "@/graphql/query-hooks"; + +export type BaseYGetter = { + dimension: DimensionMetadataFragment; + iri: string; + label: string; + getY: (d: Observation) => number | null; +}; + +export const sortData = ( + data: Observation[], + { getX }: { getX: TemporalValueGetter } +): Observation[] => { + return [...data].sort((a, b) => { + return ascending(getX(a), getX(b)); + }); +}; diff --git a/app/charts/combo/combo-state.ts b/app/charts/combo/combo-state.ts new file mode 100644 index 000000000..6b77e0105 --- /dev/null +++ b/app/charts/combo/combo-state.ts @@ -0,0 +1,150 @@ +import * as d3 from "d3"; +import React from "react"; + +import { useWidth } from "@/charts/shared/use-width"; +import { Observation } from "@/domain/data"; +import { useFormatNumber, useTimeFormatUnit } from "@/formatters"; +import { getPalette } from "@/palettes"; + +type UseCommonComboStateOptions = { + chartData: Observation[]; + timeRangeData: Observation[]; + xKey: string; + getXAsDate: (d: Observation) => Date; + getXAsString: (d: Observation) => string; + yGetters: { + label: string; + getY: (d: Observation) => number | null; + }[]; + computeTotal: boolean; +}; + +export const useCommonComboState = (options: UseCommonComboStateOptions) => { + const { + chartData, + timeRangeData, + xKey, + getXAsDate, + getXAsString, + yGetters, + computeTotal, + } = options; + + const width = useWidth(); + const formatNumber = useFormatNumber({ decimals: "auto" }); + const timeFormatUnit = useTimeFormatUnit(); + + const chartDataByX = React.useMemo(() => { + return d3.groups(chartData, getXAsString).sort(); + }, [chartData, getXAsString]); + + const chartWideData = React.useMemo(() => { + const chartWideData: Observation[] = []; + + for (const [date, observations] of chartDataByX) { + const total = computeTotal + ? d3.sum(observations, (o) => d3.sum(yGetters, (d) => d.getY(o))) + : 0; + const observation = Object.assign( + { + [xKey]: date, + [`${xKey}/__iri__`]: observations[0][`${xKey}/__iri__`], + total, + }, + ...observations.flatMap((o) => + yGetters.map((d) => ({ [d.label]: d.getY(o) })) + ) + ); + + chartWideData.push(observation); + } + + return chartWideData; + }, [computeTotal, chartDataByX, xKey, yGetters]); + + const xScaleTime = React.useMemo(() => { + const domain = d3.extent(chartData, getXAsDate) as [Date, Date]; + return d3.scaleTime().domain(domain); + }, [chartData, getXAsDate]); + + const xScaleTimeRange = React.useMemo(() => { + const domain = d3.extent(timeRangeData, getXAsDate) as [Date, Date]; + return d3.scaleTime().domain(domain); + }, [getXAsDate, timeRangeData]); + + const colors = React.useMemo(() => { + const domain = yGetters.map((d) => d.label); + const range = getPalette(); + + return d3.scaleOrdinal().domain(domain).range(range); + }, [yGetters]); + + return { + width, + formatNumber, + timeFormatUnit, + chartWideData, + xScaleTime, + xScaleTimeRange, + colors, + }; +}; + +type UseLeftRightYScalesOptions = { + scalesData: Observation[]; + paddingData: Observation[]; + getY: + | ((d: Observation) => number | null) + | ((d: Observation) => number | null)[]; + startAtZero?: boolean; +}; + +export const useYScales = (options: UseLeftRightYScalesOptions) => { + const { scalesData, paddingData, getY, startAtZero } = options; + const getMinY = (o: Observation) => { + return Array.isArray(getY) ? d3.min(getY, (d) => d(o)) : getY(o); + }; + const getMaxY = (o: Observation) => { + return Array.isArray(getY) ? d3.max(getY, (d) => d(o)) : getY(o); + }; + + const minValue = startAtZero ? 0 : d3.min(scalesData, getMinY) ?? 0; + const maxValue = d3.max(scalesData, getMaxY) ?? 0; + const yScale = d3.scaleLinear().domain([minValue, maxValue]).nice(); + + const paddingMinValue = startAtZero ? 0 : d3.min(paddingData, getMinY) ?? 0; + const paddingMaxValue = d3.max(paddingData, getMaxY) ?? 0; + const paddingYScale = d3 + .scaleLinear() + .domain([paddingMinValue, paddingMaxValue]) + .nice(); + + return { yScale, paddingYScale }; +}; + +type GetMarginsOptions = { + left: number; + top?: number; + right?: number; + bottom: number; +}; + +export const getMargins = (options: GetMarginsOptions) => { + const { left, top = 50, right = 40, bottom } = options; + return { top, right, bottom, left }; +}; + +type Scale = + | d3.ScaleTime + | d3.ScaleBand + | d3.ScaleLinear; + +export const adjustScales = ( + xScales: Scale[], + yScales: Scale[], + options: { chartWidth: number; chartHeight: number } +) => { + const { chartWidth, chartHeight } = options; + xScales.forEach((scale) => scale.range([0, chartWidth])); + yScales.forEach((scale) => scale.range([chartHeight, 0])); +}; diff --git a/app/charts/index.spec.ts b/app/charts/index.spec.ts index 7f144c183..2d0e0f1f5 100644 --- a/app/charts/index.spec.ts +++ b/app/charts/index.spec.ts @@ -8,7 +8,7 @@ import forestAreaData from "../test/__fixtures/data/forest-area-by-production-re import { getChartConfigAdjustedToChartType, getInitialConfig, - getPossibleChartType, + getPossibleChartTypes, } from "./index"; describe("initial config", () => { @@ -44,7 +44,7 @@ describe("initial config", () => { describe("possible chart types", () => { it("should allow appropriate chart types based on available dimensions", () => { const expectedChartTypes = ["area", "column", "line", "pie", "table"]; - const possibleChartTypes = getPossibleChartType({ + const possibleChartTypes = getPossibleChartTypes({ dimensions: bathingWaterData.data.dataCubeByIri.dimensions as NonNullable< ComponentsQuery["dataCubeByIri"] >["dimensions"], @@ -57,7 +57,7 @@ describe("possible chart types", () => { }); it("should only allow table if there are only measures available", () => { - const possibleChartTypes = getPossibleChartType({ + const possibleChartTypes = getPossibleChartTypes({ dimensions: [], measures: [{ __typename: "NumericalMeasure" }] as any, }); @@ -66,7 +66,7 @@ describe("possible chart types", () => { }); it("should only allow column, map, pie and table if only geo dimensions are available", () => { - const possibleChartTypes = getPossibleChartType({ + const possibleChartTypes = getPossibleChartTypes({ dimensions: [{ __typename: "GeoShapesDimension" }] as any, measures: [{ __typename: "NumericalMeasure" }] as any, }).sort(); diff --git a/app/charts/index.ts b/app/charts/index.ts index 175d26dfc..39627a869 100644 --- a/app/charts/index.ts +++ b/app/charts/index.ts @@ -1,4 +1,4 @@ -import { ascending, group } from "d3"; +import { ascending, descending, group, rollups } from "d3"; import produce from "immer"; import get from "lodash/get"; import sortBy from "lodash/sortBy"; @@ -19,11 +19,23 @@ import { ChartSegmentField, ChartType, ColumnSegmentField, + ComboChartType, + ComboLineColumnFields, + ComboLineSingleFields, FieldAdjuster, GenericFields, GenericSegmentField, InteractiveFiltersAdjusters, InteractiveFiltersConfig, + isAreaConfig, + isColumnConfig, + isComboLineColumnConfig, + isComboLineDualConfig, + isComboLineSingleConfig, + isLineConfig, + isMapConfig, + isPieConfig, + isScatterPlotConfig, isSegmentInConfig, LineSegmentField, MapAreaLayer, @@ -31,21 +43,15 @@ import { MapSymbolLayer, Meta, PieSegmentField, + RegularChartType, ScatterPlotSegmentField, SortingOrder, SortingType, TableColumn, TableFields, } from "@/config-types"; +import { mapValueIrisToColor } from "@/configurator/components/ui-helpers"; import { FIELD_VALUE_NONE } from "@/configurator/constants"; -import { HierarchyValue } from "@/graphql/resolver-types"; -import { getDefaultCategoricalPaletteName } from "@/palettes"; -import { bfs } from "@/utils/bfs"; -import { CHART_CONFIG_VERSION } from "@/utils/chart-config/versioning"; -import { createChartId } from "@/utils/create-chart-id"; -import { isMultiHierarchyNode } from "@/utils/hierarchy"; - -import { mapValueIrisToColor } from "../configurator/components/ui-helpers"; import { getCategoricalDimensions, getGeoDimensions, @@ -56,19 +62,25 @@ import { isNumericalMeasure, isOrdinalMeasure, isTemporalDimension, -} from "../domain/data"; +} from "@/domain/data"; import { DimensionMetadataFragment, GeoCoordinatesDimension, GeoShapesDimension, NumericalMeasure, OrdinalMeasure, -} from "../graphql/query-hooks"; +} from "@/graphql/query-hooks"; +import { HierarchyValue } from "@/graphql/resolver-types"; import { DataCubeMetadata, DataCubeMetadataWithHierarchies, -} from "../graphql/types"; -import { unreachableError } from "../utils/unreachable"; +} from "@/graphql/types"; +import { getDefaultCategoricalPaletteName } from "@/palettes"; +import { bfs } from "@/utils/bfs"; +import { CHART_CONFIG_VERSION } from "@/utils/chart-config/versioning"; +import { createChartId } from "@/utils/create-chart-id"; +import { isMultiHierarchyNode } from "@/utils/hierarchy"; +import { unreachableError } from "@/utils/unreachable"; export const chartTypes: ChartType[] = [ "column", @@ -78,6 +90,25 @@ export const chartTypes: ChartType[] = [ "pie", "table", "map", + "comboLineSingle", + "comboLineDual", + "comboLineColumn", +]; + +export const regularChartTypes: RegularChartType[] = [ + "column", + "line", + "area", + "scatterplot", + "pie", + "table", + "map", +]; + +export const comboChartTypes: ComboChartType[] = [ + "comboLineSingle", + "comboLineDual", + "comboLineColumn", ]; export const chartTypesOrder: { [k in ChartType]: number } = { @@ -88,6 +119,9 @@ export const chartTypesOrder: { [k in ChartType]: number } = { pie: 4, map: 5, table: 6, + comboLineSingle: 7, + comboLineDual: 8, + comboLineColumn: 9, }; /** @@ -287,17 +321,17 @@ const META: Meta = { }, }; -export const getInitialConfig = ({ - key, - chartType, - dimensions, - measures, -}: { +type GetInitialConfigOptions = { key?: string; chartType: ChartType; dimensions: DataCubeMetadataWithHierarchies["dimensions"]; measures: DataCubeMetadataWithHierarchies["measures"]; -}): ChartConfig => { +}; + +export const getInitialConfig = ( + options: GetInitialConfigOptions +): ChartConfig => { + const { key, chartType, dimensions, measures } = options; const genericConfigProps: { key: string; version: string; @@ -310,10 +344,11 @@ export const getInitialConfig = ({ activeField: undefined, }; const numericalMeasures = measures.filter(isNumericalMeasure); + const temporalDimensions = getTemporalDimensions(dimensions); switch (chartType) { case "area": - const areaXComponentIri = getTemporalDimensions(dimensions)[0].iri; + const areaXComponentIri = temporalDimensions[0].iri; return { ...genericConfigProps, @@ -351,7 +386,7 @@ export const getInitialConfig = ({ }, }; case "line": - const lineXComponentIri = getTemporalDimensions(dimensions)[0].iri; + const lineXComponentIri = temporalDimensions[0].iri; return { ...genericConfigProps, @@ -492,6 +527,85 @@ export const getInitialConfig = ({ ]) ) as TableFields, }; + case "comboLineSingle": { + // It's guaranteed by getPossibleChartTypes that there are at least two units. + const mostCommonUnit = rollups( + numericalMeasures, + (v) => v.length, + (d) => d.unit + ).sort((a, b) => descending(a[1], b[1]))[0][0]; + + return { + ...genericConfigProps, + chartType: "comboLineSingle", + filters: {}, + interactiveFiltersConfig: getInitialInteractiveFiltersConfig({ + timeRangeComponentIri: temporalDimensions[0].iri, + }), + fields: { + x: { componentIri: temporalDimensions[0].iri }, + // Use all measures with the most common unit. + y: { + componentIris: numericalMeasures + .filter((d) => d.unit === mostCommonUnit) + .map((d) => d.iri), + }, + }, + }; + } + case "comboLineDual": { + // It's guaranteed by getPossibleChartTypes that there are at least two units. + const [firstUnit, secondUnit] = Array.from( + new Set(numericalMeasures.map((d) => d.unit)) + ); + + return { + ...genericConfigProps, + chartType: "comboLineDual", + filters: {}, + interactiveFiltersConfig: getInitialInteractiveFiltersConfig({ + timeRangeComponentIri: temporalDimensions[0].iri, + }), + fields: { + x: { componentIri: temporalDimensions[0].iri }, + y: { + leftAxisComponentIri: numericalMeasures.find( + (d) => d.unit === firstUnit + )!.iri, + rightAxisComponentIri: numericalMeasures.find( + (d) => d.unit === secondUnit + )!.iri, + }, + }, + }; + } + case "comboLineColumn": { + // It's guaranteed by getPossibleChartTypes that there are at least two units. + const [firstUnit, secondUnit] = Array.from( + new Set(numericalMeasures.map((d) => d.unit)) + ); + + return { + ...genericConfigProps, + chartType: "comboLineColumn", + filters: {}, + interactiveFiltersConfig: getInitialInteractiveFiltersConfig({ + timeRangeComponentIri: temporalDimensions[0].iri, + }), + fields: { + x: { componentIri: temporalDimensions[0].iri }, + y: { + lineComponentIri: numericalMeasures.find( + (d) => d.unit === firstUnit + )!.iri, + lineAxisOrientation: "right", + columnComponentIri: numericalMeasures.find( + (d) => d.unit === secondUnit + )!.iri, + }, + }, + }; + } // This code *should* be unreachable! If it's not, it means we haven't checked // all cases (and we should get a TS error). @@ -587,17 +701,19 @@ const getAdjustedChartConfig = ({ const newPath = path === "" ? k : `${path}.${k}`; if (v !== undefined) { - if (isConfigLeaf(newPath, v)) { + const override = pathOverrides?.[newPath]; + + if (isConfigLeaf(newPath, v) || override) { const getChartConfigWithAdjustedField: FieldAdjuster< ChartConfig, unknown > = - (pathOverrides && get(adjusters, pathOverrides[newPath])) || + (override?.path && get(adjusters, override.path)) || get(adjusters, newPath); if (getChartConfigWithAdjustedField) { newChartConfig = getChartConfigWithAdjustedField({ - oldValue: v, + oldValue: override?.oldValue ? override.oldValue(v) : v, newChartConfig, oldChartConfig, dimensions, @@ -1152,6 +1268,204 @@ const chartConfigsAdjusters: ChartConfigsAdjusters = { }, interactiveFiltersConfig: interactiveFiltersAdjusters, }, + comboLineSingle: { + filters: ({ oldValue, newChartConfig }) => { + return produce(newChartConfig, (draft) => { + draft.filters = oldValue; + }); + }, + fields: { + x: { + componentIri: ({ oldValue, newChartConfig, dimensions }) => { + const ok = dimensions.find( + (d) => isTemporalDimension(d) && d.iri === oldValue + ); + + if (ok) { + return produce(newChartConfig, (draft) => { + draft.fields.x.componentIri = oldValue; + }); + } + + return newChartConfig; + }, + }, + y: { + componentIris: ({ oldValue, newChartConfig, measures }) => { + const numericalMeasures = measures.filter(isNumericalMeasure); + const availableMeasureIris = numericalMeasures.map((d) => d.iri); + const measure = numericalMeasures.find( + (d) => d.iri === (oldValue ?? availableMeasureIris[0]) + ) as NumericalMeasure; + + return produce(newChartConfig, (draft) => { + draft.fields.y = { + componentIris: numericalMeasures + .filter((d) => d.unit === measure.unit) + .map((d) => d.iri), + }; + }); + }, + }, + }, + interactiveFiltersConfig: interactiveFiltersAdjusters, + }, + comboLineDual: { + filters: ({ oldValue, newChartConfig }) => { + return produce(newChartConfig, (draft) => { + draft.filters = oldValue; + }); + }, + fields: { + x: { + componentIri: ({ oldValue, newChartConfig, dimensions }) => { + const ok = dimensions.find( + (d) => isTemporalDimension(d) && d.iri === oldValue + ); + + if (ok) { + return produce(newChartConfig, (draft) => { + draft.fields.x.componentIri = oldValue; + }); + } + + return newChartConfig; + }, + }, + y: ({ newChartConfig, oldChartConfig, measures }) => { + const numericalMeasures = measures.filter(isNumericalMeasure); + const numericalMeasureIris = numericalMeasures.map((d) => d.iri); + let leftMeasure = numericalMeasures.find( + (d) => d.iri === numericalMeasureIris[0] + ) as NumericalMeasure; + let rightMeasureIri: string | undefined; + const getMeasure = (iri: string) => { + return numericalMeasures.find( + (d) => d.iri === iri + ) as NumericalMeasure; + }; + + if (isComboLineColumnConfig(oldChartConfig)) { + const { + lineComponentIri: lineIri, + lineAxisOrientation: lineOrientation, + columnComponentIri: columnIri, + } = oldChartConfig.fields.y; + const leftAxisIri = lineOrientation === "left" ? lineIri : columnIri; + leftMeasure = getMeasure(leftAxisIri); + rightMeasureIri = lineOrientation === "left" ? columnIri : lineIri; + } else if (isComboLineSingleConfig(oldChartConfig)) { + leftMeasure = getMeasure(oldChartConfig.fields.y.componentIris[0]); + } else if ( + isAreaConfig(oldChartConfig) || + isColumnConfig(oldChartConfig) || + isLineConfig(oldChartConfig) || + isPieConfig(oldChartConfig) || + isScatterPlotConfig(oldChartConfig) + ) { + leftMeasure = getMeasure(oldChartConfig.fields.y.componentIri); + } else if (isMapConfig(oldChartConfig)) { + const { areaLayer, symbolLayer } = oldChartConfig.fields; + const leftAxisIri = + areaLayer?.color.componentIri ?? symbolLayer?.measureIri; + + if (leftAxisIri) { + leftMeasure = getMeasure(leftAxisIri); + } + } + + return produce(newChartConfig, (draft) => { + draft.fields.y = { + leftAxisComponentIri: leftMeasure.iri, + rightAxisComponentIri: ( + numericalMeasures.find((d) => + rightMeasureIri + ? d.iri === rightMeasureIri + : d.unit !== leftMeasure.unit + ) as NumericalMeasure + ).iri, + }; + }); + }, + }, + interactiveFiltersConfig: interactiveFiltersAdjusters, + }, + comboLineColumn: { + filters: ({ oldValue, newChartConfig }) => { + return produce(newChartConfig, (draft) => { + draft.filters = oldValue; + }); + }, + fields: { + x: { + componentIri: ({ oldValue, newChartConfig, dimensions }) => { + const ok = dimensions.find( + (d) => isTemporalDimension(d) && d.iri === oldValue + ); + + if (ok) { + return produce(newChartConfig, (draft) => { + draft.fields.x.componentIri = oldValue; + }); + } + + return newChartConfig; + }, + }, + y: ({ newChartConfig, oldChartConfig, measures }) => { + const numericalMeasures = measures.filter(isNumericalMeasure); + const numericalMeasureIris = numericalMeasures.map((d) => d.iri); + let leftMeasure = numericalMeasures.find( + (d) => d.iri === numericalMeasureIris[0] + ) as NumericalMeasure; + let rightAxisMeasureIri: string | undefined; + const getMeasure = (iri: string) => { + return numericalMeasures.find( + (d) => d.iri === iri + ) as NumericalMeasure; + }; + + if (isComboLineDualConfig(oldChartConfig)) { + const leftAxisIri = oldChartConfig.fields.y.leftAxisComponentIri; + leftMeasure = getMeasure(leftAxisIri); + rightAxisMeasureIri = oldChartConfig.fields.y.rightAxisComponentIri; + } else if (isComboLineSingleConfig(oldChartConfig)) { + leftMeasure = getMeasure(oldChartConfig.fields.y.componentIris[0]); + } else if ( + isAreaConfig(oldChartConfig) || + isColumnConfig(oldChartConfig) || + isLineConfig(oldChartConfig) || + isPieConfig(oldChartConfig) || + isScatterPlotConfig(oldChartConfig) + ) { + leftMeasure = getMeasure(oldChartConfig.fields.y.componentIri); + } else if (isMapConfig(oldChartConfig)) { + const { areaLayer, symbolLayer } = oldChartConfig.fields; + const leftAxisIri = + areaLayer?.color.componentIri ?? symbolLayer?.measureIri; + + if (leftAxisIri) { + leftMeasure = getMeasure(leftAxisIri); + } + } + + return produce(newChartConfig, (draft) => { + draft.fields.y = { + columnComponentIri: leftMeasure.iri, + lineComponentIri: ( + numericalMeasures.find((d) => + rightAxisMeasureIri + ? d.iri === rightAxisMeasureIri + : d.unit !== leftMeasure.unit + ) as NumericalMeasure + ).iri, + lineAxisOrientation: "right", + }; + }); + }, + }, + interactiveFiltersConfig: interactiveFiltersAdjusters, + }, }; type ChartConfigAdjusters = typeof chartConfigsAdjusters[ChartType]; @@ -1159,86 +1473,287 @@ type ChartConfigAdjusters = typeof chartConfigsAdjusters[ChartType]; const chartConfigsPathOverrides: { [newChartType in ChartType]: { [oldChartType in ChartType]?: { - [oldFieldToOverride: string]: string; + [oldFieldToOverride: string]: { + path: string; + oldValue?: (d: any) => any; + }; }; }; } = { column: { map: { - "fields.areaLayer.componentIri": "fields.x.componentIri", - "fields.areaLayer.color.componentIri": "fields.y.componentIri", + "fields.areaLayer.componentIri": { path: "fields.x.componentIri" }, + "fields.areaLayer.color.componentIri": { path: "fields.y.componentIri" }, }, table: { - fields: "fields.segment", + fields: { path: "fields.segment" }, + }, + comboLineSingle: { + "fields.y.componentIris": { + path: "fields.y.componentIri", + oldValue: (d: ComboLineSingleFields["y"]["componentIris"]) => d[0], + }, + }, + comboLineDual: { + "fields.y.leftAxisComponentIri": { path: "fields.y.componentIri" }, + }, + comboLineColumn: { + "fields.y": { + path: "fields.y.componentIri", + oldValue: (d: ComboLineColumnFields["y"]) => { + return d.lineAxisOrientation === "left" + ? d.lineComponentIri + : d.columnComponentIri; + }, + }, }, }, line: { map: { - "fields.areaLayer.color.componentIri": "fields.y.componentIri", + "fields.areaLayer.color.componentIri": { path: "fields.y.componentIri" }, }, table: { - fields: "fields.segment", + fields: { path: "fields.segment" }, + }, + comboLineSingle: { + "fields.y.componentIris": { + path: "fields.y.componentIri", + oldValue: (d: ComboLineSingleFields["y"]["componentIris"]) => d[0], + }, + }, + comboLineDual: { + "fields.y.leftAxisComponentIri": { path: "fields.y.componentIri" }, + }, + comboLineColumn: { + "fields.y": { + path: "fields.y.componentIri", + oldValue: (d: ComboLineColumnFields["y"]) => { + return d.lineAxisOrientation === "left" + ? d.lineComponentIri + : d.columnComponentIri; + }, + }, }, }, area: { map: { - "fields.areaLayer.color.componentIri": "fields.y.componentIri", + "fields.areaLayer.color.componentIri": { path: "fields.y.componentIri" }, }, table: { - fields: "fields.segment", + fields: { path: "fields.segment" }, + }, + comboLineSingle: { + "fields.y.componentIris": { + path: "fields.y.componentIri", + oldValue: (d: ComboLineSingleFields["y"]["componentIris"]) => d[0], + }, + }, + comboLineDual: { + "fields.y.leftAxisComponentIri": { path: "fields.y.componentIri" }, + }, + comboLineColumn: { + "fields.y": { + path: "fields.y.componentIri", + oldValue: (d: ComboLineColumnFields["y"]) => { + return d.lineAxisOrientation === "left" + ? d.lineComponentIri + : d.columnComponentIri; + }, + }, }, }, scatterplot: { map: { - "fields.areaLayer.color.componentIri": "fields.y.componentIri", + "fields.areaLayer.color.componentIri": { path: "fields.y.componentIri" }, }, table: { - fields: "fields.segment", + fields: { path: "fields.segment" }, + }, + comboLineSingle: { + "fields.y.componentIris": { + path: "fields.y.componentIri", + oldValue: (d: ComboLineSingleFields["y"]["componentIris"]) => d[0], + }, + }, + comboLineDual: { + "fields.y.leftAxisComponentIri": { path: "fields.y.componentIri" }, + }, + comboLineColumn: { + "fields.y": { + path: "fields.y.componentIri", + oldValue: (d: ComboLineColumnFields["y"]) => { + return d.lineAxisOrientation === "left" + ? d.lineComponentIri + : d.columnComponentIri; + }, + }, }, }, pie: { map: { - "fields.areaLayer.componentIri": "fields.x.componentIri", - "fields.areaLayer.color.componentIri": "fields.y.componentIri", + "fields.areaLayer.componentIri": { path: "fields.x.componentIri" }, + "fields.areaLayer.color.componentIri": { path: "fields.y.componentIri" }, }, table: { - fields: "fields.segment", + fields: { path: "fields.segment" }, + }, + comboLineSingle: { + "fields.y.componentIris": { + path: "fields.y.componentIri", + oldValue: (d: ComboLineSingleFields["y"]["componentIris"]) => d[0], + }, + }, + comboLineDual: { + "fields.y.leftAxisComponentIri": { path: "fields.y.componentIri" }, + }, + comboLineColumn: { + "fields.y": { + path: "fields.y.componentIri", + oldValue: (d: ComboLineColumnFields["y"]) => { + return d.lineAxisOrientation === "left" + ? d.lineComponentIri + : d.columnComponentIri; + }, + }, }, }, table: { column: { - "fields.segment": "fields", + "fields.segment": { path: "fields" }, }, line: { - "fields.segment": "fields", + "fields.segment": { path: "fields" }, }, area: { - "fields.segment": "fields", + "fields.segment": { path: "fields" }, }, scatterplot: { - "fields.segment": "fields", + "fields.segment": { path: "fields" }, }, pie: { - "fields.segment": "fields", + "fields.segment": { path: "fields" }, }, }, map: { column: { - "fields.x.componentIri": "fields.areaLayer.componentIri", - "fields.y.componentIri": "fields.areaLayer.color.componentIri", + "fields.x.componentIri": { path: "fields.areaLayer.componentIri" }, + "fields.y.componentIri": { path: "fields.areaLayer.color.componentIri" }, + }, + line: { + "fields.y.componentIri": { path: "fields.areaLayer.color.componentIri" }, + }, + area: { + "fields.y.componentIri": { path: "fields.areaLayer.color.componentIri" }, + }, + scatterplot: { + "fields.y.componentIri": { path: "fields.areaLayer.color.componentIri" }, + }, + pie: { + "fields.x.componentIri": { path: "fields.areaLayer.componentIri" }, + "fields.y.componentIri": { path: "fields.areaLayer.color.componentIri" }, + }, + comboLineSingle: { + "fields.y.componentIris": { + path: "fields.areaLayer.color.componentIri", + oldValue: (d: ComboLineSingleFields["y"]["componentIris"]) => d[0], + }, + }, + comboLineDual: { + "fields.y.leftAxisComponentIri": { + path: "fields.areaLayer.color.componentIri", + }, + }, + comboLineColumn: { + "fields.y": { + path: "fields.areaLayer.color.componentIri", + oldValue: (d: ComboLineColumnFields["y"]) => { + return d.lineAxisOrientation === "left" + ? d.lineComponentIri + : d.columnComponentIri; + }, + }, + }, + }, + comboLineSingle: { + column: { + "fields.y.componentIri": { path: "fields.y.componentIris" }, + }, + line: { + "fields.y.componentIri": { path: "fields.y.componentIris" }, + }, + area: { + "fields.y.componentIri": { path: "fields.y.componentIris" }, + }, + scatterplot: { + "fields.y.componentIri": { path: "fields.y.componentIris" }, + }, + pie: { + "fields.y.componentIri": { path: "fields.y.componentIris" }, + }, + map: { + "fields.areaLayer.color.componentIri": { + path: "fields.y.componentIris", + }, + }, + comboLineDual: { + "fields.y.leftAxisComponentIri": { + path: "fields.y.componentIris", + }, + }, + comboLineColumn: { + "fields.y.lineComponentIri": { path: "fields.y.componentIris" }, + }, + }, + comboLineDual: { + column: { + "fields.y": { path: "fields.y" }, + }, + line: { + "fields.y": { path: "fields.y" }, + }, + area: { + "fields.y": { path: "fields.y" }, + }, + scatterplot: { + "fields.y": { path: "fields.y" }, + }, + pie: { + "fields.y": { path: "fields.y" }, + }, + map: { + "fields.areaLayer": { path: "fields.y" }, + }, + comboLineSingle: { + "fields.y": { path: "fields.y" }, + }, + comboLineColumn: { + "fields.y": { path: "fields.y" }, + }, + }, + comboLineColumn: { + column: { + "fields.y": { path: "fields.y" }, }, line: { - "fields.y.componentIri": "fields.areaLayer.color.componentIri", + "fields.y": { path: "fields.y" }, }, area: { - "fields.y.componentIri": "fields.areaLayer.color.componentIri", + "fields.y": { path: "fields.y" }, }, scatterplot: { - "fields.y.componentIri": "fields.areaLayer.color.componentIri", + "fields.y": { path: "fields.y" }, }, pie: { - "fields.x.componentIri": "fields.areaLayer.componentIri", - "fields.y.componentIri": "fields.areaLayer.color.componentIri", + "fields.y": { path: "fields.y" }, + }, + map: { + "fields.areaLayer": { path: "fields.y" }, + }, + comboLineSingle: { + "fields.y": { path: "fields.y" }, + }, + comboLineDual: { + "fields.y": { path: "fields.y" }, }, }, }; @@ -1266,7 +1781,7 @@ const adjustSegmentSorting = ({ }; // Helpers -export const getPossibleChartType = ({ +export const getPossibleChartTypes = ({ dimensions, measures, }: { @@ -1279,10 +1794,10 @@ export const getPossibleChartType = ({ const geoDimensions = getGeoDimensions(dimensions); const temporalDimensions = getTemporalDimensions(dimensions); - const categoricalEnabled: ChartType[] = ["column", "pie"]; - const geoEnabled: ChartType[] = ["column", "map", "pie"]; - const multipleNumericalMeasuresEnabled: ChartType[] = ["scatterplot"]; - const timeEnabled: ChartType[] = ["area", "column", "line"]; + const categoricalEnabled: RegularChartType[] = ["column", "pie"]; + const geoEnabled: RegularChartType[] = ["column", "map", "pie"]; + const multipleNumericalMeasuresEnabled: RegularChartType[] = ["scatterplot"]; + const timeEnabled: RegularChartType[] = ["area", "column", "line"]; const possibles: ChartType[] = ["table"]; if (numericalMeasures.length > 0) { @@ -1296,6 +1811,18 @@ export const getPossibleChartType = ({ if (numericalMeasures.length > 1) { possibles.push(...multipleNumericalMeasuresEnabled); + + if (temporalDimensions.length > 0) { + const uniqueUnits = Array.from( + new Set(numericalMeasures.map((d) => d.unit)) + ); + + if (uniqueUnits.length > 1) { + possibles.push(...comboChartTypes); + } else { + possibles.push("comboLineSingle"); + } + } } if (temporalDimensions.length > 0) { @@ -1312,9 +1839,11 @@ export const getPossibleChartType = ({ .sort((a, b) => chartTypesOrder[a] - chartTypesOrder[b]); }; -export const getFieldComponentIris = (fields: GenericFields) => { +export const getFieldComponentIris = (fields: ChartConfig["fields"]) => { return new Set( - Object.values(fields).flatMap((f) => (f ? [f.componentIri] : [])) + Object.values(fields).flatMap((f) => + f?.componentIri ? [f.componentIri] : [] + ) ); }; @@ -1334,8 +1863,12 @@ export const getHiddenFieldIris = (fields: GenericFields) => { ); }; -export const getFieldComponentIri = (fields: GenericFields, field: string) => { - return fields[field]?.componentIri; +export const getFieldComponentIri = ( + fields: ChartConfig["fields"], + field: string +): string | undefined => { + // Multi axis charts have multiple componentIris in the y field. + return (fields as $IntentionalAny)[field]?.componentIri; }; const convertTableFieldsToSegmentField = ({ diff --git a/app/charts/line/lines-state.tsx b/app/charts/line/lines-state.tsx index ea329bd64..5ad3722c9 100644 --- a/app/charts/line/lines-state.tsx +++ b/app/charts/line/lines-state.tsx @@ -58,6 +58,7 @@ export type LinesState = CommonChartState & xScale: ScaleTime; yScale: ScaleLinear; colors: ScaleOrdinal; + getColorLabel: (segment: string) => string; grouped: Map; chartWideData: ArrayLike; xKey: string; @@ -80,6 +81,7 @@ const useLinesState = ( segmentsByAbbreviationOrLabel, getSegment, getSegmentAbbreviationOrLabel, + getSegmentLabel, } = variables; const { chartData, @@ -126,12 +128,10 @@ const useLinesState = ( const xDomain = extent(chartData, (d) => getX(d)) as [Date, Date]; const xScale = scaleTime().domain(xDomain); - const interactiveXTimeRangeDomain = useMemo(() => { + const xScaleTimeRangeDomain = useMemo(() => { return extent(timeRangeData, (d) => getX(d)) as [Date, Date]; }, [timeRangeData, getX]); - const interactiveXTimeRangeScale = scaleTime().domain( - interactiveXTimeRangeDomain - ); + const xScaleTimeRange = scaleTime().domain(xScaleTimeRangeDomain); // y const minValue = Math.min(min(scalesData, getY) ?? 0, 0); @@ -219,7 +219,7 @@ const useLinesState = ( const { chartWidth, chartHeight } = bounds; xScale.range([0, chartWidth]); - interactiveXTimeRangeScale.range([0, chartWidth]); + xScaleTimeRange.range([0, chartWidth]); yScale.range([chartHeight, 0]); // Tooltip @@ -228,7 +228,6 @@ const useLinesState = ( const tooltipValues = chartData.filter( (d) => getX(d).getTime() === x.getTime() ); - const yValues = tooltipValues.map(getY); const sortedTooltipValues = sortByIndex({ data: tooltipValues, order: segments, @@ -237,6 +236,7 @@ const useLinesState = ( }); const xAnchor = xScale(x); + const yValues = tooltipValues.map(getY); const [yMin, yMax] = extent(yValues, (d) => d ?? 0) as [number, number]; const yAnchor = yScale((yMin + yMax) * 0.5); @@ -253,7 +253,7 @@ const useLinesState = ( placement: getCenteredTooltipPlacement({ chartWidth, xAnchor, - segment: !!fields.segment, + topAnchor: !fields.segment, }), xValue: timeFormatUnit(getX(datum), xDimension.timeUnit), datum: { @@ -267,6 +267,7 @@ const useLinesState = ( value: yValueFormatter(getY(td)), color: colors(getSegment(td)) as string, yPos: yScale(getY(td) ?? 0), + symbol: "line", })), }; }; @@ -277,10 +278,11 @@ const useLinesState = ( chartData, allData, xScale, - interactiveXTimeRangeScale, + xScaleTimeRange, yScale, segments, colors, + getColorLabel: getSegmentLabel, grouped: preparedDataGroupedBySegment, chartWideData, xKey, diff --git a/app/charts/pie/pie-state.tsx b/app/charts/pie/pie-state.tsx index 8a57fe920..b23dadfed 100644 --- a/app/charts/pie/pie-state.tsx +++ b/app/charts/pie/pie-state.tsx @@ -43,6 +43,7 @@ export type PieState = CommonChartState & chartType: "pie"; getPieData: Pie<$IntentionalAny, Observation>; colors: ScaleOrdinal; + getColorLabel: (segment: string) => string; getAnnotationInfo: (d: PieArcDatum) => TooltipInfo; }; @@ -59,6 +60,7 @@ const usePieState = ( segmentsByAbbreviationOrLabel, getSegment, getSegmentAbbreviationOrLabel, + getSegmentLabel, } = variables; // Segment dimension is guaranteed to be present, because it is required. const segmentDimension = _segmentDimension as DimensionMetadataFragment; @@ -258,6 +260,7 @@ const usePieState = ( allData, getPieData, colors, + getColorLabel: getSegmentLabel, getAnnotationInfo, ...variables, }; diff --git a/app/charts/scatterplot/scatterplot-state.tsx b/app/charts/scatterplot/scatterplot-state.tsx index c534aa75f..a489856cf 100644 --- a/app/charts/scatterplot/scatterplot-state.tsx +++ b/app/charts/scatterplot/scatterplot-state.tsx @@ -45,6 +45,7 @@ export type ScatterplotState = CommonChartState & yScale: ScaleLinear; hasSegment: boolean; colors: ScaleOrdinal; + getColorLabel: (segment: string) => string; getAnnotationInfo: (d: Observation, values: Observation[]) => TooltipInfo; }; @@ -63,6 +64,7 @@ const useScatterplotState = ( segmentsByAbbreviationOrLabel, getSegment, getSegmentAbbreviationOrLabel, + getSegmentLabel, } = variables; const { chartData, scalesData, segmentData, paddingData, allData } = data; const { fields, interactiveFiltersConfig } = chartConfig; @@ -215,6 +217,7 @@ const useScatterplotState = ( yScale, hasSegment, colors, + getColorLabel: getSegmentLabel, getAnnotationInfo, ...variables, }; diff --git a/app/charts/shared/axis-height-linear.tsx b/app/charts/shared/axis-height-linear.tsx index 58ad87199..bfccc64c7 100644 --- a/app/charts/shared/axis-height-linear.tsx +++ b/app/charts/shared/axis-height-linear.tsx @@ -1,10 +1,11 @@ -import { axisLeft, NumberValue } from "d3"; +import { axisLeft, axisRight, NumberValue, ScaleLinear } from "d3"; import React, { useEffect, useRef } from "react"; import type { AreasState } from "@/charts/area/areas-state"; import type { GroupedColumnsState } from "@/charts/column/columns-grouped-state"; import type { StackedColumnsState } from "@/charts/column/columns-stacked-state"; import type { ColumnsState } from "@/charts/column/columns-state"; +import { ComboLineSingleState } from "@/charts/combo/combo-line-single-state"; import type { LinesState } from "@/charts/line/lines-state"; import type { ScatterplotState } from "@/charts/scatterplot/scatterplot-state"; import { useChartState } from "@/charts/shared/chart-state"; @@ -24,24 +25,87 @@ import { getTextWidth } from "@/utils/get-text-width"; export const TICK_PADDING = 6; export const AxisHeightLinear = () => { - const ref = useRef(null); - const enableTransition = useTransitionStore((state) => state.enable); - const transitionDuration = useTransitionStore((state) => state.duration); - const formatNumber = useFormatNumber({ decimals: "auto" }); - const calculationType = useInteractiveFilters((d) => d.calculation.type); - const normalized = calculationType === "percent"; - - // FIXME: add "NumericalY" chart type here. - const { yScale, yAxisLabel, yMeasure, bounds } = useChartState() as + const { gridColor, labelColor, axisLabelFontSize } = useChartTheme(); + const [ref, setRef] = React.useState(null); + const state = useChartState() as | AreasState | ColumnsState | GroupedColumnsState | StackedColumnsState | LinesState - | ScatterplotState; - const { margins } = bounds; + | ScatterplotState + | ComboLineSingleState; + const axisTitleWidth = + getTextWidth(state.yAxisLabel, { + fontSize: axisLabelFontSize, + }) + TICK_PADDING; + + useRenderAxisHeightLinear(ref, { + id: "axis-height-linear", + orientation: "left", + scale: state.yScale, + width: state.bounds.chartWidth, + height: state.bounds.chartHeight, + margins: state.bounds.margins, + lineColor: gridColor, + textColor: labelColor, + }); + + return ( + <> + {state.chartType === "comboLineSingle" ? ( + + {state.yAxisLabel} + + ) : ( + + + + {state.yAxisLabel} + + + + )} + setRef(newRef)} /> + + ); +}; - const ticks = getTickNumber(bounds.chartHeight); +export const useRenderAxisHeightLinear = ( + container: SVGGElement | null, + { + id, + orientation, + scale, + width, + height, + margins, + lineColor, + textColor, + }: { + id: string; + orientation: "left" | "right"; + scale: ScaleLinear; + width: number; + height: number; + margins: { left: number; top: number }; + lineColor: string; + textColor: string; + } +) => { + const leftAligned = orientation === "left"; + const enableTransition = useTransitionStore((state) => state.enable); + const transitionDuration = useTransitionStore((state) => state.duration); + const { labelFontSize, fontFamily } = useChartTheme(); + const formatNumber = useFormatNumber({ decimals: "auto" }); + const calculationType = useInteractiveFilters((d) => d.calculation.type); + const normalized = calculationType === "percent"; + const ticks = getTickNumber(height); const tickFormat = React.useCallback( (d: NumberValue) => { return normalized ? `${formatNumber(d)}%` : formatNumber(d); @@ -49,75 +113,53 @@ export const AxisHeightLinear = () => { [formatNumber, normalized] ); - const { - labelColor, - labelFontSize, - axisLabelFontSize, - gridColor, - fontFamily, - } = useChartTheme(); - const titleWidth = - getTextWidth(yAxisLabel, { - fontSize: axisLabelFontSize, - }) + TICK_PADDING; + React.useEffect(() => { + if (!container) { + return; + } - useEffect(() => { - if (ref.current) { - const axis = axisLeft(yScale) - .ticks(ticks) - .tickSizeInner(-bounds.chartWidth) - .tickFormat(tickFormat) - .tickPadding(TICK_PADDING); - const g = renderContainer(ref.current, { - id: "axis-height-linear", - 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), - }), - }); + const axis = (leftAligned ? axisLeft : axisRight)(scale) + .ticks(ticks) + .tickSizeInner((leftAligned ? -1 : 1) * width) + .tickFormat(tickFormat) + .tickPadding(TICK_PADDING); + const g = renderContainer(container, { + id, + 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.select(".domain").remove(); - g.selectAll(".tick line") - .attr("stroke", gridColor) - .attr("stroke-width", 1); - g.selectAll(".tick text") - .attr("dy", 3) - .attr("fill", labelColor) - .attr("font-family", fontFamily) - .style("font-size", labelFontSize) - .attr("text-anchor", "end"); - } + g.select(".domain").remove(); + g.selectAll(".tick line").attr("stroke", lineColor).attr("stroke-width", 1); + g.selectAll(".tick text") + .attr("dy", 3) + .attr("fill", textColor) + .attr("font-family", fontFamily) + .style("font-size", labelFontSize) + .attr("text-anchor", leftAligned ? "end" : "start"); }, [ - bounds.chartWidth, + container, enableTransition, fontFamily, - gridColor, - labelColor, + id, labelFontSize, + leftAligned, + lineColor, margins.left, margins.top, + scale, + textColor, tickFormat, ticks, transitionDuration, - yScale, + width, ]); - - return ( - <> - {/* TODO: at some point it would make sense to allow wrapping */} - - - {yAxisLabel} - - - - - - ); }; export const AxisHeightLinearDomain = () => { diff --git a/app/charts/shared/axis-width-band.tsx b/app/charts/shared/axis-width-band.tsx index 842520287..f6e48407d 100644 --- a/app/charts/shared/axis-width-band.tsx +++ b/app/charts/shared/axis-width-band.tsx @@ -2,6 +2,7 @@ import { axisBottom } from "d3"; import { useEffect, useRef } from "react"; import { ColumnsState } from "@/charts/column/columns-state"; +import { ComboLineColumnState } from "@/charts/combo/combo-line-column-state"; import { useChartState } from "@/charts/shared/chart-state"; import { maybeTransition, @@ -13,8 +14,8 @@ import { useTransitionStore } from "@/stores/transition"; export const AxisWidthBand = () => { const ref = useRef(null); - const { xScale, yScale, bounds, xTimeUnit, getXLabel } = - useChartState() as ColumnsState; + const state = useChartState() as ColumnsState | ComboLineColumnState; + const { xScale, getXLabel, xTimeUnit, yScale, bounds } = state; const enableTransition = useTransitionStore((state) => state.enable); const transitionDuration = useTransitionStore((state) => state.duration); const formatDate = useTimeFormatUnit(); diff --git a/app/charts/shared/axis-width-time.tsx b/app/charts/shared/axis-width-time.tsx index 743445ca0..9c276cde5 100644 --- a/app/charts/shared/axis-width-time.tsx +++ b/app/charts/shared/axis-width-time.tsx @@ -2,6 +2,8 @@ import { axisBottom } from "d3"; import { useEffect, useRef } from "react"; import { AreasState } from "@/charts/area/areas-state"; +import { ComboLineDualState } from "@/charts/combo/combo-line-dual-state"; +import { ComboLineSingleState } from "@/charts/combo/combo-line-single-state"; import { LinesState } from "@/charts/line/lines-state"; import { useChartState } from "@/charts/shared/chart-state"; import { @@ -21,7 +23,11 @@ export const AxisTime = () => { const enableTransition = useTransitionStore((state) => state.enable); const transitionDuration = useTransitionStore((state) => state.duration); const formatDateAuto = useFormatShortDateAuto(); - const { xScale, yScale, bounds } = useChartState() as LinesState | AreasState; + const { xScale, yScale, bounds } = useChartState() as + | LinesState + | AreasState + | ComboLineSingleState + | ComboLineDualState; const { chartHeight, margins } = bounds; const { labelColor, gridColor, domainColor, labelFontSize, fontFamily } = useChartTheme(); @@ -87,7 +93,11 @@ export const AxisTimeDomain = () => { const ref = useRef(null); const enableTransition = useTransitionStore((state) => state.enable); const transitionDuration = useTransitionStore((state) => state.duration); - const { xScale, yScale, bounds } = useChartState() as LinesState | AreasState; + const { xScale, yScale, bounds } = useChartState() as + | LinesState + | AreasState + | ComboLineSingleState + | ComboLineDualState; const { chartHeight, margins } = bounds; const { domainColor } = useChartTheme(); diff --git a/app/charts/shared/brush/index.tsx b/app/charts/shared/brush/index.tsx index f2dcbe3a3..b92b54d83 100644 --- a/app/charts/shared/brush/index.tsx +++ b/app/charts/shared/brush/index.tsx @@ -55,7 +55,7 @@ export const BrushTime = () => { brushHandleFillColor, labelFontSize, } = useChartTheme(); - const { chartType, bounds, interactiveXTimeRangeScale } = + const { chartType, bounds, xScaleTimeRange } = useChartState() as ChartWithInteractiveXTimeRangeState; const { getX } = useChartState() as LinesState | AreasState; const { getXAsDate, allData } = useChartState() as ColumnsState; @@ -65,13 +65,13 @@ export const BrushTime = () => { // Brush dimensions const { width, margins, chartHeight } = bounds; const brushLabelsWidth = - getTextWidth(formatDateAuto(interactiveXTimeRangeScale.domain()[0]), { + getTextWidth(formatDateAuto(xScaleTimeRange.domain()[0]), { fontSize: labelFontSize, }) * 2 + 20; const brushWidth = width - brushLabelsWidth - margins.right; - const brushWidthScale = interactiveXTimeRangeScale.copy(); + const brushWidthScale = xScaleTimeRange.copy(); brushWidthScale.range([0, brushWidth]); diff --git a/app/charts/shared/chart-helpers.tsx b/app/charts/shared/chart-helpers.tsx index 85d619b8a..5a39cc3c4 100644 --- a/app/charts/shared/chart-helpers.tsx +++ b/app/charts/shared/chart-helpers.tsx @@ -172,7 +172,7 @@ export const usePlottableData = ( ) => { const isPlottable = useCallback( (d: Observation) => { - for (let p of [getX, getY].filter(truthy)) { + for (const p of [getX, getY].filter(truthy)) { const v = p(d); if (v === undefined || v === null) { return false; diff --git a/app/charts/shared/chart-state.ts b/app/charts/shared/chart-state.ts index f1acd7f70..2bd11db58 100644 --- a/app/charts/shared/chart-state.ts +++ b/app/charts/shared/chart-state.ts @@ -7,6 +7,9 @@ import { AreasState } from "@/charts/area/areas-state"; import { GroupedColumnsState } from "@/charts/column/columns-grouped-state"; import { StackedColumnsState } from "@/charts/column/columns-stacked-state"; import { ColumnsState } from "@/charts/column/columns-state"; +import { ComboLineColumnState } from "@/charts/combo/combo-line-column-state"; +import { ComboLineDualState } from "@/charts/combo/combo-line-dual-state"; +import { ComboLineSingleState } from "@/charts/combo/combo-line-single-state"; import { LinesState } from "@/charts/line/lines-state"; import { MapState } from "@/charts/map/map-state"; import { PieState } from "@/charts/pie/pie-state"; @@ -57,6 +60,9 @@ export type ChartState = | ColumnsState | StackedColumnsState | GroupedColumnsState + | ComboLineSingleState + | ComboLineColumnState + | ComboLineDualState | LinesState | MapState | PieState @@ -526,5 +532,5 @@ export const useChartData = ( // TODO: base this on UI encodings? export type InteractiveXTimeRangeState = { - interactiveXTimeRangeScale: ScaleTime; + xScaleTimeRange: ScaleTime; }; diff --git a/app/charts/shared/interaction/hover-dots-multiple.tsx b/app/charts/shared/interaction/hover-dots-multiple.tsx index fff80e8fd..4aa2dc74e 100644 --- a/app/charts/shared/interaction/hover-dots-multiple.tsx +++ b/app/charts/shared/interaction/hover-dots-multiple.tsx @@ -2,6 +2,7 @@ import { Box, Theme } from "@mui/material"; import { makeStyles } from "@mui/styles"; import { Fragment } from "react"; +import { ComboLineSingleState } from "@/charts/combo/combo-line-single-state"; import { LinesState } from "@/charts/line/lines-state"; import { useChartState } from "@/charts/shared/chart-state"; import { useInteraction } from "@/charts/shared/use-interaction"; @@ -30,8 +31,9 @@ const useStyles = makeStyles((theme: Theme) => ({ })); const HoverDots = ({ d }: { d: Observation }) => { - const { getAnnotationInfo, bounds } = useChartState() as LinesState; - + const { getAnnotationInfo, bounds } = useChartState() as + | LinesState + | ComboLineSingleState; const { xAnchor, values } = getAnnotationInfo(d); const classes = useStyles(); diff --git a/app/charts/shared/interaction/ruler.tsx b/app/charts/shared/interaction/ruler.tsx index 30aa90e20..840e467ff 100644 --- a/app/charts/shared/interaction/ruler.tsx +++ b/app/charts/shared/interaction/ruler.tsx @@ -1,6 +1,9 @@ import { Box, Theme } from "@mui/material"; import { makeStyles } from "@mui/styles"; +import { ComboLineColumnState } from "@/charts/combo/combo-line-column-state"; +import { ComboLineDualState } from "@/charts/combo/combo-line-dual-state"; +import { ComboLineSingleState } from "@/charts/combo/combo-line-single-state"; import { LinesState } from "@/charts/line/lines-state"; import { useChartState } from "@/charts/shared/chart-state"; import { @@ -11,18 +14,35 @@ import { useInteraction } from "@/charts/shared/use-interaction"; import { Margins } from "@/charts/shared/use-width"; import { Observation } from "@/domain/data"; -export const Ruler = () => { +type RulerProps = { + rotate?: boolean; +}; + +export const Ruler = (props: RulerProps) => { + const { rotate = false } = props; const [state] = useInteraction(); const { visible, d } = state.interaction; - return <>{visible && d && }; + + return <>{visible && d && }; +}; + +type RulerInnerProps = { + d: Observation; + rotate: boolean; }; -const RulerInner = ({ d }: { d: Observation }) => { - const { getAnnotationInfo, bounds } = useChartState() as LinesState; +const RulerInner = (props: RulerInnerProps) => { + const { d, rotate } = props; + const { getAnnotationInfo, bounds } = useChartState() as + | LinesState + | ComboLineSingleState + | ComboLineDualState + | ComboLineColumnState; const { xAnchor, xValue, datum, placement, values } = getAnnotationInfo(d); return ( { ); }; -interface RulerContentProps { +type RulerContentProps = { + rotate: boolean; xValue: string; values: TooltipValue[] | undefined; chartHeight: number; @@ -42,9 +63,9 @@ interface RulerContentProps { xAnchor: number; datum: TooltipValue; placement: TooltipPlacement; -} +}; -const useStyles = makeStyles((theme: Theme) => ({ +const useStyles = makeStyles((theme: Theme) => ({ left: { width: 0, position: "absolute", @@ -58,7 +79,10 @@ const useStyles = makeStyles((theme: Theme) => ({ position: "absolute", fontWeight: "bold", backgroundColor: theme.palette.grey[100], - transform: "translateX(-50%)", + transform: ({ rotate }) => + rotate + ? "translateX(-50%) translateY(50%) rotate(90deg)" + : "translateX(-50%)", paddingLeft: theme.spacing(1), paddingRight: theme.spacing(1), fontSize: "0.875rem", @@ -66,13 +90,10 @@ const useStyles = makeStyles((theme: Theme) => ({ }, })); -export const RulerContent = ({ - xValue, - chartHeight, - margins, - xAnchor, -}: RulerContentProps) => { - const classes = useStyles(); +export const RulerContent = (props: RulerContentProps) => { + const { rotate, xValue, chartHeight, margins, xAnchor } = props; + const classes = useStyles({ rotate }); + return ( <> { export const getCenteredTooltipPlacement = (props: { chartWidth: number; xAnchor: number; - segment: boolean; + topAnchor: boolean; }): TooltipPlacement => { - const { chartWidth, xAnchor, segment } = props; + const { chartWidth, xAnchor, topAnchor } = props; - return segment + return topAnchor ? { - x: xAnchor < chartWidth * 0.25 ? "right" : "left", - y: "middle", - } - : { x: "center", y: "top", + } + : { + x: xAnchor < chartWidth * 0.25 ? "right" : "left", + y: "middle", }; }; diff --git a/app/charts/shared/interaction/tooltip-content.tsx b/app/charts/shared/interaction/tooltip-content.tsx index 23b9a6a62..dd4fc3c89 100644 --- a/app/charts/shared/interaction/tooltip-content.tsx +++ b/app/charts/shared/interaction/tooltip-content.tsx @@ -59,14 +59,12 @@ export const TooltipMultiple = ({ {xValue} )} - {segmentValues.map((segment, i) => ( + {segmentValues.map((d, i) => ( ))} diff --git a/app/charts/shared/interaction/tooltip.tsx b/app/charts/shared/interaction/tooltip.tsx index f886fae1b..dfbd87c72 100644 --- a/app/charts/shared/interaction/tooltip.tsx +++ b/app/charts/shared/interaction/tooltip.tsx @@ -11,6 +11,7 @@ import { TooltipMultiple, TooltipSingle, } 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"; @@ -30,6 +31,7 @@ export interface TooltipValue { error?: string; color: string; yPos?: number; + symbol?: LegendSymbol; } export interface TooltipInfo { xAnchor: number; diff --git a/app/charts/shared/legend-color.tsx b/app/charts/shared/legend-color.tsx index 4a259fd2b..9c2c3e979 100644 --- a/app/charts/shared/legend-color.tsx +++ b/app/charts/shared/legend-color.tsx @@ -31,7 +31,7 @@ import { MaybeTooltip } from "@/utils/maybe-tooltip"; import { makeDimensionValueSorters } from "@/utils/sorting-values"; import useEvent from "@/utils/use-event"; -type LegendSymbol = "square" | "line" | "circle"; +export type LegendSymbol = "square" | "line" | "circle"; const useStyles = makeStyles((theme) => ({ legendContainer: { @@ -207,7 +207,7 @@ type LegendColorProps = { export const LegendColor = memo(function LegendColor(props: LegendColorProps) { const { chartConfig, symbol, interactive } = props; - const { colors, getSegmentLabel } = useChartState() as ColorsChartState; + const { colors, getColorLabel } = useChartState() as ColorsChartState; const values = colors.domain(); const groups = useLegendGroups({ chartConfig, values }); @@ -215,7 +215,7 @@ export const LegendColor = memo(function LegendColor(props: LegendColorProps) { colors(v)} - getLabel={getSegmentLabel} + getLabel={getColorLabel} symbol={symbol} interactive={interactive} numberOfOptions={values.length} diff --git a/app/charts/shared/overlay-horizontal.tsx b/app/charts/shared/overlay-horizontal.tsx index 145b6e4d3..6e1aa916d 100644 --- a/app/charts/shared/overlay-horizontal.tsx +++ b/app/charts/shared/overlay-horizontal.tsx @@ -2,67 +2,85 @@ import { bisector, pointer } from "d3"; import { MouseEvent, memo, useRef } from "react"; import { AreasState } from "@/charts/area/areas-state"; +import { ComboLineColumnState } from "@/charts/combo/combo-line-column-state"; +import { ComboLineDualState } from "@/charts/combo/combo-line-dual-state"; +import { ComboLineSingleState } from "@/charts/combo/combo-line-single-state"; import { LinesState } from "@/charts/line/lines-state"; import { useChartState } from "@/charts/shared/chart-state"; import { useInteraction } from "@/charts/shared/use-interaction"; import { Observation } from "@/domain/data"; export const InteractionHorizontal = memo(function InteractionHorizontal() { + const chartState = useChartState() as + | AreasState + | LinesState + | ComboLineSingleState + | ComboLineDualState + | ComboLineColumnState; + const { chartData, chartWideData, xScale, getX } = + chartState.chartType === "comboLineColumn" + ? { + chartData: chartState.chartData, + chartWideData: chartState.chartWideData, + xScale: chartState.xScaleTime, + getX: chartState.getXAsDate, + } + : chartState; + const { chartWidth, chartHeight, margins } = chartState.bounds; const [state, dispatch] = useInteraction(); const ref = useRef(null); - const { chartData, bounds, getX, xScale, chartWideData } = useChartState() as - | AreasState - | LinesState; - - const { chartWidth, chartHeight, margins } = bounds; + const hideTooltip = () => { + dispatch({ + type: "INTERACTION_HIDE", + }); + }; const findDatum = (e: MouseEvent) => { const [x, y] = pointer(e, ref.current!); - const bisectDate = bisector( - (ds: Observation, date: Date) => getX(ds).getTime() - date.getTime() + (d: Observation, date: Date) => getX(d).getTime() - date.getTime() ).left; - const thisDate = xScale.invert(x); const i = bisectDate(chartWideData, thisDate, 1); const dLeft = chartWideData[i - 1]; - const dRight = chartWideData[i] || dLeft; - + const dRight = chartWideData[i] ?? dLeft; const closestDatum = thisDate.getTime() - getX(dLeft).getTime() > getX(dRight).getTime() - thisDate.getTime() ? dRight : dLeft; - if (closestDatum) { - const closestDatumTime = getX(closestDatum).getTime(); - const datumToUpdate = chartData.find( - (d) => closestDatumTime === getX(d).getTime() - ) as Observation; + if (!closestDatum) { + if (state.interaction.visible) { + hideTooltip(); + } + + return; + } - if ( - !state.interaction.d || - closestDatumTime !== getX(state.interaction.d).getTime() - ) { - dispatch({ - type: "INTERACTION_UPDATE", - value: { - interaction: { - visible: true, - mouse: { x, y }, - d: datumToUpdate, - }, + const closestDatumTime = getX(closestDatum).getTime(); + const datumToUpdate = chartData.find( + (d) => closestDatumTime === getX(d).getTime() + ) as Observation; + + if ( + !state.interaction.d || + closestDatumTime !== getX(state.interaction.d).getTime() || + !state.interaction.visible + ) { + dispatch({ + type: "INTERACTION_UPDATE", + value: { + interaction: { + visible: true, + mouse: { x, y }, + d: datumToUpdate, }, - }); - } + }, + }); } }; - const hideTooltip = () => { - dispatch({ - type: "INTERACTION_HIDE", - }); - }; return ( diff --git a/app/components/chart-with-filters.tsx b/app/components/chart-with-filters.tsx index d2488ce27..6fb5571e6 100644 --- a/app/components/chart-with-filters.tsx +++ b/app/components/chart-with-filters.tsx @@ -21,6 +21,24 @@ const ChartColumnsVisualization = dynamic( () => null as never ) ); +const ChartComboLineSingleVisualization = dynamic( + import("@/charts/combo/chart-combo-line-single").then( + (mod) => mod.ChartComboLineSingleVisualization, + () => null as never + ) +); +const ChartComboLineDualVisualization = dynamic( + import("@/charts/combo/chart-combo-line-dual").then( + (mod) => mod.ChartComboLineDualVisualization, + () => null as never + ) +); +const ChartComboLineColumnVisualization = dynamic( + import("@/charts/combo/chart-combo-line-column").then( + (mod) => mod.ChartComboLineColumnVisualization, + () => null as never + ) +); const ChartLinesVisualization = dynamic( import("@/charts/line/chart-lines").then( (mod) => mod.ChartLinesVisualization, @@ -101,6 +119,28 @@ const GenericChart = (props: GenericChartProps) => { return ( ); + case "comboLineSingle": + return ( + + ); + case "comboLineDual": + return ( + + ); + case "comboLineColumn": + return ( + + ); + default: const _exhaustiveCheck: never = chartConfig; return _exhaustiveCheck; diff --git a/app/components/form.tsx b/app/components/form.tsx index fb4cdd319..140733fd2 100644 --- a/app/components/form.tsx +++ b/app/components/form.tsx @@ -318,6 +318,7 @@ export const Select = ({ onClose, onOpen, loading, + sx, }: { id: string; options: SelectOption[]; @@ -347,7 +348,7 @@ export const Select = ({ return ( - + {label && (