diff --git a/app/charts/area/areas-state.tsx b/app/charts/area/areas-state.tsx index 7e4ac9dc7..e386c6384 100644 --- a/app/charts/area/areas-state.tsx +++ b/app/charts/area/areas-state.tsx @@ -29,17 +29,17 @@ import { stackOffsetDivergingPositiveZeros, useOptionalNumericVariable, usePlottableData, - usePreparedData, + useDataAfterInteractiveFilters, useSegment, useStringVariable, useTemporalVariable, } from "@/charts/shared/chart-helpers"; +import { CommonChartState } from "@/charts/shared/chart-state"; import { TooltipInfo } from "@/charts/shared/interaction/tooltip"; import useChartFormatters from "@/charts/shared/use-chart-formatters"; import { ChartContext, ChartProps } from "@/charts/shared/use-chart-state"; import { InteractionProvider } from "@/charts/shared/use-interaction"; -import { useInteractiveFilters } from "@/charts/shared/use-interactive-filters"; -import { Bounds, Observer, useWidth } from "@/charts/shared/use-width"; +import { Observer, useWidth } from "@/charts/shared/use-width"; import { AreaFields } from "@/configurator"; import { isTemporalDimension, Observation } from "@/domain/data"; import { @@ -52,10 +52,11 @@ import { sortByIndex } from "@/utils/array"; import { estimateTextWidth } from "@/utils/estimate-text-width"; import { makeDimensionValueSorters } from "@/utils/sorting-values"; -export interface AreasState { +export interface AreasState extends CommonChartState { chartType: "area"; data: Observation[]; - bounds: Bounds; + allData: Observation[]; + preparedData: Observation[]; getX: (d: Observation) => Date; xScale: ScaleTime; xEntireScale: ScaleTime; @@ -92,8 +93,8 @@ const useAreasState = ( } = chartProps; const width = useWidth(); const formatNumber = useFormatNumber(); + const estimateNumberWidth = (d: number) => estimateTextWidth(formatNumber(d)); const timeFormatUnit = useTimeFormatUnit(); - const [interactiveFilters] = useInteractiveFilters(); const xDimension = dimensions.find((d) => d.iri === fields.x.componentIri); @@ -133,10 +134,6 @@ const useAreasState = ( ); const xKey = fields.x.componentIri; - const hasInteractiveTimeFilter = useMemo( - () => interactiveFiltersConfig?.timeRange.active, - [interactiveFiltersConfig?.timeRange.active] - ); // All Data (used for brushing) const sortedData = useMemo( @@ -168,12 +165,9 @@ const useAreasState = ( plotters: [getX, getY], }); - // Data for chart - const preparedData = usePreparedData({ - legendFilterActive: interactiveFiltersConfig?.legend.active, - timeRangeFilterActive: interactiveFiltersConfig?.timeRange.active, + const preparedData = useDataAfterInteractiveFilters({ sortedData: plottableSortedData, - interactiveFilters, + interactiveFiltersConfig, getX, getSegment, }); @@ -320,18 +314,18 @@ const useAreasState = ( ]); /** Dimensions */ - const left = hasInteractiveTimeFilter - ? estimateTextWidth(formatNumber(entireMaxTotalValue)) - : Math.max( - estimateTextWidth(formatNumber(yScale.domain()[0])), - estimateTextWidth(formatNumber(yScale.domain()[1])) - ); - const bottom = hasInteractiveTimeFilter ? BRUSH_BOTTOM_SPACE : 40; + const [yMin, yMax] = yScale.domain(); + const left = interactiveFiltersConfig?.timeRange.active + ? estimateNumberWidth(entireMaxTotalValue) + : Math.max(estimateNumberWidth(yMin), estimateNumberWidth(yMax)); + const bottom = interactiveFiltersConfig?.timeRange.active + ? BRUSH_BOTTOM_SPACE + : 40; const margins = { top: 50, right: 40, - bottom: bottom, + bottom, left: left + LEFT_MARGIN_OFFSET, }; const chartWidth = width - margins.left - margins.right; @@ -366,11 +360,8 @@ const useAreasState = ( }); const yAnchor = 0; - const xPlacement = "center"; - const yPlacement = "top"; - const yValueFormatter = (value: number | null) => formatNumberWithUnit( value, @@ -400,8 +391,10 @@ const useAreasState = ( return { chartType: "area", - data, bounds, + data, + allData: plottableSortedData, + preparedData, getX, xScale, xEntireScale, diff --git a/app/charts/bar/bars-grouped-state.tsx b/app/charts/bar/bars-grouped-state.tsx deleted file mode 100644 index 0989b7031..000000000 --- a/app/charts/bar/bars-grouped-state.tsx +++ /dev/null @@ -1,302 +0,0 @@ -import { - ascending, - descending, - group, - max, - min, - rollup, - scaleBand, - ScaleBand, - ScaleLinear, - scaleLinear, - ScaleOrdinal, - scaleOrdinal, - sum, -} from "d3"; -import React, { ReactNode, useMemo } from "react"; - -import { - BAR_HEIGHT, - BAR_SPACE_ON_TOP, - BOTTOM_MARGIN_OFFSET, -} from "@/charts/bar/constants"; -import { - useNumericVariable, - useSegment, - useStringVariable, -} from "@/charts/shared/chart-helpers"; -import { ChartContext, ChartProps } from "@/charts/shared/use-chart-state"; -import { InteractionProvider } from "@/charts/shared/use-interaction"; -import { InteractiveFiltersProvider } from "@/charts/shared/use-interactive-filters"; -import { Bounds, Observer, useWidth } from "@/charts/shared/use-width"; -import { BarFields, SortingOrder, SortingType } from "@/configurator"; -import { mkNumber } from "@/configurator/components/ui-helpers"; -import { Observation } from "@/domain/data"; -import { useLocale } from "@/locales/use-locale"; -import { getPalette } from "@/palettes"; -import { sortByIndex } from "@/utils/array"; - -export interface GroupedBarsState { - chartType: string; - sortedData: Observation[]; - bounds: Bounds; - getX: (d: Observation) => number; - xScale: ScaleLinear; - getY: (d: Observation) => string; - yScale: ScaleBand; - yScaleIn: ScaleBand; - getSegment: (d: Observation) => string; - segments: string[]; - xAxisLabel: string; - colors: ScaleOrdinal; - grouped: [string, Observation[]][]; -} - -const useGroupedBarsState = ({ - data, - fields, - dimensions, - measures, -}: Pick & { - fields: BarFields; -}): GroupedBarsState => { - const locale = useLocale(); - const width = useWidth(); - - const getX = useNumericVariable(fields.x.componentIri); - const getY = useStringVariable(fields.y.componentIri); - const getSegment = useSegment(fields.segment?.componentIri); - - const xAxisLabel = - measures.find((d) => d.iri === fields.x.componentIri)?.label ?? - fields.y.componentIri; - - // Sort - const ySortingType = fields.y.sorting?.sortingType; - const ySortingOrder = fields.y.sorting?.sortingOrder; - const yOrder = [ - ...rollup( - data, - (v) => sum(v, (x) => getX(x)), - (x) => getY(x) - ), - ] - .sort((a, b) => ascending(a[1], b[1])) - .map((d) => d[0]); - - const sortedData = useMemo( - () => - sortData({ - data, - getY, - ySortingType, - ySortingOrder, - yOrder, - }), - [data, getY, ySortingType, ySortingOrder, yOrder] - ); - // segments - const segmentSortingType = fields.segment?.sorting?.sortingType; - const segmentSortingOrder = fields.segment?.sorting?.sortingOrder; - const segmentsOrderedByName = Array.from( - new Set(sortedData.map((d) => getSegment(d))) - ).sort((a, b) => - segmentSortingOrder === "asc" - ? a.localeCompare(b, locale) - : b.localeCompare(a, locale) - ); - - const segmentsOrderedByTotalValue = [ - ...rollup( - sortedData, - (v) => sum(v, (x) => getX(x)), - (x) => getSegment(x) - ), - ] - .sort((a, b) => - segmentSortingOrder === "asc" - ? ascending(a[1], b[1]) - : descending(a[1], b[1]) - ) - .map((d) => d[0]); - - const segments = - segmentSortingType === "byDimensionLabel" - ? segmentsOrderedByName - : segmentsOrderedByTotalValue; - - // Map ordered segments to colors - const colors = scaleOrdinal(); - const segmentDimension = dimensions.find( - (d) => d.iri === fields.segment?.componentIri - ) as $FixMe; - - if (fields.segment && segmentDimension && fields.segment.colorMapping) { - const orderedSegmentLabelsAndColors = segments.map((segment) => { - const dvIri = segmentDimension.values.find( - (s: $FixMe) => s.label === segment - ).value; - - return { - label: segment, - color: fields.segment?.colorMapping![dvIri] || "#006699", - }; - }); - - colors.domain(orderedSegmentLabelsAndColors.map((s) => s.label)); - colors.range(orderedSegmentLabelsAndColors.map((s) => s.color)); - } else { - colors.domain(segments); - colors.range(getPalette(fields.segment?.palette)); - } - - // x - const minValue = Math.min(mkNumber(min(sortedData, (d) => getX(d))), 0); - const maxValue = max(sortedData, (d) => getX(d)) as number; - const xScale = scaleLinear() - .domain([mkNumber(minValue), mkNumber(maxValue)]) - .nice(); - - // y - const bandDomain = [...new Set(sortedData.map((d) => getY(d) as string))]; - const chartHeight = - bandDomain.length * (BAR_HEIGHT * segments.length + BAR_SPACE_ON_TOP); - - const yScale = scaleBand().domain(bandDomain).range([0, chartHeight]); - - const yScaleIn = scaleBand() - .domain(segments) - // .padding(0) - .range([0, BAR_HEIGHT * segments.length]); - - // Group - const groupedMap = group(sortedData, getY); - const grouped = [...groupedMap]; - - // sort by segments - grouped.forEach((group) => { - return [ - group[0], - sortByIndex({ - data: group[1], - order: segments, - getCategory: getSegment, - sortOrder: segmentSortingOrder, - }), - ]; - }); - - const margins = { - top: 0, - right: 40, - bottom: BOTTOM_MARGIN_OFFSET, - left: 0, - }; - const chartWidth = width - margins.left - margins.right; - const bounds = { - width, - height: chartHeight + margins.top + margins.bottom, - margins, - chartWidth, - chartHeight, - }; - - xScale.range([0, chartWidth]); - - return { - chartType: "bar", - sortedData, - bounds, - getX, - xScale, - getY, - yScale, - yScaleIn, - getSegment, - segments, - xAxisLabel, - colors, - grouped, - }; -}; - -const GroupedBarsChartProvider = ({ - data, - fields, - dimensions, - measures, - - children, -}: Pick & { - children: ReactNode; - fields: BarFields; -}) => { - const state = useGroupedBarsState({ - data, - fields, - dimensions, - measures, - }); - return ( - {children} - ); -}; - -export const GroupedBarsChart = ({ - data, - fields, - dimensions, - measures, - children, -}: Pick & { - children: ReactNode; - fields: BarFields; -}) => { - return ( - - - - - {children} - - - - - ); -}; - -const sortData = ({ - data, - getY, - ySortingType, - ySortingOrder, - yOrder, -}: { - data: Observation[]; - getY: (d: Observation) => string; - ySortingType: SortingType | undefined; - ySortingOrder: SortingOrder | undefined; - yOrder: string[]; -}) => { - if (ySortingOrder === "desc" && ySortingType === "byDimensionLabel") { - return [...data].sort((a, b) => descending(getY(a), getY(b))); - } else if (ySortingOrder === "asc" && ySortingType === "byDimensionLabel") { - return [...data].sort((a, b) => ascending(getY(a), getY(b))); - } else if (ySortingType === "byMeasure") { - const sd = sortByIndex({ - data, - order: yOrder, - getCategory: getY, - sortOrder: ySortingOrder, - }); - return sd; - } else { - // default to scending alphabetical - return [...data].sort((a, b) => ascending(getY(a), getY(b))); - } -}; diff --git a/app/charts/bar/bars-grouped.tsx b/app/charts/bar/bars-grouped.tsx deleted file mode 100644 index aaeb7b32a..000000000 --- a/app/charts/bar/bars-grouped.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import { GroupedBarsState } from "@/charts/bar/bars-grouped-state"; -import { Bar } from "@/charts/bar/bars-simple"; -import { BAR_AXIS_OFFSET, BAR_SPACE_ON_TOP } from "@/charts/bar/constants"; -import { useChartState } from "@/charts/shared/use-chart-state"; -import { useChartTheme } from "@/charts/shared/use-chart-theme"; -import { useInteractiveFilters } from "@/charts/shared/use-interactive-filters"; - -export const BarsGrouped = () => { - const { - bounds, - xScale, - yScaleIn, - getX, - yScale, - getSegment, - colors, - grouped, - } = useChartState() as GroupedBarsState; - const { margins } = bounds; - const { - markBorderColor, - axisLabelFontSize, - axisLabelFontWeight, - axisLabelColor, - } = useChartTheme(); - const [interactiveFilters] = useInteractiveFilters(); - const { categories } = interactiveFilters; - const activeInteractiveFilters = Object.keys(categories); - - return ( - - {grouped.map((segment, i) => { - return ( - - - {segment[0]} - - - {segment[1].map((d, i) => ( - - ))} - - - ); - })} - - ); -}; - -export const BarsGroupedLabels = () => { - const { bounds, yScaleIn, getX, yScale, getSegment, grouped } = - useChartState() as GroupedBarsState; - const { margins } = bounds; - const { axisLabelColor, labelFontSize, fontFamily } = useChartTheme(); - - return ( - - {grouped.map((segment, i) => { - return ( - - - {segment[1].map((d, i) => ( - - {getX(d)} {getSegment(d)} - - ))} - - - ); - })} - - ); -}; diff --git a/app/charts/bar/bars-simple.tsx b/app/charts/bar/bars-simple.tsx deleted file mode 100644 index 8f70bc73d..000000000 --- a/app/charts/bar/bars-simple.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import { memo } from "react"; - -import { BarsState } from "@/charts/bar/bars-state"; -import { - BAR_AXIS_OFFSET, - BAR_HEIGHT, - BAR_SPACE_ON_TOP, -} from "@/charts/bar/constants"; -import { useChartState } from "@/charts/shared/use-chart-state"; -import { useChartTheme } from "@/charts/shared/use-chart-theme"; -import { useTheme } from "@/themes"; - -export const Bars = () => { - const { sortedData, bounds, getX, xScale, getY, yScale } = - useChartState() as BarsState; - const theme = useTheme(); - const { axisLabelFontSize, axisLabelFontWeight, axisLabelColor } = - useChartTheme(); - const { margins } = bounds; - - return ( - - {sortedData.map((d, i) => { - return ( - - - {getY(d)} - - - - ); - })} - - ); -}; - -export const BarLabels = () => { - const { sortedData, bounds, getX, getY, yScale } = - useChartState() as BarsState; - const { labelColor, labelFontSize, fontFamily } = useChartTheme(); - const { margins } = bounds; - - return ( - - {sortedData.map((d, i) => { - return ( - - - {getX(d)} - - - ); - })} - - ); -}; - -export const Bar = memo( - ({ - x, - y, - width, - height, - color, - fillOpacity = 1, - stroke, - }: { - x: number; - y: number; - width: number; - height: number; - color: string; - fillOpacity?: number; - stroke?: string; - }) => { - return ( - - ); - } -); diff --git a/app/charts/bar/bars-state.tsx b/app/charts/bar/bars-state.tsx deleted file mode 100644 index f44539b44..000000000 --- a/app/charts/bar/bars-state.tsx +++ /dev/null @@ -1,198 +0,0 @@ -import { - ascending, - descending, - max, - min, - scaleBand, - ScaleBand, - ScaleLinear, - scaleLinear, - ScaleOrdinal, - scaleOrdinal, -} from "d3"; -import { ReactNode, useMemo } from "react"; - -import { - BAR_HEIGHT, - BAR_SPACE_ON_TOP, - BOTTOM_MARGIN_OFFSET, - LEFT_MARGIN_OFFSET, -} from "@/charts/bar/constants"; -import { - useNumericVariable, - useSegment, - useStringVariable, -} from "@/charts/shared/chart-helpers"; -import { ChartContext, ChartProps } from "@/charts/shared/use-chart-state"; -import { InteractionProvider } from "@/charts/shared/use-interaction"; -import { Bounds, Observer, useWidth } from "@/charts/shared/use-width"; -import { BarFields, SortingOrder, SortingType } from "@/configurator"; -import { mkNumber } from "@/configurator/components/ui-helpers"; -import { Observation } from "@/domain/data"; -import { getPalette } from "@/palettes"; - -export interface BarsState { - chartType: "bar"; - bounds: Bounds; - sortedData: Observation[]; - getX: (d: Observation) => number; - xScale: ScaleLinear; - getY: (d: Observation) => string; - yScale: ScaleBand; - getSegment: (d: Observation) => string; - segments: string[]; - xAxisLabel: string; - xAxisDescription: string | undefined; - colors: ScaleOrdinal; -} - -const MARGINS = { - top: 50, - right: 40, - bottom: BOTTOM_MARGIN_OFFSET, - left: LEFT_MARGIN_OFFSET, -}; - -const useBarsState = ({ - data, - fields, - measures, -}: Pick & { - fields: BarFields; -}): BarsState => { - const width = useWidth(); - - const getX = useNumericVariable(fields.x.componentIri); - const getY = useStringVariable(fields.y.componentIri); - const getSegment = useSegment(fields.segment?.componentIri); - - const xMeasure = measures.find((d) => d.iri === fields.x.componentIri); - - const xAxisLabel = xMeasure?.label ?? fields.y.componentIri; - const xAxisDescription = xMeasure?.description || undefined; - - // Sort data - const sortingType = fields.y.sorting?.sortingType; - const sortingOrder = fields.y.sorting?.sortingOrder; - - const sortedData = useMemo(() => { - return sortData({ data, sortingType, sortingOrder, getX, getY }); - }, [data, getX, getY, sortingType, sortingOrder]); - - // segments - const segments = useMemo( - () => [...new Set(sortedData.map((d) => getSegment(d)))], - [getSegment, sortedData] - ); - const colors = scaleOrdinal(getPalette(fields.segment?.palette)).domain( - segments - ); - - // scales and bounds - const { xScale, bounds, yScale } = useMemo(() => { - const minValue = Math.min(mkNumber(min(sortedData, (d) => getX(d))), 0); - const maxValue = max(sortedData, (d) => getX(d)); - const xScale = scaleLinear() - .domain([mkNumber(minValue), mkNumber(maxValue)]) - .nice(); - const bandDomain = [...new Set(sortedData.map((d) => getY(d)))]; - - const chartHeight = bandDomain.length * (BAR_HEIGHT + BAR_SPACE_ON_TOP); - const yScale = scaleBand() - .domain(bandDomain) - .range([0, chartHeight]); - const chartWidth = width - MARGINS.left - MARGINS.right; - const bounds = { - width, - height: chartHeight + MARGINS.top + MARGINS.bottom, - margins: MARGINS, - chartWidth, - chartHeight, - }; - - xScale.range([0, chartWidth]); - - return { xScale, yScale, bounds }; - }, [getX, getY, sortedData, width]); - - return { - chartType: "bar", - bounds, - sortedData, - getX, - xScale, - getY, - yScale, - getSegment, - segments, - xAxisLabel, - xAxisDescription, - colors, - }; -}; - -const BarChartProvider = ({ - data, - fields, - measures, - children, -}: Pick & { - children: ReactNode; - fields: BarFields; -}) => { - const state = useBarsState({ - data, - fields, - measures, - }); - return ( - {children} - ); -}; - -export const BarChart = ({ - data, - fields, - measures, - children, -}: Pick & { - children: ReactNode; - fields: BarFields; -}) => { - return ( - - - - {children} - - - - ); -}; - -const sortData = ({ - data, - getX, - getY, - sortingType, - sortingOrder, -}: { - data: Observation[]; - getX: (d: Observation) => number; - getY: (d: Observation) => string; - sortingType?: SortingType; - sortingOrder?: SortingOrder; -}) => { - if (sortingOrder === "desc" && sortingType === "byDimensionLabel") { - return [...data].sort((a, b) => descending(getY(a), getY(b))); - } else if (sortingOrder === "asc" && sortingType === "byDimensionLabel") { - return [...data].sort((a, b) => ascending(getY(a), getY(b))); - } else if (sortingOrder === "desc" && sortingType === "byMeasure") { - return [...data].sort((a, b) => descending(getX(a), getX(b))); - } else if (sortingOrder === "asc" && sortingType === "byMeasure") { - return [...data].sort((a, b) => ascending(getX(a), getX(b))); - } else { - // default to ascending alphabetical - return [...data].sort((a, b) => ascending(getY(a), getY(b))); - } -}; diff --git a/app/charts/bar/chart-bar.tsx b/app/charts/bar/chart-bar.tsx deleted file mode 100644 index 4a486850d..000000000 --- a/app/charts/bar/chart-bar.tsx +++ /dev/null @@ -1,111 +0,0 @@ -import React, { memo } from "react"; - -import { BarsGrouped } from "@/charts/bar/bars-grouped"; -import { GroupedBarsChart } from "@/charts/bar/bars-grouped-state"; -import { Bars } from "@/charts/bar/bars-simple"; -import { BarChart } from "@/charts/bar/bars-state"; -import { AxisWidthLinear } from "@/charts/shared/axis-width-linear"; -import { ChartContainer, ChartSvg } from "@/charts/shared/containers"; -import { - InteractiveLegendColor, - LegendColor, -} from "@/charts/shared/legend-color"; -import { - Filters, - BarConfig, - BarFields, - InteractiveFiltersConfig, - FilterValueSingle, - DataSource, -} from "@/configurator"; -import { Observation } from "@/domain/data"; -import { - DimensionMetadataFragment, - useDataCubeObservationsQuery, -} from "@/graphql/query-hooks"; -import { useLocale } from "@/locales/use-locale"; - -import { ChartLoadingWrapper } from "../chart-loading-wrapper"; - -export const ChartBarsVisualization = ({ - dataSetIri, - dataSource, - chartConfig, - queryFilters, -}: { - dataSetIri: string; - dataSource: DataSource; - chartConfig: BarConfig; - queryFilters: Filters | FilterValueSingle; -}) => { - const locale = useLocale(); - const [queryResp] = useDataCubeObservationsQuery({ - variables: { - iri: dataSetIri, - sourceType: dataSource.type, - sourceUrl: dataSource.url, - locale, - dimensions: null, // FIXME: Try to load less dimensions - filters: queryFilters, - }, - }); - - return ( - - ); -}; - -export const ChartBars = memo( - ({ - observations, - dimensions, - measures, - fields, - interactiveFiltersConfig, - }: { - observations: Observation[]; - dimensions: DimensionMetadataFragment[]; - measures: DimensionMetadataFragment[]; - fields: BarFields; - interactiveFiltersConfig: InteractiveFiltersConfig; - }) => { - return ( - <> - {fields.segment?.componentIri ? ( - - - - - - - - {fields.segment && - interactiveFiltersConfig?.legend.active === true ? ( - - ) : fields.segment ? ( - - ) : null} - - ) : ( - - - - - - - - - )} - - ); - } -); diff --git a/app/charts/bar/constants.ts b/app/charts/bar/constants.ts deleted file mode 100644 index ba7685abf..000000000 --- a/app/charts/bar/constants.ts +++ /dev/null @@ -1,9 +0,0 @@ -export const LEFT_MARGIN_OFFSET = 5; -export const BOTTOM_MARGIN_OFFSET = 60; -export const VERTICAL_PADDING = 0.5; -export const VERTICAL_PADDING_OUTER = 0.5; -export const VERTICAL_PADDING_INNER = 0.5; -export const VERTICAL_PADDING_WITHIN = 0.1; -export const BAR_HEIGHT = 24; -export const BAR_SPACE_ON_TOP = 50; -export const BAR_AXIS_OFFSET = 8; diff --git a/app/charts/chart-config-ui-options.ts b/app/charts/chart-config-ui-options.ts index 46c29e2d9..9e6ea21b2 100644 --- a/app/charts/chart-config-ui-options.ts +++ b/app/charts/chart-config-ui-options.ts @@ -48,7 +48,8 @@ export interface EncodingSpec { options?: EncodingOption[]; } -// dataFilters is enabled by default. +// dataFilters is enabled by default +// timeSlider is enabled dynamically based on availability of temporal dimensions type InteractiveFilterType = "legend" | "timeRange"; export interface ChartSpec { @@ -59,7 +60,6 @@ export interface ChartSpec { interface ChartSpecs { area: ChartSpec; - bar: ChartSpec; column: ChartSpec; line: ChartSpec; map: ChartSpec; @@ -125,44 +125,6 @@ export const chartConfigOptionsUISpec: ChartSpecs = { ], interactiveFilters: ["legend", "timeRange"], }, - bar: { - chartType: "bar", - encodings: [ - { - field: "y", - optional: false, - componentTypes: [ - "TemporalDimension", - "NominalDimension", - "OrdinalDimension", - "GeoCoordinatesDimension", - "GeoShapesDimension", - ], - filters: true, - }, - { - field: "x", - optional: false, - componentTypes: ["NumericalMeasure"], - filters: false, - }, - { - field: "segment", - optional: true, - componentTypes: SEGMENT_COMPONENT_TYPES, - filters: true, - sorting: [ - { sortingType: "byDimensionLabel", sortingOrder: ["asc", "desc"] }, - { sortingType: "byTotalSize", sortingOrder: ["asc", "desc"] }, - ], - options: [ - { field: "chartSubType" }, - { field: "color", type: "palette" }, - ], - }, - ], - interactiveFilters: ["legend", "timeRange"], - }, column: { chartType: "column", encodings: [ diff --git a/app/charts/column/chart-column.tsx b/app/charts/column/chart-column.tsx index 3ec2813d1..353129383 100644 --- a/app/charts/column/chart-column.tsx +++ b/app/charts/column/chart-column.tsx @@ -30,6 +30,7 @@ import { FilterValueSingle, InteractiveFiltersConfig, } from "@/configurator"; +import { TimeSlider } from "@/configurator/interactive-filters/time-slider"; import { Observation } from "@/domain/data"; import { DimensionMetadataFragment, @@ -162,6 +163,11 @@ export const ChartColumns = memo( + {interactiveFiltersConfig?.timeSlider.componentIri && ( + + )} )} diff --git a/app/charts/column/columns-grouped-state.tsx b/app/charts/column/columns-grouped-state.tsx index e7164e0de..7e8167e39 100644 --- a/app/charts/column/columns-grouped-state.tsx +++ b/app/charts/column/columns-grouped-state.tsx @@ -29,20 +29,20 @@ import { } from "@/charts/column/constants"; import { getLabelWithUnit, + useDataAfterInteractiveFilters, useOptionalNumericVariable, usePlottableData, - usePreparedData, useSegment, useStringVariable, useTemporalVariable, } from "@/charts/shared/chart-helpers"; +import { CommonChartState } from "@/charts/shared/chart-state"; import { TooltipInfo } from "@/charts/shared/interaction/tooltip"; import { useChartPadding } from "@/charts/shared/padding"; import useChartFormatters from "@/charts/shared/use-chart-formatters"; import { ChartContext, ChartProps } from "@/charts/shared/use-chart-state"; import { InteractionProvider } from "@/charts/shared/use-interaction"; -import { useInteractiveFilters } from "@/charts/shared/use-interactive-filters"; -import { Bounds, Observer, useWidth } from "@/charts/shared/use-width"; +import { Observer, useWidth } from "@/charts/shared/use-width"; import { ColumnFields, SortingField } from "@/configurator"; import { mkNumber, @@ -55,11 +55,10 @@ import { getPalette } from "@/palettes"; import { sortByIndex } from "@/utils/array"; import { makeDimensionValueSorters } from "@/utils/sorting-values"; -export interface GroupedColumnsState { +export interface GroupedColumnsState extends CommonChartState { chartType: "column"; preparedData: Observation[]; allData: Observation[]; - bounds: Bounds; getX: (d: Observation) => string; getXAsDate: (d: Observation) => Date; xIsTime: boolean; @@ -101,8 +100,6 @@ const useGroupedColumnsState = ( const width = useWidth(); const formatNumber = useFormatNumber(); - const [interactiveFilters] = useInteractiveFilters(); - const xDimension = dimensions.find((d) => d.iri === fields.x.componentIri); if (!xDimension) { @@ -166,11 +163,9 @@ const useGroupedColumnsState = ( }); // Data for chart - const preparedData = usePreparedData({ - legendFilterActive: interactiveFiltersConfig?.legend.active, - timeRangeFilterActive: interactiveFiltersConfig?.timeRange.active, + const preparedData = useDataAfterInteractiveFilters({ sortedData: plottableSortedData, - interactiveFilters, + interactiveFiltersConfig, getX: getXAsDate, getSegment, }); @@ -443,9 +438,9 @@ const useGroupedColumnsState = ( return { chartType: "column", + bounds, preparedData, allData: plottableSortedData, - bounds, getX, getXAsDate, xScale, diff --git a/app/charts/column/columns-stacked-state.tsx b/app/charts/column/columns-stacked-state.tsx index d71646ed1..eafe45268 100644 --- a/app/charts/column/columns-stacked-state.tsx +++ b/app/charts/column/columns-stacked-state.tsx @@ -34,20 +34,20 @@ import { import { getLabelWithUnit, getWideData, + useDataAfterInteractiveFilters, useOptionalNumericVariable, usePlottableData, - usePreparedData, useSegment, useStringVariable, useTemporalVariable, } from "@/charts/shared/chart-helpers"; +import { CommonChartState } from "@/charts/shared/chart-state"; import { TooltipInfo } from "@/charts/shared/interaction/tooltip"; import { useChartPadding } from "@/charts/shared/padding"; import useChartFormatters from "@/charts/shared/use-chart-formatters"; import { ChartContext, ChartProps } from "@/charts/shared/use-chart-state"; import { InteractionProvider } from "@/charts/shared/use-interaction"; -import { useInteractiveFilters } from "@/charts/shared/use-interactive-filters"; -import { Bounds, Observer, useWidth } from "@/charts/shared/use-width"; +import { Observer, useWidth } from "@/charts/shared/use-width"; import { ColumnFields, SortingOrder, SortingType } from "@/configurator"; import { isTemporalDimension, Observation } from "@/domain/data"; import { formatNumberWithUnit, useFormatNumber } from "@/formatters"; @@ -55,11 +55,10 @@ import { getPalette } from "@/palettes"; import { sortByIndex } from "@/utils/array"; import { makeDimensionValueSorters } from "@/utils/sorting-values"; -export interface StackedColumnsState { +export interface StackedColumnsState extends CommonChartState { chartType: "column"; preparedData: Observation[]; allData: Observation[]; - bounds: Bounds; getX: (d: Observation) => string; getXAsDate: (d: Observation) => Date; xIsTime: boolean; @@ -100,7 +99,6 @@ const useColumnsStackedState = ( } = chartProps; const width = useWidth(); const formatNumber = useFormatNumber(); - const [interactiveFilters] = useInteractiveFilters(); const xDimension = dimensions.find((d) => d.iri === fields.x.componentIri); @@ -159,11 +157,9 @@ const useColumnsStackedState = ( }); // Data for Chart - const preparedData = usePreparedData({ - legendFilterActive: interactiveFiltersConfig?.legend.active, - timeRangeFilterActive: interactiveFiltersConfig?.timeRange.active, + const preparedData = useDataAfterInteractiveFilters({ sortedData: plottableSortedData, - interactiveFilters, + interactiveFiltersConfig, getX: getXAsDate, getSegment, }); @@ -498,9 +494,9 @@ const useColumnsStackedState = ( return { chartType: "column", + bounds, preparedData, allData: plottableSortedData, - bounds, getX, getXAsDate, xScale, diff --git a/app/charts/column/columns-state.tsx b/app/charts/column/columns-state.tsx index a8cce40e5..d7fdef919 100644 --- a/app/charts/column/columns-state.tsx +++ b/app/charts/column/columns-state.tsx @@ -26,20 +26,20 @@ import { } from "@/charts/column/constants"; import { getLabelWithUnit, + useDataAfterInteractiveFilters, useOptionalNumericVariable, usePlottableData, - usePreparedData, useSegment, useStringVariable, useTemporalVariable, } from "@/charts/shared/chart-helpers"; +import { CommonChartState } from "@/charts/shared/chart-state"; import { TooltipInfo } from "@/charts/shared/interaction/tooltip"; import { useChartPadding } from "@/charts/shared/padding"; import useChartFormatters from "@/charts/shared/use-chart-formatters"; import { ChartContext, ChartProps } from "@/charts/shared/use-chart-state"; import { InteractionProvider } from "@/charts/shared/use-interaction"; -import { useInteractiveFilters } from "@/charts/shared/use-interactive-filters"; -import { Bounds, Observer, useWidth } from "@/charts/shared/use-width"; +import { Observer, useWidth } from "@/charts/shared/use-width"; import { ColumnFields, SortingOrder, SortingType } from "@/configurator"; import { mkNumber, @@ -57,9 +57,8 @@ import { TemporalDimension, TimeUnit } from "@/graphql/query-hooks"; import { getPalette } from "@/palettes"; import { makeDimensionValueSorters } from "@/utils/sorting-values"; -export interface ColumnsState { +export interface ColumnsState extends CommonChartState { chartType: "column"; - bounds: Bounds; preparedData: Observation[]; allData: Observation[]; getX: (d: Observation) => string; @@ -102,7 +101,6 @@ const useColumnsState = ( const width = useWidth(); const formatNumber = useFormatNumber(); const timeFormatUnit = useTimeFormatUnit(); - const [interactiveFilters] = useInteractiveFilters(); const dimensionsByIri = useMemo( () => Object.fromEntries(dimensions.map((d) => [d.iri, d])), @@ -159,10 +157,9 @@ const useColumnsState = ( plotters: [getXAsDate, getY], }); - const preparedData = usePreparedData({ - timeRangeFilterActive: interactiveFiltersConfig?.timeRange.active, + const preparedData = useDataAfterInteractiveFilters({ sortedData: plottableSortedData, - interactiveFilters, + interactiveFiltersConfig, getX: getXAsDate, }); diff --git a/app/charts/index.ts b/app/charts/index.ts index e3e2d6752..266c338a0 100644 --- a/app/charts/index.ts +++ b/app/charts/index.ts @@ -96,6 +96,7 @@ export const findPreferredDimension = ( }; const INITIAL_INTERACTIVE_FILTERS_CONFIG: InteractiveFiltersConfig = { + // FIXME: we shouldn't keep empty props legend: { active: false, componentIri: "", @@ -109,6 +110,9 @@ const INITIAL_INTERACTIVE_FILTERS_CONFIG: InteractiveFiltersConfig = { to: "", }, }, + timeSlider: { + componentIri: "", + }, dataFilters: { active: false, componentIris: [], @@ -212,23 +216,6 @@ export const getInitialConfig = ({ const numericalMeasures = measures.filter(isNumericalMeasure); switch (chartType) { - case "bar": - return { - version: CHART_CONFIG_VERSION, - chartType, - filters: {}, - interactiveFiltersConfig: INITIAL_INTERACTIVE_FILTERS_CONFIG, - fields: { - x: { componentIri: measures[0].iri }, - y: { - componentIri: findPreferredDimension( - dimensions, - "TemporalDimension" - ).iri, - sorting: DEFAULT_SORTING, - }, - }, - }; case "column": return { version: CHART_CONFIG_VERSION, @@ -582,7 +569,6 @@ const interactiveFiltersAdjusters: InteractiveFiltersAdjusters = { }; const chartConfigsAdjusters: ChartConfigsAdjusters = { - bar: {}, column: { filters: ({ oldValue, newChartConfig }) => { return produce(newChartConfig, (draft) => { @@ -1001,7 +987,6 @@ const chartConfigsPathOverrides: { }; }; } = { - bar: {}, column: { map: { "fields.areaLayer.componentIri": "fields.x.componentIri", diff --git a/app/charts/line/lines-state.tsx b/app/charts/line/lines-state.tsx index ffadc41d0..1768b79b9 100644 --- a/app/charts/line/lines-state.tsx +++ b/app/charts/line/lines-state.tsx @@ -20,19 +20,19 @@ import { BRUSH_BOTTOM_SPACE } from "@/charts/shared/brush"; import { getLabelWithUnit, getWideData, + useDataAfterInteractiveFilters, useOptionalNumericVariable, usePlottableData, - usePreparedData, useSegment, useStringVariable, useTemporalVariable, } from "@/charts/shared/chart-helpers"; +import { CommonChartState } from "@/charts/shared/chart-state"; import { TooltipInfo } from "@/charts/shared/interaction/tooltip"; import useChartFormatters from "@/charts/shared/use-chart-formatters"; import { ChartContext, ChartProps } from "@/charts/shared/use-chart-state"; import { InteractionProvider } from "@/charts/shared/use-interaction"; -import { useInteractiveFilters } from "@/charts/shared/use-interactive-filters"; -import { Bounds, Observer, useWidth } from "@/charts/shared/use-width"; +import { Observer, useWidth } from "@/charts/shared/use-width"; import { LineFields } from "@/configurator"; import { isTemporalDimension, Observation } from "@/domain/data"; import { @@ -46,10 +46,11 @@ import { sortByIndex } from "@/utils/array"; import { estimateTextWidth } from "@/utils/estimate-text-width"; import { makeDimensionValueSorters } from "@/utils/sorting-values"; -export interface LinesState { +export interface LinesState extends CommonChartState { chartType: "line"; data: Observation[]; - bounds: Bounds; + allData: Observation[]; + preparedData: Observation[]; segments: string[]; getX: (d: Observation) => Date; xScale: ScaleTime; @@ -91,7 +92,6 @@ const useLinesState = ( const width = useWidth(); const formatNumber = useFormatNumber(); const timeFormatUnit = useTimeFormatUnit(); - const [interactiveFilters] = useInteractiveFilters(); const xDimension = dimensions.find((d) => d.iri === fields.x.componentIri); @@ -132,11 +132,9 @@ const useLinesState = ( }); // All Data - const preparedData = usePreparedData({ - legendFilterActive: interactiveFiltersConfig?.legend.active, - timeRangeFilterActive: interactiveFiltersConfig?.timeRange.active, + const preparedData = useDataAfterInteractiveFilters({ sortedData: plottableSortedData, - interactiveFilters, + interactiveFiltersConfig, getX, getSegment, }); @@ -336,10 +334,13 @@ const useLinesState = ( })), }; }; + return { chartType: "line", - data, bounds, + data, + allData: plottableSortedData, + preparedData, getX, xScale, xEntireScale, diff --git a/app/charts/map/map-state.tsx b/app/charts/map/map-state.tsx index b1143ee0d..01d7a7ce9 100644 --- a/app/charts/map/map-state.tsx +++ b/app/charts/map/map-state.tsx @@ -23,9 +23,11 @@ import { useOptionalNumericVariable, useStringVariable, } from "@/charts/shared/chart-helpers"; +import { CommonChartState } from "@/charts/shared/chart-state"; +import { colorToRgbArray } from "@/charts/shared/colors"; import { ChartContext, ChartProps } from "@/charts/shared/use-chart-state"; import { InteractionProvider } from "@/charts/shared/use-interaction"; -import { Bounds, Observer, useWidth } from "@/charts/shared/use-width"; +import { Observer, useWidth } from "@/charts/shared/use-width"; import { getErrorMeasure, useErrorMeasure, @@ -52,8 +54,6 @@ import { formatNumberWithUnit, useFormatNumber } from "@/formatters"; import { DimensionMetadataFragment } from "@/graphql/query-hooks"; import { getColorInterpolator } from "@/palettes"; -import { colorToRgbArray } from "../shared/colors"; - import { getBBox } from "./helpers"; type AreaLayerColors = @@ -87,9 +87,8 @@ type SymbolLayerColors = | { type: "fixed"; getColor: (d: Observation) => number[] } | AreaLayerColors; -export interface MapState { +export interface MapState extends CommonChartState { chartType: "map"; - bounds: Bounds; features: GeoData; locked: boolean; lockedBBox: BBox | undefined; @@ -524,8 +523,8 @@ const useMapState = ( return { chartType: "map", - features, bounds, + features, showBaseLayer: baseLayer.show, locked: baseLayer.locked || false, lockedBBox: chartProps.baseLayer.bbox, diff --git a/app/charts/pie/pie-state.tsx b/app/charts/pie/pie-state.tsx index 5ceb0d5f4..dbf3bfcf6 100644 --- a/app/charts/pie/pie-state.tsx +++ b/app/charts/pie/pie-state.tsx @@ -12,25 +12,27 @@ import orderBy from "lodash/orderBy"; import React, { ReactNode, useMemo, useCallback } from "react"; import { + useDataAfterInteractiveFilters, useOptionalNumericVariable, usePlottableData, - usePreparedData, useSegment, } from "@/charts/shared/chart-helpers"; +import { CommonChartState } from "@/charts/shared/chart-state"; import { TooltipInfo } from "@/charts/shared/interaction/tooltip"; import useChartFormatters from "@/charts/shared/use-chart-formatters"; import { ChartContext, ChartProps } from "@/charts/shared/use-chart-state"; import { InteractionProvider } from "@/charts/shared/use-interaction"; -import { useInteractiveFilters } from "@/charts/shared/use-interactive-filters"; -import { Bounds, Observer, useWidth } from "@/charts/shared/use-width"; +import { Observer, useWidth } from "@/charts/shared/use-width"; import { PieFields } from "@/configurator"; import { DimensionValue, Observation } from "@/domain/data"; import { formatNumberWithUnit, useFormatNumber } from "@/formatters"; import { getPalette } from "@/palettes"; import { makeDimensionValueSorters } from "@/utils/sorting-values"; -export interface PieState { - bounds: Bounds; - data: Observation[]; + +export interface PieState extends CommonChartState { + chartType: "pie"; + allData: Observation[]; + preparedData: Observation[]; getPieData: Pie<$IntentionalAny, Observation>; getY: (d: Observation) => number | null; getX: (d: Observation) => string; @@ -58,7 +60,6 @@ const usePieState = ( } = chartProps; const width = useWidth(); const formatNumber = useFormatNumber(); - const [interactiveFilters] = useInteractiveFilters(); const yMeasure = measures.find((d) => d.iri === fields.y.componentIri); @@ -79,10 +80,9 @@ const usePieState = ( }); // Apply end-user-activated interactive filters to the stack - const preparedData = usePreparedData({ - legendFilterActive: interactiveFiltersConfig?.legend.active, + const preparedData = useDataAfterInteractiveFilters({ sortedData: plottableData, - interactiveFilters, + interactiveFiltersConfig, getSegment: getX, }); @@ -249,9 +249,12 @@ const usePieState = ( values: undefined, }; }; + return { + chartType: "pie", bounds, - data: preparedData, + allData: plottableData, + preparedData, getPieData, getY, getX, diff --git a/app/charts/pie/pie.tsx b/app/charts/pie/pie.tsx index 47b67f239..dc8b253f2 100644 --- a/app/charts/pie/pie.tsx +++ b/app/charts/pie/pie.tsx @@ -6,11 +6,11 @@ import { useInteraction } from "@/charts/shared/use-interaction"; import { Observation } from "@/domain/data"; export const Pie = () => { - const { data, getPieData, getX, colors, bounds } = + const { preparedData, getPieData, getX, colors, bounds } = useChartState() as PieState; const { width, height, chartWidth, chartHeight } = bounds; - const arcs = getPieData(data); + const arcs = getPieData(preparedData); const maxSide = Math.min(chartWidth, chartHeight) / 2; diff --git a/app/charts/scatterplot/scatterplot-simple.tsx b/app/charts/scatterplot/scatterplot-simple.tsx index 7bed51d5c..686f58399 100644 --- a/app/charts/scatterplot/scatterplot-simple.tsx +++ b/app/charts/scatterplot/scatterplot-simple.tsx @@ -6,7 +6,7 @@ import { useTheme } from "@/themes"; export const Scatterplot = () => { const { - data, + preparedData, bounds, getX, xScale, @@ -22,10 +22,10 @@ export const Scatterplot = () => { return ( - {data.map((d, index) => { + {preparedData.map((d, i) => { return ( number | null; xScale: ScaleLinear; getY: (d: Observation) => number | null; @@ -64,8 +64,6 @@ const useScatterplotState = ({ fields: ScatterPlotFields; aspectRatio: number; }): ScatterplotState => { - const [interactiveFilters] = useInteractiveFilters(); - const width = useWidth(); const formatNumber = useFormatNumber(); @@ -83,10 +81,9 @@ const useScatterplotState = ({ }); // Data for chart - const preparedData = usePreparedData({ - legendFilterActive: interactiveFiltersConfig?.legend.active, + const preparedData = useDataAfterInteractiveFilters({ sortedData: plottableSortedData, - interactiveFilters, + interactiveFiltersConfig, getSegment, }); const xMeasure = measures.find((d) => d.iri === fields.x.componentIri); @@ -235,8 +232,9 @@ const useScatterplotState = ({ return { chartType: "scatterplot", - data: preparedData, // sortedData + filtered data, bounds, + allData: plottableSortedData, + preparedData, getX, xScale, getY, diff --git a/app/charts/shared/axis-height-band.tsx b/app/charts/shared/axis-height-band.tsx deleted file mode 100644 index 5fd164c1b..000000000 --- a/app/charts/shared/axis-height-band.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import { axisLeft } from "d3"; -import { select, Selection } from "d3"; -import { useEffect, useRef } from "react"; - -import { BarsState } from "@/charts/bar/bars-state"; -import { useChartState } from "@/charts/shared/use-chart-state"; -import { useChartTheme } from "@/charts/shared/use-chart-theme"; - -// export const AxisHeightBand = () => { -// const ref = useRef(null); -// const { xScale, yScale, bounds } = useChartState() as BarsState; - -// const { chartHeight, margins } = bounds; - -// const { -// labelColor, -// gridColor, -// labelFontSize, -// fontFamily, -// domainColor, -// } = useChartTheme(); - -// const mkAxis = (g: Selection) => { -// const rotation = true; // xScale.domain().length > 6; -// const hasNegativeValues = xScale.domain()[0] < 0; - -// const fontSize = -// yScale.bandwidth() > labelFontSize ? labelFontSize : yScale.bandwidth(); -// g.call( -// axisLeft(yScale) -// .tickSizeOuter(0) -// .tickSizeInner(hasNegativeValues ? -chartHeight : 6) -// ); - -// g.select(".domain").remove(); -// g.selectAll(".tick line").attr( -// "stroke", -// hasNegativeValues ? gridColor : domainColor -// ); -// g.selectAll(".tick text") -// .attr("font-size", fontSize) -// .attr("font-family", fontFamily) -// .attr("fill", labelColor) -// .attr("y", rotation ? 0 : fontSize + 6) -// .attr("x", rotation ? fontSize : 0) -// .attr("dy", rotation ? ".35em" : 0); -// // .attr("transform", rotation ? "rotate(90)" : "rotate(0)") -// // .attr("text-anchor", rotation ? "start" : "unset"); -// }; - -// useEffect(() => { -// const g = select(ref.current); -// mkAxis(g as Selection); -// }); - -// return ( -// -// ); -// }; - -export const AxisHeightBandDomain = () => { - const ref = useRef(null); - const { xScale, bounds } = useChartState() as BarsState; - const { chartHeight, margins } = bounds; - const { domainColor } = useChartTheme(); - - const mkAxisDomain = ( - g: Selection - ) => { - g.call(axisLeft(xScale).tickSizeOuter(0)); - g.selectAll(".tick line").remove(); - g.selectAll(".tick text").remove(); - g.select(".domain") - .attr("transform", `translate(0, -${bounds.chartHeight - xScale(0)})`) - .attr("stroke", domainColor); - }; - - useEffect(() => { - const g = select(ref.current); - mkAxisDomain(g as Selection); - }); - - return ( - - ); -}; diff --git a/app/charts/shared/axis-width-linear.tsx b/app/charts/shared/axis-width-linear.tsx index a48a43cd6..ad4dfb863 100644 --- a/app/charts/shared/axis-width-linear.tsx +++ b/app/charts/shared/axis-width-linear.tsx @@ -2,7 +2,6 @@ import { axisBottom } from "d3"; import { select, Selection } from "d3"; import { useEffect, useRef } from "react"; -import { BarsState } from "@/charts/bar/bars-state"; import { ScatterplotState } from "@/charts/scatterplot/scatterplot-state"; import { useChartState } from "@/charts/shared/use-chart-state"; import { useChartTheme } from "@/charts/shared/use-chart-theme"; @@ -12,11 +11,10 @@ import { MaybeTooltip } from "@/utils/maybe-tooltip"; export const AxisWidthLinear = () => { const formatNumber = useFormatNumber(); - const { xScale, bounds, xAxisLabel, xAxisDescription, chartType } = - useChartState() as ScatterplotState | BarsState; + const { xScale, bounds, xAxisLabel, xAxisDescription } = + useChartState() as ScatterplotState; const { chartWidth, chartHeight, margins } = bounds; - const { domainColor, labelColor, labelFontSize, gridColor, fontFamily } = - useChartTheme(); + const { labelColor, labelFontSize, gridColor, fontFamily } = useChartTheme(); const xAxisRef = useRef(null); const mkAxis = (g: Selection) => { @@ -43,15 +41,6 @@ export const AxisWidthLinear = () => { .attr("text-anchor", "middle"); g.select("path.domain").attr("stroke", gridColor); - - // Styles for bar chart - if (chartType === "bar") { - g.select(".tick:first-of-type line") - .attr("stroke", domainColor) - .attr("stroke-width", 1); - g.select(".tick:first-of-type text").remove(); - g.select("path.domain").remove(); - } }; useEffect(() => { diff --git a/app/charts/shared/brush.tsx b/app/charts/shared/brush.tsx index 777362b95..c75bd0d7b 100644 --- a/app/charts/shared/brush.tsx +++ b/app/charts/shared/brush.tsx @@ -125,7 +125,7 @@ export const BrushTime = () => { // Update interactive filters state dispatch({ - type: "ADD_TIME_FILTER", + type: "SET_TIME_RANGE_FILTER", value: newDates, }); } @@ -183,13 +183,13 @@ export const BrushTime = () => { if (getDate(indexLeft).getTime() < to.getTime()) { // new lower than "to" dispatch({ - type: "ADD_TIME_FILTER", + type: "SET_TIME_RANGE_FILTER", value: [getDate(indexLeft), to], }); } else { // new too high, don't do anything dispatch({ - type: "ADD_TIME_FILTER", + type: "SET_TIME_RANGE_FILTER", value: [from, to], }); } @@ -199,12 +199,12 @@ export const BrushTime = () => { const indexRight = fullData[index]; if (getDate(indexRight).getTime() < to.getTime()) { dispatch({ - type: "ADD_TIME_FILTER", + type: "SET_TIME_RANGE_FILTER", value: [getDate(indexRight), to], }); } else { dispatch({ - type: "ADD_TIME_FILTER", + type: "SET_TIME_RANGE_FILTER", value: [from, to], }); } @@ -215,12 +215,12 @@ export const BrushTime = () => { if (getDate(indexLeft).getTime() > from.getTime()) { dispatch({ - type: "ADD_TIME_FILTER", + type: "SET_TIME_RANGE_FILTER", value: [from, getDate(indexLeft)], }); } else { dispatch({ - type: "ADD_TIME_FILTER", + type: "SET_TIME_RANGE_FILTER", value: [from, to], }); } @@ -231,12 +231,12 @@ export const BrushTime = () => { if (indexLeft && getDate(indexLeft).getTime() > from.getTime()) { dispatch({ - type: "ADD_TIME_FILTER", + type: "SET_TIME_RANGE_FILTER", value: [from, getDate(indexLeft)], }); } else { dispatch({ - type: "ADD_TIME_FILTER", + type: "SET_TIME_RANGE_FILTER", value: [from, to], }); } @@ -447,7 +447,7 @@ export const BrushTime = () => { // // Update interactive filters state // dispatch({ -// type: "ADD_TIME_FILTER", +// type: "SET_TIME_RANGE_FILTER", // value: [startIndex, endIndex - 1], // }); // } @@ -475,13 +475,13 @@ export const BrushTime = () => { // if (indexLeft < to) { // // new lower than "to" // dispatch({ -// type: "ADD_TIME_FILTER", +// type: "SET_TIME_RANGE_FILTER", // value: [indexLeft, to], // }); // } else { // // new too high, don't do anything // dispatch({ -// type: "ADD_TIME_FILTER", +// type: "SET_TIME_RANGE_FILTER", // value: [from, to], // }); // } @@ -491,12 +491,12 @@ export const BrushTime = () => { // const indexRight = from + 1; // if (indexRight < to) { // dispatch({ -// type: "ADD_TIME_FILTER", +// type: "SET_TIME_RANGE_FILTER", // value: [indexRight, to], // }); // } else { // dispatch({ -// type: "ADD_TIME_FILTER", +// type: "SET_TIME_RANGE_FILTER", // value: [from, to], // }); // } @@ -507,12 +507,12 @@ export const BrushTime = () => { // if (indexLeft > from) { // dispatch({ -// type: "ADD_TIME_FILTER", +// type: "SET_TIME_RANGE_FILTER", // value: [from, indexLeft], // }); // } else { // dispatch({ -// type: "ADD_TIME_FILTER", +// type: "SET_TIME_RANGE_FILTER", // value: [from, to], // }); // } @@ -522,12 +522,12 @@ export const BrushTime = () => { // if (indexRight && indexRight > from) { // dispatch({ -// type: "ADD_TIME_FILTER", +// type: "SET_TIME_RANGE_FILTER", // value: [from, indexRight], // }); // } else { // dispatch({ -// type: "ADD_TIME_FILTER", +// type: "SET_TIME_RANGE_FILTER", // value: [from, to], // }); // } diff --git a/app/charts/shared/chart-helpers.spec.tsx b/app/charts/shared/chart-helpers.spec.tsx index 3e64f07cb..6d8e2a730 100644 --- a/app/charts/shared/chart-helpers.spec.tsx +++ b/app/charts/shared/chart-helpers.spec.tsx @@ -6,7 +6,7 @@ import { prepareQueryFilters, } from "@/charts/shared/chart-helpers"; import { InteractiveFiltersState } from "@/charts/shared/use-interactive-filters"; -import { LineConfig } from "@/configurator"; +import { ChartType, Filters, InteractiveFiltersConfig } from "@/configurator"; import { FIELD_VALUE_NONE } from "@/configurator/constants"; import { Observation } from "@/domain/data"; import line1Fixture from "@/test/__fixtures/config/prod/line-1.json"; @@ -20,7 +20,7 @@ const { col, val } = makeCubeNsGetters( "http://environment.ld.admin.ch/foen/px/0703010000_105" ); -const commonInteractiveFiltersConfig = { +const commonInteractiveFiltersConfig: InteractiveFiltersConfig = { legend: { active: false, componentIri: col("2"), @@ -34,6 +34,9 @@ const commonInteractiveFiltersConfig = { to: "2020-01-01", }, }, + timeSlider: { + componentIri: "", + }, dataFilters: { componentIris: [col("3"), col("4")], active: false, @@ -47,6 +50,9 @@ const commonInteractiveFiltersState: InteractiveFiltersState = { from: new Date(2021, 0, 1), to: new Date(2021, 11, 31), }, + timeSlider: { + value: undefined, + }, dataFilters: { [col("3")]: { type: "single", @@ -58,11 +64,10 @@ const commonInteractiveFiltersState: InteractiveFiltersState = { describe("useQueryFilters", () => { it("should not merge interactive filters state if interactive filters are disabled at publish time", () => { const queryFilters = prepareQueryFilters( - { - ...line1Fixture.data.chartConfig, - interactiveFiltersConfig: commonInteractiveFiltersConfig, - } as LineConfig, - commonInteractiveFiltersState + line1Fixture.data.chartConfig.chartType as ChartType, + line1Fixture.data.chartConfig.filters as Filters, + commonInteractiveFiltersConfig, + commonInteractiveFiltersState.dataFilters ); expect(queryFilters[col("3")]).toEqual({ type: "single", @@ -72,16 +77,16 @@ describe("useQueryFilters", () => { it("should merge interactive filters state if interactive filters are active at publish time", () => { const queryFilters = prepareQueryFilters( - { - ...line1Fixture.data.chartConfig, - interactiveFiltersConfig: merge({}, commonInteractiveFiltersConfig, { - dataFilters: { - active: true, - }, - }), - } as LineConfig, - commonInteractiveFiltersState + line1Fixture.data.chartConfig.chartType as ChartType, + line1Fixture.data.chartConfig.filters as Filters, + merge({}, commonInteractiveFiltersConfig, { + dataFilters: { + active: true, + }, + }), + commonInteractiveFiltersState.dataFilters ); + expect(queryFilters[col("3")]).toEqual({ type: "single", value: val("3", "1"), @@ -90,14 +95,13 @@ describe("useQueryFilters", () => { it("should omit none values since they should not be passed to graphql layer", () => { const queryFilters = prepareQueryFilters( - { - ...line1Fixture.data.chartConfig, - interactiveFiltersConfig: merge({}, commonInteractiveFiltersConfig, { - dataFilters: { - active: true, - }, - }), - } as LineConfig, + line1Fixture.data.chartConfig.chartType as ChartType, + line1Fixture.data.chartConfig.filters as Filters, + merge({}, commonInteractiveFiltersConfig, { + dataFilters: { + active: true, + }, + }), merge({}, commonInteractiveFiltersState, { dataFilters: { [col("3")]: { @@ -105,8 +109,9 @@ describe("useQueryFilters", () => { value: FIELD_VALUE_NONE, }, }, - }) + }).dataFilters ); + expect(queryFilters[col("3")]).toBeUndefined(); }); }); diff --git a/app/charts/shared/chart-helpers.tsx b/app/charts/shared/chart-helpers.tsx index c04d58437..ae6c64861 100644 --- a/app/charts/shared/chart-helpers.tsx +++ b/app/charts/shared/chart-helpers.tsx @@ -14,9 +14,11 @@ import { import { parseDate } from "@/configurator/components/ui-helpers"; import { ChartConfig, + ChartType, Filters, FilterValueSingle, ImputationType, + InteractiveFiltersConfig, isAreaConfig, } from "@/configurator/config-types"; import { FIELD_VALUE_NONE } from "@/configurator/constants"; @@ -31,24 +33,26 @@ export type QueryFilters = Filters | FilterValueSingle; // if applicable // - removes none values since they should not be sent as part of the GraphQL query export const prepareQueryFilters = ( - { chartType, filters, interactiveFiltersConfig }: ChartConfig, - IFState: InteractiveFiltersState + chartType: ChartType, + filters: Filters, + interactiveFiltersConfig: InteractiveFiltersConfig, + dataFilters: InteractiveFiltersState["dataFilters"] ): Filters => { - let res: QueryFilters; - const dataFiltersActive = interactiveFiltersConfig?.dataFilters.active; - - if (chartType !== "table") { - const queryFilters = dataFiltersActive - ? { ...filters, ...IFState.dataFilters } - : filters; - res = queryFilters; - } else { - res = filters; + let queryFilters = filters; + const { timeSlider } = interactiveFiltersConfig || {}; + + if (chartType !== "table" && interactiveFiltersConfig?.dataFilters.active) { + queryFilters = { ...queryFilters, ...dataFilters }; } - res = omitBy(res, (x) => x.type === "single" && x.value === FIELD_VALUE_NONE); + queryFilters = omitBy(queryFilters, (v, k) => { + return ( + (v.type === "single" && v.value === FIELD_VALUE_NONE) || + k === timeSlider?.componentIri + ); + }); - return res; + return queryFilters; }; export const useQueryFilters = ({ @@ -59,8 +63,18 @@ export const useQueryFilters = ({ const [IFState] = useInteractiveFilters(); return useMemo(() => { - return prepareQueryFilters(chartConfig, IFState); - }, [chartConfig, IFState]); + return prepareQueryFilters( + chartConfig.chartType, + chartConfig.filters, + chartConfig.interactiveFiltersConfig, + IFState.dataFilters + ); + }, [ + chartConfig.chartType, + chartConfig.filters, + chartConfig.interactiveFiltersConfig, + IFState.dataFilters, + ]); }; type ValuePredicate = (v: any) => boolean; @@ -82,58 +96,90 @@ export const usePlottableData = ({ } return true; }, - [plotters] + // eslint-disable-next-line react-hooks/exhaustive-deps + plotters ); + return useMemo(() => data.filter(isPlottable), [data, isPlottable]); }; -// Prepare data used in charts. -// Different than the full dataset because -// interactive filters may be applied (legend + brush) -export const usePreparedData = ({ - timeRangeFilterActive, - legendFilterActive, +/** Prepares the data to be used in charts. + * + * Different than the full dataset, because interactive filters might be applied. + */ +export const useDataAfterInteractiveFilters = ({ sortedData, - interactiveFilters, + interactiveFiltersConfig, getX, getSegment, }: { - timeRangeFilterActive?: boolean; - legendFilterActive?: boolean; sortedData: Array; - interactiveFilters: InteractiveFiltersState; + interactiveFiltersConfig: InteractiveFiltersConfig; getX?: (d: Observation) => Date; getSegment?: (d: Observation) => string; }) => { - const { from, to } = interactiveFilters.timeRange; - const { categories } = interactiveFilters; - const activeInteractiveFilters = Object.keys(categories); + const [IFState] = useInteractiveFilters(); + + // time range + const fromTime = IFState.timeRange.from?.getTime(); + const toTime = IFState.timeRange.to?.getTime(); + + // time slider + const getTime = useTemporalVariable( + interactiveFiltersConfig?.timeSlider.componentIri || "" + ); + const timeSliderValue = IFState.timeSlider.value; + + // legend + const legendItems = Object.keys(IFState.categories); const allFilters = useMemo(() => { - const timeFilter: ValuePredicate | null = - getX && from && to && timeRangeFilterActive - ? (d: Observation) => - getX(d).getTime() >= from.getTime() && - getX(d).getTime() <= to.getTime() + const timeRangeFilter = + getX && fromTime && toTime && interactiveFiltersConfig?.timeRange.active + ? (d: Observation) => { + const time = getX(d).getTime(); + return time >= fromTime && time <= toTime; + } : null; - const legendFilter: ValuePredicate | null = - legendFilterActive && getSegment - ? (d: Observation) => !activeInteractiveFilters.includes(getSegment(d)) + const timeSliderFilter = + interactiveFiltersConfig?.timeSlider.componentIri && timeSliderValue + ? (d: Observation) => { + return getTime(d).getTime() === timeSliderValue.getTime(); + } + : null; + const legendFilter = + interactiveFiltersConfig?.legend.active && getSegment + ? (d: Observation) => { + return !legendItems.includes(getSegment(d)); + } : null; - return overEvery([legendFilter, timeFilter].filter(truthy)); + + return overEvery( + ( + [ + timeRangeFilter, + timeSliderFilter, + legendFilter, + ] as (ValuePredicate | null)[] + ).filter(truthy) + ); }, [ - activeInteractiveFilters, - from, + legendItems, getSegment, getX, - legendFilterActive, - timeRangeFilterActive, - to, + getTime, + fromTime, + toTime, + interactiveFiltersConfig?.legend.active, + interactiveFiltersConfig?.timeRange.active, + interactiveFiltersConfig?.timeSlider.componentIri, + timeSliderValue, ]); const preparedData = useMemo(() => { return sortedData.filter(allFilters); }, [allFilters, sortedData]); + return preparedData; }; diff --git a/app/charts/shared/chart-state.ts b/app/charts/shared/chart-state.ts new file mode 100644 index 000000000..18382d059 --- /dev/null +++ b/app/charts/shared/chart-state.ts @@ -0,0 +1,8 @@ +import { ChartType } from "@/configurator"; + +import { Bounds } from "./use-width"; + +export interface CommonChartState { + chartType: ChartType; + bounds: Bounds; +} diff --git a/app/charts/shared/overlay-voronoi.tsx b/app/charts/shared/overlay-voronoi.tsx index 9f5f2ca56..396283d77 100644 --- a/app/charts/shared/overlay-voronoi.tsx +++ b/app/charts/shared/overlay-voronoi.tsx @@ -15,14 +15,22 @@ export const InteractionVoronoi = memo(function InteractionVoronoi({ }) { const [, dispatch] = useInteraction(); const ref = useRef(null); - const { data, getX, xScale, getY, yScale, getSegment, colors, bounds } = - useChartState() as LinesState | AreasState | ScatterplotState; + const { + preparedData, + getX, + xScale, + getY, + yScale, + getSegment, + colors, + bounds, + } = useChartState() as LinesState | AreasState | ScatterplotState; const { chartWidth, chartHeight, margins } = bounds; // FIXME: delaunay/voronoi calculation could be memoized const delaunay = Delaunay.from( - data, + preparedData, (d) => xScale(getX(d) ?? NaN), (d) => yScale(getY(d) ?? NaN) ); @@ -32,7 +40,7 @@ export const InteractionVoronoi = memo(function InteractionVoronoi({ const [x, y] = pointer(e, ref.current!); const location = delaunay.find(x, y); - const d = data[location]; + const d = preparedData[location]; if (typeof location !== "undefined") { dispatch({ @@ -55,7 +63,7 @@ export const InteractionVoronoi = memo(function InteractionVoronoi({ return ( {debug && - data.map((d, i) => ( + preparedData.map((d, i) => ( (InteractiveFiltersStateReducer, INTERACTIVE_FILTERS_INITIAL_STATE); return ( diff --git a/app/charts/shared/use-sync-interactive-filters.spec.tsx b/app/charts/shared/use-sync-interactive-filters.spec.tsx index 6c325027f..fc8554d5b 100644 --- a/app/charts/shared/use-sync-interactive-filters.spec.tsx +++ b/app/charts/shared/use-sync-interactive-filters.spec.tsx @@ -7,32 +7,65 @@ import { useInteractiveFilters, } from "@/charts/shared/use-interactive-filters"; import useSyncInteractiveFilters from "@/charts/shared/use-sync-interactive-filters"; -import { ChartConfig } from "@/configurator"; +import { + ChartConfig, + ConfiguratorStateConfiguringChart, + InteractiveFiltersConfig, +} from "@/configurator/config-types"; import fixture from "@/test/__fixtures/config/dev/4YL1p4QTFQS4.json"; +const { handleInteractiveFilterTimeSliderReset } = jest.requireActual( + "@/configurator/configurator-state" +); -const chartConfig = { - ...fixture.data.chartConfig, - interactiveFiltersConfig: { - legend: { - componentIri: "https://fake-iri/dimension/0", - active: false, - }, - dataFilters: { - active: true, - componentIris: [ - "http://environment.ld.admin.ch/foen/px/0703010000_103/dimension/1", - ], - }, - timeRange: { - active: false, - componentIri: "https://fake-iri/dimension/2", - presets: { - type: "range", - from: "2021-01-01", - to: "2021-01-12", - }, +const interactiveFiltersConfig: InteractiveFiltersConfig = { + legend: { + componentIri: "https://fake-iri/dimension/0", + active: false, + }, + dataFilters: { + active: true, + componentIris: [ + "http://environment.ld.admin.ch/foen/px/0703010000_103/dimension/1", + ], + }, + timeSlider: { + componentIri: + "http://environment.ld.admin.ch/foen/px/0703010000_103/dimension/0", + }, + timeRange: { + active: false, + componentIri: "https://fake-iri/dimension/2", + presets: { + type: "range", + from: "2021-01-01", + to: "2021-01-12", }, }, +}; + +const configuratorState = { + state: "CONFIGURING_CHART", + chartConfig: { + interactiveFiltersConfig, + }, +} as unknown as ConfiguratorStateConfiguringChart; + +jest.mock("@/configurator/configurator-state", () => { + return { + useConfiguratorState: () => { + return [ + configuratorState, + (_: { type: "INTERACTIVE_FILTER_TIME_SLIDER_RESET" }) => { + handleInteractiveFilterTimeSliderReset(configuratorState); + }, + ]; + }, + }; +}); + +const chartConfig = { + ...fixture.data.chartConfig, + interactiveFiltersConfig, } as ChartConfig; const setup = ({ @@ -44,6 +77,7 @@ const setup = ({ const [ifstate] = useInteractiveFilters(); const [useModified, setUseModified] = useState(false); useSyncInteractiveFilters(useModified ? modifiedChartConfig : chartConfig); + return (