diff --git a/CHANGELOG.md b/CHANGELOG.md index 51e062bfd..252faa779 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,10 @@ You can also check the [release page](https://github.com/visualize-admin/visuali ## Unreleased -Nothing yet. +### Features + +- Added Animation field for Column, Pie and Scatter charts 🎬 +- Added animations to columns in Column chart (position, size, color) ## [3.19.16] - 2023-05-12 diff --git a/app/charts/area/areas-state.tsx b/app/charts/area/areas-state.tsx index 55fbf27db..a53907ca8 100644 --- a/app/charts/area/areas-state.tsx +++ b/app/charts/area/areas-state.tsx @@ -174,6 +174,8 @@ const useAreasState = ( const { preparedData, scalesData } = useDataAfterInteractiveFilters({ sortedData: plottableSortedData, interactiveFiltersConfig, + // No animation yet for areas + animationField: undefined, getX, getSegment, }); diff --git a/app/charts/chart-config-ui-options.ts b/app/charts/chart-config-ui-options.ts index 5643854d9..05a1cfa73 100644 --- a/app/charts/chart-config-ui-options.ts +++ b/app/charts/chart-config-ui-options.ts @@ -10,9 +10,13 @@ import { * Related to config-types.ts. */ +type BaseEncodingFieldType = "animation"; type MapEncodingFieldType = "baseLayer" | "areaLayer" | "symbolLayer"; type XYEncodingFieldType = "x" | "y" | "segment"; -export type EncodingFieldType = MapEncodingFieldType | XYEncodingFieldType; +export type EncodingFieldType = + | BaseEncodingFieldType + | MapEncodingFieldType + | XYEncodingFieldType; export type EncodingOption = | { field: "chartSubType" } @@ -168,6 +172,12 @@ export const chartConfigOptionsUISpec: ChartSpecs = { { field: "useAbbreviations" }, ], }, + { + field: "animation", + optional: true, + componentTypes: ["TemporalDimension"], + filters: true, + }, ], interactiveFilters: ["legend", "timeRange"], }, @@ -277,6 +287,12 @@ export const chartConfigOptionsUISpec: ChartSpecs = { { field: "useAbbreviations" }, ], }, + { + field: "animation", + optional: true, + componentTypes: ["TemporalDimension"], + filters: true, + }, ], interactiveFilters: ["legend"], }, @@ -305,6 +321,12 @@ export const chartConfigOptionsUISpec: ChartSpecs = { { field: "useAbbreviations" }, ], }, + { + field: "animation", + optional: true, + componentTypes: ["TemporalDimension"], + filters: true, + }, ], interactiveFilters: ["legend"], }, diff --git a/app/charts/column/chart-column.tsx b/app/charts/column/chart-column.tsx index d338b6f19..48edfcfa7 100644 --- a/app/charts/column/chart-column.tsx +++ b/app/charts/column/chart-column.tsx @@ -132,10 +132,13 @@ export const ChartColumns = memo( fields.segment && interactiveFiltersConfig?.legend.active } /> - {interactiveFiltersConfig?.timeSlider.componentIri && ( + {fields.animation && ( )} @@ -167,10 +170,13 @@ export const ChartColumns = memo( fields.segment && interactiveFiltersConfig?.legend.active } /> - {interactiveFiltersConfig?.timeSlider.componentIri && ( + {fields.animation && ( )} @@ -194,10 +200,13 @@ export const ChartColumns = memo( - {interactiveFiltersConfig?.timeSlider.componentIri && ( + {fields.animation && ( )} diff --git a/app/charts/column/columns-grouped-state.tsx b/app/charts/column/columns-grouped-state.tsx index bd9bf2081..65e030182 100644 --- a/app/charts/column/columns-grouped-state.tsx +++ b/app/charts/column/columns-grouped-state.tsx @@ -187,6 +187,7 @@ const useGroupedColumnsState = ( const { preparedData, scalesData } = useDataAfterInteractiveFilters({ sortedData: plottableSortedData, interactiveFiltersConfig, + animationField: fields.animation, getX: getXAsDate, getSegment, }); diff --git a/app/charts/column/columns-grouped.tsx b/app/charts/column/columns-grouped.tsx index b47733002..ec25468d9 100644 --- a/app/charts/column/columns-grouped.tsx +++ b/app/charts/column/columns-grouped.tsx @@ -1,5 +1,8 @@ +import { select } from "d3"; +import React from "react"; + import { GroupedColumnsState } from "@/charts/column/columns-grouped-state"; -import { Column } from "@/charts/column/rendering-utils"; +import { RenderDatum, renderColumn } from "@/charts/column/rendering-utils"; import { useChartState } from "@/charts/shared/use-chart-state"; import { VerticalWhisker } from "@/charts/whiskers"; @@ -44,6 +47,12 @@ export const ErrorWhiskers = () => { ); }; +type GroupedRenderDatum = { + key: string; + x: number; + data: RenderDatum[]; +}; + export const ColumnsGrouped = () => { const { bounds, @@ -55,28 +64,58 @@ export const ColumnsGrouped = () => { colors, grouped, } = useChartState() as GroupedColumnsState; + const ref = React.useRef(null); const { margins } = bounds; - return ( - - {grouped.map((segment) => ( - - {segment[1].map((d, i) => { - const y = getY(d) ?? NaN; + const bandwidth = xScaleIn.bandwidth(); + const y0 = yScale(0); + const renderData: GroupedRenderDatum[] = React.useMemo(() => { + return grouped.map((segment) => { + return { + key: segment[0], + x: xScale(segment[0]) as number, + data: segment[1].map((d) => { + const y = getY(d) ?? NaN; - return ( - - ); - })} - - ))} - + return { + x: xScaleIn(getSegment(d)) as number, + y: yScale(Math.max(y, 0)), + width: bandwidth, + height: Math.abs(yScale(y) - y0), + color: colors(getSegment(d)), + }; + }), + }; + }); + }, [ + colors, + getSegment, + bandwidth, + getY, + grouped, + xScaleIn, + xScale, + yScale, + y0, + ]); + + React.useEffect(() => { + if (ref.current) { + select(ref.current) + .selectAll("g") + .data(renderData, (d) => d.key) + .join("g") + .attr("transform", (d) => `translate(${d.x}, 0)`) + .selectAll("rect") + .data( + (d) => d.data, + (d) => d.x + ) + .call(renderColumn, y0); + } + }, [renderData, y0]); + + return ( + ); }; diff --git a/app/charts/column/columns-simple.tsx b/app/charts/column/columns-simple.tsx index 10059a239..23490ca96 100644 --- a/app/charts/column/columns-simple.tsx +++ b/app/charts/column/columns-simple.tsx @@ -1,5 +1,8 @@ +import { select } from "d3"; +import React from "react"; + import { ColumnsState } from "@/charts/column/columns-state"; -import { Column } from "@/charts/column/rendering-utils"; +import { RenderDatum, renderColumn } from "@/charts/column/rendering-utils"; import { useChartState } from "@/charts/shared/use-chart-state"; import { VerticalWhisker } from "@/charts/whiskers"; import { useTheme } from "@/themes"; @@ -42,30 +45,50 @@ export const ErrorWhiskers = () => { }; export const Columns = () => { + const ref = React.useRef(null); const { preparedData, bounds, getX, xScale, getY, yScale } = useChartState() as ColumnsState; const theme = useTheme(); const { margins } = bounds; + const bandwidth = xScale.bandwidth(); + const y0 = yScale(0); + const renderData: RenderDatum[] = React.useMemo(() => { + const getColor = (d: number) => { + return d <= 0 ? theme.palette.secondary.main : theme.palette.primary.main; + }; + + return preparedData.map((d) => { + const xScaled = xScale(getX(d)) as number; + const y = getY(d) ?? NaN; + const yScaled = yScale(y); + const height = Math.abs(yScaled - y0); + const color = getColor(y); + + return { x: xScaled, y: yScaled, width: bandwidth, height, color }; + }); + }, [ + preparedData, + bandwidth, + getX, + getY, + xScale, + yScale, + y0, + theme.palette.primary.main, + theme.palette.secondary.main, + ]); + + React.useEffect(() => { + if (ref.current) { + select(ref.current) + .selectAll("rect") + .data(renderData, (d) => d.x) + .call(renderColumn, y0); + } + }, [renderData, yScale, y0]); + return ( - - {preparedData.map((d, i) => { - const y = getY(d) ?? NaN; - const x = xScale(getX(d)) as number; - return ( - - ); - })} - + ); }; diff --git a/app/charts/column/columns-stacked-state.tsx b/app/charts/column/columns-stacked-state.tsx index 328f950ef..3580f520f 100644 --- a/app/charts/column/columns-stacked-state.tsx +++ b/app/charts/column/columns-stacked-state.tsx @@ -200,6 +200,7 @@ const useColumnsStackedState = ( const { preparedData, scalesData } = useDataAfterInteractiveFilters({ sortedData: plottableSortedData, interactiveFiltersConfig, + animationField: fields.animation, getX: getXAsDate, getSegment, }); diff --git a/app/charts/column/columns-stacked.tsx b/app/charts/column/columns-stacked.tsx index 7b1c580d2..0565f7916 100644 --- a/app/charts/column/columns-stacked.tsx +++ b/app/charts/column/columns-stacked.tsx @@ -1,29 +1,60 @@ +import { select } from "d3"; +import React from "react"; + import { StackedColumnsState } from "@/charts/column/columns-stacked-state"; -import { Column } from "@/charts/column/rendering-utils"; +import { RenderDatum, renderColumn } from "@/charts/column/rendering-utils"; import { useChartState } from "@/charts/shared/use-chart-state"; +type StackedRenderDatum = { + key: string; + data: RenderDatum[]; +}; + export const ColumnsStacked = () => { + const ref = React.useRef(null); const { bounds, getX, xScale, yScale, colors, series } = useChartState() as StackedColumnsState; const { margins } = bounds; + const bandwidth = xScale.bandwidth(); + const y0 = yScale(0); + const renderData: StackedRenderDatum[] = React.useMemo(() => { + return series.map((d) => { + const key = d.key; + const color = colors(key); + + return { + key, + data: d.map((segment: $FixMe) => { + return { + x: xScale(getX(segment.data)) as number, + y: yScale(segment[1]), + width: bandwidth, + height: y0 - yScale(segment[1] - segment[0]), + color, + }; + }), + }; + }); + }, [bandwidth, colors, getX, series, xScale, y0, yScale]); + + React.useEffect(() => { + if (ref.current) { + select(ref.current) + .selectAll("g") + .data(renderData, (d) => d.key) + .join("g") + .attr("data-n", (d) => d.key) + .selectAll("rect") + .data( + (d) => d.data, + (d) => `${d.x}-${d.y}` + ) + .call(renderColumn, y0); + } + }, [renderData, y0]); + return ( - - {series.map((sv) => ( - - {sv.map((segment: $FixMe, i: number) => { - return ( - - ); - })} - - ))} - + ); }; diff --git a/app/charts/column/columns-state.tsx b/app/charts/column/columns-state.tsx index bcc1ffffe..31060aec1 100644 --- a/app/charts/column/columns-state.tsx +++ b/app/charts/column/columns-state.tsx @@ -162,6 +162,7 @@ const useColumnsState = ( const { preparedData, scalesData } = useDataAfterInteractiveFilters({ sortedData: plottableSortedData, interactiveFiltersConfig, + animationField: fields.animation, getX: getXAsDate, }); diff --git a/app/charts/column/rendering-utils.tsx b/app/charts/column/rendering-utils.tsx index be423e3a7..b8838cb99 100644 --- a/app/charts/column/rendering-utils.tsx +++ b/app/charts/column/rendering-utils.tsx @@ -1,30 +1,46 @@ -import { HTMLAttributes, memo } from "react"; +import { Selection } from "d3"; -export const Column = memo( - ({ - x, - y, - width, - height, - color, - ...props - }: { - x: number; - y: number; - width: number; - height: number; - color?: string; - } & HTMLAttributes) => { - return ( - - ); - } -); +export type RenderDatum = { + x: number; + y: number; + width: number; + height: number; + color: string; +}; + +export const renderColumn = ( + g: Selection, + y0: number +) => { + g.join( + (enter) => + enter + .append("rect") + .attr("data-index", (_, i) => i) + .attr("x", (d) => d.x) + .attr("y", y0) + .attr("width", (d) => d.width) + .attr("height", 0) + .attr("fill", (d) => d.color) + .call((enter) => + enter + .transition() + .attr("y", (d) => d.y) + .attr("height", (d) => d.height) + ), + (update) => + update.call((update) => + update + .transition() + .attr("x", (d) => d.x) + .attr("y", (d) => d.y) + .attr("width", (d) => d.width) + .attr("height", (d) => d.height) + .attr("fill", (d) => d.color) + ), + (exit) => + exit.call((exit) => + exit.transition().attr("y", y0).attr("height", 0).remove() + ) + ); +}; diff --git a/app/charts/index.ts b/app/charts/index.ts index fe29d2e07..492c2448a 100644 --- a/app/charts/index.ts +++ b/app/charts/index.ts @@ -100,7 +100,6 @@ export const findPreferredDimension = ( }; const INITIAL_INTERACTIVE_FILTERS_CONFIG: InteractiveFiltersConfig = { - // FIXME: we shouldn't keep empty props legend: { active: false, componentIri: "", @@ -114,9 +113,6 @@ const INITIAL_INTERACTIVE_FILTERS_CONFIG: InteractiveFiltersConfig = { to: "", }, }, - timeSlider: { - componentIri: "", - }, dataFilters: { active: false, componentIris: [], @@ -474,6 +470,7 @@ const getAdjustedChartConfig = ({ ); case "filters": case "fields.segment": + case "fields.animation": case "interactiveFiltersConfig.legend": return true; default: @@ -658,6 +655,15 @@ const chartConfigsAdjusters: ChartConfigsAdjusters = { } }); }, + animation: ({ oldValue, newChartConfig }) => { + return produce(newChartConfig, (draft) => { + // Temporal dimension could be used as X axis, in this case we need to + // remove the animation. + if (newChartConfig.fields.x.componentIri !== oldValue?.componentIri) { + draft.fields.animation = oldValue; + } + }); + }, }, interactiveFiltersConfig: interactiveFiltersAdjusters, }, @@ -865,6 +871,18 @@ const chartConfigsAdjusters: ChartConfigsAdjusters = { } }); }, + animation: ({ oldValue, newChartConfig }) => { + return produce(newChartConfig, (draft) => { + // Temporal dimension could be used as X or Y axis, in this case we need to + // remove the animation. + if ( + newChartConfig.fields.x.componentIri !== oldValue?.componentIri && + newChartConfig.fields.y.componentIri !== oldValue?.componentIri + ) { + draft.fields.animation = oldValue; + } + }); + }, }, interactiveFiltersConfig: interactiveFiltersAdjusters, }, @@ -924,6 +942,11 @@ const chartConfigsAdjusters: ChartConfigsAdjusters = { } }); }, + animation: ({ oldValue, newChartConfig }) => { + return produce(newChartConfig, (draft) => { + draft.fields.animation = oldValue; + }); + }, }, interactiveFiltersConfig: interactiveFiltersAdjusters, }, diff --git a/app/charts/line/lines-state.tsx b/app/charts/line/lines-state.tsx index cf66c2e8b..2d8b2bf93 100644 --- a/app/charts/line/lines-state.tsx +++ b/app/charts/line/lines-state.tsx @@ -154,6 +154,8 @@ const useLinesState = ( const { preparedData, scalesData } = useDataAfterInteractiveFilters({ sortedData: plottableSortedData, interactiveFiltersConfig, + // No animation yet for lines + animationField: undefined, getX, getSegment, }); diff --git a/app/charts/pie/chart-pie.tsx b/app/charts/pie/chart-pie.tsx index 52e3ee395..084e407d1 100644 --- a/app/charts/pie/chart-pie.tsx +++ b/app/charts/pie/chart-pie.tsx @@ -122,10 +122,13 @@ export const ChartPie = memo( } /> - {interactiveFiltersConfig?.timeSlider.componentIri && ( + {fields.animation && ( )} diff --git a/app/charts/pie/pie-state.tsx b/app/charts/pie/pie-state.tsx index d472dc37e..b865f61e8 100644 --- a/app/charts/pie/pie-state.tsx +++ b/app/charts/pie/pie-state.tsx @@ -99,6 +99,7 @@ const usePieState = ( const { preparedData } = useDataAfterInteractiveFilters({ sortedData: plottableData, interactiveFiltersConfig, + animationField: fields.animation, getSegment, }); diff --git a/app/charts/scatterplot/chart-scatterplot.tsx b/app/charts/scatterplot/chart-scatterplot.tsx index f0d2af8f7..903ba0b71 100644 --- a/app/charts/scatterplot/chart-scatterplot.tsx +++ b/app/charts/scatterplot/chart-scatterplot.tsx @@ -127,10 +127,13 @@ export const ChartScatterplot = memo( } /> - {interactiveFiltersConfig?.timeSlider.componentIri && ( + {fields.animation && ( )} diff --git a/app/charts/scatterplot/scatterplot-state.tsx b/app/charts/scatterplot/scatterplot-state.tsx index 0f52280aa..59ca7b405 100644 --- a/app/charts/scatterplot/scatterplot-state.tsx +++ b/app/charts/scatterplot/scatterplot-state.tsx @@ -107,6 +107,8 @@ const useScatterplotState = ({ const { preparedData, scalesData } = useDataAfterInteractiveFilters({ sortedData: plottableSortedData, interactiveFiltersConfig, + // No animation yet for scatterplot + animationField: undefined, getSegment, }); const xMeasure = measures.find((d) => d.iri === fields.x.componentIri); diff --git a/app/charts/shared/chart-helpers.spec.tsx b/app/charts/shared/chart-helpers.spec.tsx index 086021094..77af7f3aa 100644 --- a/app/charts/shared/chart-helpers.spec.tsx +++ b/app/charts/shared/chart-helpers.spec.tsx @@ -44,9 +44,6 @@ const commonInteractiveFiltersConfig: InteractiveFiltersConfig = { to: "2020-01-01", }, }, - timeSlider: { - componentIri: "", - }, dataFilters: { componentIris: [col("3"), col("4")], active: false, diff --git a/app/charts/shared/chart-helpers.tsx b/app/charts/shared/chart-helpers.tsx index 717987e2b..5e1126f45 100644 --- a/app/charts/shared/chart-helpers.tsx +++ b/app/charts/shared/chart-helpers.tsx @@ -12,13 +12,10 @@ import { InteractiveFiltersState, useInteractiveFilters, } from "@/charts/shared/use-interactive-filters"; -import { - CategoricalColorField, - InteractiveFiltersTimeSlider, - NumericalColorField, -} from "@/configurator"; +import { CategoricalColorField, NumericalColorField } from "@/configurator"; import { parseDate } from "@/configurator/components/ui-helpers"; import { + AnimationField, ChartConfig, ChartType, Filters, @@ -47,17 +44,13 @@ export const prepareQueryFilters = ( dataFilters: InteractiveFiltersState["dataFilters"] ): Filters => { let queryFilters = filters; - const { timeSlider } = interactiveFiltersConfig || {}; if (chartType !== "table" && interactiveFiltersConfig?.dataFilters.active) { queryFilters = { ...queryFilters, ...dataFilters }; } - queryFilters = omitBy(queryFilters, (v, k) => { - return ( - (v.type === "single" && v.value === FIELD_VALUE_NONE) || - k === timeSlider?.componentIri - ); + queryFilters = omitBy(queryFilters, (v) => { + return v.type === "single" && v.value === FIELD_VALUE_NONE; }); return queryFilters; @@ -131,9 +124,6 @@ export const getChartConfigComponentIris = (chartConfig: ChartConfig) => { const v = IFConfig[k]; switch (k) { - case "timeSlider": - IFIris.push((v as InteractiveFiltersTimeSlider).componentIri); - break; case "legend": IFIris.push((v as InteractiveFiltersLegend).componentIri); break; @@ -187,11 +177,13 @@ type ValuePredicate = (v: any) => boolean; export const useDataAfterInteractiveFilters = ({ sortedData, interactiveFiltersConfig, + animationField, getX, getSegment, }: { sortedData: Observation[]; interactiveFiltersConfig: InteractiveFiltersConfig; + animationField: AnimationField | undefined; getX?: (d: Observation) => Date; getSegment?: (d: Observation) => string; }): { @@ -211,9 +203,7 @@ export const useDataAfterInteractiveFilters = ({ const toTime = IFState.timeRange.to?.getTime(); // time slider - const getTime = useTemporalVariable( - interactiveFiltersConfig?.timeSlider.componentIri || "" - ); + const getTime = useTemporalVariable(animationField?.componentIri ?? ""); const timeSliderValue = IFState.timeSlider.value; // legend @@ -228,7 +218,7 @@ export const useDataAfterInteractiveFilters = ({ } : null; const timeSliderFilter = - interactiveFiltersConfig?.timeSlider.componentIri && timeSliderValue + animationField?.componentIri && timeSliderValue ? (d: Observation) => { return getTime(d).getTime() === timeSliderValue.getTime(); } @@ -254,8 +244,8 @@ export const useDataAfterInteractiveFilters = ({ fromTime, toTime, interactiveFiltersConfig?.timeRange.active, - interactiveFiltersConfig?.timeSlider.componentIri, interactiveFiltersConfig?.legend.active, + animationField, timeSliderValue, getSegment, getTime, @@ -267,7 +257,7 @@ export const useDataAfterInteractiveFilters = ({ }, [allFilters, sortedData]); const timeSliderFilterPresent = !!( - interactiveFiltersConfig?.timeSlider.componentIri && timeSliderValue + animationField?.componentIri && timeSliderValue ); const scalesData = timeSliderFilterPresent ? sortedData : preparedData; diff --git a/app/charts/shared/use-sync-interactive-filters.spec.tsx b/app/charts/shared/use-sync-interactive-filters.spec.tsx index fc8554d5b..0748323ae 100644 --- a/app/charts/shared/use-sync-interactive-filters.spec.tsx +++ b/app/charts/shared/use-sync-interactive-filters.spec.tsx @@ -9,13 +9,9 @@ import { import useSyncInteractiveFilters from "@/charts/shared/use-sync-interactive-filters"; 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 interactiveFiltersConfig: InteractiveFiltersConfig = { legend: { @@ -28,10 +24,6 @@ const interactiveFiltersConfig: InteractiveFiltersConfig = { "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", @@ -43,26 +35,6 @@ const interactiveFiltersConfig: InteractiveFiltersConfig = { }, }; -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, @@ -142,10 +114,6 @@ describe("useSyncInteractiveFilters", () => { "http://environment.ld.admin.ch/foen/px/0703010000_103/dimension/1/1", }); - expect( - configuratorState.chartConfig.interactiveFiltersConfig?.timeSlider - .componentIri - ).toEqual(""); expect(IFState2.timeSlider.value).toBeUndefined(); }); }); diff --git a/app/charts/shared/use-sync-interactive-filters.tsx b/app/charts/shared/use-sync-interactive-filters.tsx index 9a7099d3a..82e148919 100644 --- a/app/charts/shared/use-sync-interactive-filters.tsx +++ b/app/charts/shared/use-sync-interactive-filters.tsx @@ -7,12 +7,9 @@ import { FilterValueSingle, isSegmentInConfig, } from "@/configurator/config-types"; -import { useConfiguratorState } from "@/configurator/configurator-state"; import { FIELD_VALUE_NONE } from "@/configurator/constants"; import useFilterChanges from "@/configurator/use-filter-changes"; -import { getFieldComponentIri } from ".."; - /** * Makes sure interactive filters are in sync with chart config. * @@ -22,7 +19,6 @@ import { getFieldComponentIri } from ".."; */ const useSyncInteractiveFilters = (chartConfig: ChartConfig) => { const [IFstate, dispatch] = useInteractiveFilters(); - const [_, dispatchConfigurator] = useConfiguratorState(); const { interactiveFiltersConfig } = chartConfig; // Time range filter @@ -46,32 +42,6 @@ const useSyncInteractiveFilters = (chartConfig: ChartConfig) => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [dispatch, presetFromStr, presetToStr]); - // Time slider filter - const timeSliderFilter = interactiveFiltersConfig?.timeSlider; - const xComponentIri = getFieldComponentIri(chartConfig.fields, "x"); - useEffect(() => { - if ( - timeSliderFilter?.componentIri === "" && - IFstate.timeSlider.value !== undefined - ) { - dispatch({ type: "RESET_TIME_SLIDER_FILTER" }); - } - - if ( - xComponentIri !== undefined && - xComponentIri === timeSliderFilter?.componentIri - ) { - dispatchConfigurator({ type: "INTERACTIVE_FILTER_TIME_SLIDER_RESET" }); - } - }, [ - IFstate.timeSlider.value, - timeSliderFilter?.componentIri, - dispatch, - dispatchConfigurator, - interactiveFiltersConfig, - xComponentIri, - ]); - // Data Filters const componentIris = interactiveFiltersConfig?.dataFilters.componentIris; useEffect(() => { diff --git a/app/components/form.tsx b/app/components/form.tsx index b93670361..f5af1be4e 100644 --- a/app/components/form.tsx +++ b/app/components/form.tsx @@ -643,6 +643,7 @@ export const FieldSetLegend = ({ fontWeight: "regular", fontSize: ["0.625rem", "0.75rem", "0.75rem"], pl: 0, + mb: 1, ...sx, }} > diff --git a/app/configurator/components/chart-annotations-selector.tsx b/app/configurator/components/chart-annotations-selector.tsx index fbc9801f9..ff8b1818d 100644 --- a/app/configurator/components/chart-annotations-selector.tsx +++ b/app/configurator/components/chart-annotations-selector.tsx @@ -74,7 +74,6 @@ export const ChartAnnotationsSelector = ({ case "dataFilters": case "legend": case "timeRange": - case "timeSlider": return true; default: diff --git a/app/configurator/components/chart-controls/control-tab.tsx b/app/configurator/components/chart-controls/control-tab.tsx index 09ec7557e..c4ae933a0 100644 --- a/app/configurator/components/chart-controls/control-tab.tsx +++ b/app/configurator/components/chart-controls/control-tab.tsx @@ -1,7 +1,7 @@ import { Trans } from "@lingui/macro"; import { Box, Button, Theme, Typography } from "@mui/material"; import { makeStyles } from "@mui/styles"; -import { ReactNode } from "react"; +import React, { ReactNode } from "react"; import Flex from "@/components/flex"; import { FieldProps } from "@/configurator"; @@ -9,6 +9,8 @@ import { getFieldLabel } from "@/configurator/components/field-i18n"; import { getIconName } from "@/configurator/components/ui-helpers"; import { DimensionMetadataFragment } from "@/graphql/query-hooks"; import { Icon, IconName } from "@/icons"; +import SvgIcEdit from "@/icons/components/IcEdit"; +import useEvent from "@/utils/use-event"; export const ControlTab = ({ component, @@ -16,46 +18,45 @@ export const ControlTab = ({ onClick, checked, labelId, - rightIcon, }: { component?: DimensionMetadataFragment; value: string; onClick: (x: string) => void; labelId: string; - rightIcon?: React.ReactNode; } & FieldProps) => { + const handleClick = useEvent(() => onClick(value)); + return ( - - onClick(value)} - > + + Add ... - ) + component?.label ?? Add… } checked={checked} optional={!component} - rightIcon={rightIcon} + rightIcon={} /> ); }; +const useFieldIconStyles = makeStyles((theme) => ({ + root: { + color: theme.palette.primary.main, + width: 18, + height: 18, + }, +})); + +const FieldEditIcon = () => { + const classes = useFieldIconStyles(); + return ; +}; + export const OnOffControlTab = ({ value, label, @@ -202,7 +203,7 @@ const useStyles = makeStyles((theme: Theme) => ({ "&:active": { backgroundColor: theme.palette.action.hover, }, - "&:disabled": { + "&.Mui-disabled": { cursor: "initial", backgroundColor: theme.palette.muted.main, }, @@ -238,9 +239,7 @@ export const ControlTabButton = ({ id={`tab-${value}`} onClick={() => onClick(value)} className={classes.controlTabButton} - sx={{ - backgroundColor: checked ? "action.hover" : "grey.100", - }} + sx={{ backgroundColor: checked ? "action.hover" : "grey.100" }} > {children} diff --git a/app/configurator/components/chart-options-selector.tsx b/app/configurator/components/chart-options-selector.tsx index 3cd203e9f..1469f7ae4 100644 --- a/app/configurator/components/chart-options-selector.tsx +++ b/app/configurator/components/chart-options-selector.tsx @@ -17,6 +17,7 @@ import { useImputationNeeded } from "@/charts/shared/chart-helpers"; import Flex from "@/components/flex"; import { FieldSetLegend, Radio, Select } from "@/components/form"; import { + AnimationField, ChartConfig, ChartType, ColorFieldType, @@ -25,6 +26,7 @@ import { ConfiguratorStateConfiguringChart, ImputationType, imputationTypes, + isAnimationInConfig, isAreaConfig, isConfiguring, isTableConfig, @@ -172,7 +174,11 @@ const ActiveFieldSwitch = ({ metaData: DataCubeMetadataWithHierarchies; imputationNeeded: boolean; }) => { - const { activeField } = state; + const activeField = state.activeField as EncodingFieldType | undefined; + + if (!activeField) { + return null; + } const encodings = chartConfigOptionsUISpec[state.chartConfig.chartType].encodings; @@ -180,10 +186,6 @@ const ActiveFieldSwitch = ({ (e) => e.field === activeField ) as EncodingSpec; - if (!activeField) { - return null; - } - const activeFieldComponentIri = getFieldComponentIri( state.chartConfig.fields, activeField @@ -220,7 +222,7 @@ const EncodingOptionsPanel = ({ }: { encoding: EncodingSpec; state: ConfiguratorStateConfiguringChart; - field: string; + field: EncodingFieldType; chartType: ChartType; component: DimensionMetadataFragment | undefined; dimensions: DimensionMetadataFragment[]; @@ -229,14 +231,13 @@ const EncodingOptionsPanel = ({ }) => { const panelRef = useRef(null); - const getFieldLabelHint = { + const fieldLabelHint: Partial> = { x: t({ id: "controls.select.dimension", message: "Select a dimension" }), y: t({ id: "controls.select.measure", message: "Select a measure" }), - // Empty strings for optional encodings. - baseLayer: "", - areaLayer: "", - symbolLayer: "", - segment: "", + animation: t({ + id: "controls.select.dimension", + message: "Select a dimension", + }), }; useEffect(() => { @@ -318,7 +319,7 @@ const EncodingOptionsPanel = ({ @@ -390,6 +391,7 @@ const EncodingOptionsPanel = ({ } /> )} + {optionsByField["showStandardError"] && hasStandardError && ( @@ -407,9 +409,11 @@ const EncodingOptionsPanel = ({ )} + {optionsByField["imputationType"] && isAreaConfig(state.chartConfig) && ( )} + + + {fieldDimension && + field === "animation" && + isAnimationInConfig(state.chartConfig) && + state.chartConfig.fields.animation && ( + + )} ); }; @@ -446,6 +457,81 @@ const ChartFieldAbbreviations = ({ ); }; +const ChartFieldAnimation = ({ field }: { field: AnimationField }) => { + return ( + + + + Animation Settings + + + + + {field.showPlayButton && ( + <> + + + Animation Duration + + } + /> + + {[10, 30, 60].map((d) => ( + + ))} + + + + + Animation Type + + } + /> + + + + + + + )} + + + ); +}; + const ChartFieldMultiFilter = ({ state, component, @@ -527,7 +613,6 @@ const ChartFieldOptions = ({ chartType === "column" && ( Column layout } @@ -862,7 +947,6 @@ const ChartFieldColorComponent = ({ nbClass={nbClass} /> ((theme) => ({ - root: { - color: theme.palette.primary.main, - }, -})); - -const FieldEditIcon = () => { - const classes = useFieldEditIconStyles(); - return ; -}; - const useStyles = makeStyles((theme) => ({ root: { display: "flex", - alignItems: "center", gap: "0.25rem", }, - optional: { - paddingBottom: "4px", - }, loadingIndicator: { color: theme.palette.grey[700], display: "inline-block", @@ -116,7 +97,6 @@ export const ControlTabField = ({ labelId={labelId} checked={field.checked} onClick={field.onClick} - rightIcon={} /> ); }; @@ -528,7 +508,6 @@ export const AnnotatorTabField = ({ value={`${fieldProps.value}`} checked={fieldProps.checked} onClick={fieldProps.onClick} - rightIcon={} /> ); }; @@ -706,13 +685,11 @@ const FieldLabel = ({ id: "controls.select.optional", message: `optional`, }); + return (
{label} - - {isOptional ? ( - ({optionalLabel}) - ) : null} + {isOptional ? ({optionalLabel}) : null} {isFetching ? ( ) : null} @@ -721,13 +698,13 @@ const FieldLabel = ({ }; export const ChartFieldField = ({ - label, + label = "", field, options, optional, disabled, }: { - label: string; + label?: string; field: string; options: Option[]; optional?: boolean; @@ -776,7 +753,7 @@ export const ChartOptionRadioField = ({ label: string; field: string | null; path: string; - value: string; + value: string | number; defaultChecked?: boolean; disabled?: boolean; }) => { diff --git a/app/configurator/components/ui-helpers.ts b/app/configurator/components/ui-helpers.ts index bb435e5dd..875327df0 100644 --- a/app/configurator/components/ui-helpers.ts +++ b/app/configurator/components/ui-helpers.ts @@ -188,8 +188,8 @@ export const getIconName = (name: string): IconName => { return "tableColumnTimeHidden"; case "time": return "time"; - case "play": - return "play"; + case "animation": + return "animation"; default: return "table"; diff --git a/app/configurator/config-form.tsx b/app/configurator/config-form.tsx index 53c874d50..3dd641080 100644 --- a/app/configurator/config-form.tsx +++ b/app/configurator/config-form.tsx @@ -297,7 +297,7 @@ export const useChartOptionRadioField = ({ }: { field: string | null; path: string; - value: string; + value: string | number; }): FieldProps => { const locale = useLocale(); const [state, dispatch] = useConfiguratorState(); diff --git a/app/configurator/config-types.ts b/app/configurator/config-types.ts index e29fed2d5..e041816f0 100644 --- a/app/configurator/config-types.ts +++ b/app/configurator/config-types.ts @@ -103,14 +103,6 @@ export type InteractiveFiltersTimeRange = t.TypeOf< typeof InteractiveFiltersTimeRange >; -const InteractiveFiltersTimeSlider = t.type({ - // FIXME: add range - componentIri: t.string, -}); -export type InteractiveFiltersTimeSlider = t.TypeOf< - typeof InteractiveFiltersTimeSlider ->; - const InteractiveFiltersDataConfig = t.type({ active: t.boolean, componentIris: t.array(t.string), @@ -123,7 +115,6 @@ const InteractiveFiltersConfig = t.union([ t.type({ legend: InteractiveFiltersLegend, timeRange: InteractiveFiltersTimeRange, - timeSlider: InteractiveFiltersTimeSlider, dataFilters: InteractiveFiltersDataConfig, }), t.undefined, @@ -167,6 +158,19 @@ const GenericSegmentField = t.intersection([ ]); export type GenericSegmentField = t.TypeOf; +const AnimationType = t.union([t.literal("continuous"), t.literal("stepped")]); +export type AnimationType = t.TypeOf; + +const AnimationField = t.intersection([ + GenericField, + t.type({ + showPlayButton: t.boolean, + duration: t.number, + type: AnimationType, + }), +]); +export type AnimationField = t.TypeOf; + const SortingField = t.partial({ sorting: t.type({ sortingType: SortingType, @@ -191,6 +195,7 @@ const ColumnFields = t.intersection([ }), t.partial({ segment: ColumnSegmentField, + animation: AnimationField, }), ]); const ColumnConfig = t.type( @@ -278,6 +283,7 @@ const ScatterPlotFields = t.intersection([ }), t.partial({ segment: ScatterPlotSegmentField, + animation: AnimationField, }), ]); const ScatterPlotConfig = t.type( @@ -296,11 +302,14 @@ export type ScatterPlotConfig = t.TypeOf; const PieSegmentField = t.intersection([GenericSegmentField, SortingField]); export type PieSegmentField = t.TypeOf; -const PieFields = t.type({ - y: GenericField, - // FIXME: "segment" should be "x" for consistency - segment: PieSegmentField, -}); +const PieFields = t.intersection([ + t.type({ + y: GenericField, + // FIXME: "segment" should be "x" for consistency + segment: PieSegmentField, + }), + t.partial({ animation: AnimationField }), +]); const PieConfig = t.type( { version: t.string, @@ -625,6 +634,16 @@ export const isSegmentInConfig = ( return !isTableConfig(chartConfig) && !isMapConfig(chartConfig); }; +export const isAnimationInConfig = ( + chartConfig: ChartConfig +): chartConfig is ColumnConfig | ScatterPlotConfig | PieConfig => { + return ( + chartConfig.chartType === "column" || + chartConfig.chartType === "scatterplot" || + chartConfig.chartType === "pie" + ); +}; + export const isColorFieldInConfig = ( chartConfig: ChartConfig ): chartConfig is MapConfig => { @@ -695,6 +714,7 @@ type ColumnAdjusters = BaseAdjusters & { | PieSegmentField | TableFields >; + animation: FieldAdjuster; }; }; @@ -739,6 +759,7 @@ type ScatterPlotAdjusters = BaseAdjusters & { | PieSegmentField | TableFields >; + animation: FieldAdjuster; }; }; @@ -753,6 +774,7 @@ type PieAdjusters = BaseAdjusters & { | ScatterPlotSegmentField | TableFields >; + animation: FieldAdjuster; }; }; diff --git a/app/configurator/configurator-state.tsx b/app/configurator/configurator-state.tsx index 7b402a4a8..3d99e7a0b 100644 --- a/app/configurator/configurator-state.tsx +++ b/app/configurator/configurator-state.tsx @@ -39,17 +39,18 @@ import { ConfiguratorStateConfiguringChart, ConfiguratorStateSelectingDataSet, DataSource, + decodeConfiguratorState, + Filters, FilterValue, FilterValueMultiValues, - Filters, GenericField, GenericFields, ImputationType, InteractiveFiltersConfig, + isAnimationInConfig, MapConfig, MapFields, NumericalColorField, - decodeConfiguratorState, isAreaConfig, isColorFieldInConfig, isColumnConfig, @@ -200,9 +201,6 @@ export type ConfiguratorStateAction = type: "INTERACTIVE_FILTER_CHANGED"; value: InteractiveFiltersConfig; } - | { - type: "INTERACTIVE_FILTER_TIME_SLIDER_RESET"; - } | { type: "CHART_CONFIG_REPLACED"; value: { @@ -812,15 +810,21 @@ export const handleChartFieldChanged = ( const component = [...dimensions, ...measures].find( (dim) => dim.iri === componentIri ); - const selectedValues = actionSelectedValues - ? actionSelectedValues - : component?.values || []; + const selectedValues = actionSelectedValues ?? component?.values ?? []; + // The field was not defined before if (!f) { // FIXME? - // optionalFields = ['segment', 'areaLayer', 'symbolLayer'], + // optionalFields = ['animation', 'segment', 'areaLayer', 'symbolLayer'], // should be reflected in chart encodings - if (field === "segment") { + if (field === "animation" && isAnimationInConfig(draft.chartConfig)) { + draft.chartConfig.fields.animation = { + componentIri, + showPlayButton: true, + duration: 30, + type: "continuous", + }; + } else if (field === "segment") { // FIXME: This should be more chart specific // (no "stacked" for scatterplots for instance) if (isSegmentInConfig(draft.chartConfig)) { @@ -868,7 +872,15 @@ export const handleChartFieldChanged = ( } } else { // The field is being updated - if ( + if (field === "animation" && isAnimationInConfig(draft.chartConfig)) { + draft.chartConfig.fields.animation = { + componentIri, + showPlayButton: + draft.chartConfig.fields.animation?.showPlayButton ?? true, + duration: draft.chartConfig.fields.animation?.duration ?? 30, + type: draft.chartConfig.fields.animation?.type ?? "continuous", + }; + } else if ( field === "segment" && "segment" in draft.chartConfig.fields && draft.chartConfig.fields.segment && @@ -1124,18 +1136,6 @@ export const handleInteractiveFilterChanged = ( return draft; }; -export const handleInteractiveFilterTimeSliderReset = ( - draft: ConfiguratorState -) => { - if (draft.state === "CONFIGURING_CHART") { - if (draft.chartConfig.interactiveFiltersConfig) { - draft.chartConfig.interactiveFiltersConfig.timeSlider.componentIri = ""; - } - } - - return draft; -}; - const reducer: Reducer = ( draft, action @@ -1274,9 +1274,6 @@ const reducer: Reducer = ( case "INTERACTIVE_FILTER_CHANGED": return handleInteractiveFilterChanged(draft, action); - case "INTERACTIVE_FILTER_TIME_SLIDER_RESET": - return handleInteractiveFilterTimeSliderReset(draft); - case "CHART_CONFIG_REPLACED": if (draft.state === "CONFIGURING_CHART") { draft.chartConfig = deriveFiltersFromFields( diff --git a/app/configurator/interactive-filters/helpers.ts b/app/configurator/interactive-filters/helpers.ts index 8b9d2e2b2..bf91948f8 100644 --- a/app/configurator/interactive-filters/helpers.ts +++ b/app/configurator/interactive-filters/helpers.ts @@ -1,44 +1,9 @@ -import { getFieldComponentIri, getFieldComponentIris } from "@/charts"; +import { getFieldComponentIris } from "@/charts"; import { isTemporalDimension } from "@/domain/data"; -import { - DimensionMetadataFragment, - TemporalDimension, - TimeUnit, -} from "@/graphql/query-hooks"; +import { TimeUnit } from "@/graphql/query-hooks"; import { DataCubeMetadata } from "@/graphql/types"; -import { - ChartConfig, - ConfiguratorStateConfiguringChart, -} from "../config-types"; - -export const getTimeSliderFilterDimensions = ({ - chartConfig, - dataCubeByIri, -}: { - chartConfig: ChartConfig; - dataCubeByIri: { - dimensions: DimensionMetadataFragment[]; - measures: DimensionMetadataFragment[]; - }; -}): TemporalDimension[] => { - if (dataCubeByIri) { - const allComponents = [ - ...dataCubeByIri.dimensions, - ...dataCubeByIri.measures, - ]; - const xComponentIri = getFieldComponentIri(chartConfig.fields, "x"); - const xComponent = allComponents.find((d) => d.iri === xComponentIri); - - return allComponents.filter( - (d) => - isTemporalDimension(d) && - (isTemporalDimension(xComponent) ? d.iri !== xComponent.iri : true) - ) as TemporalDimension[]; - } - - return []; -}; +import { ConfiguratorStateConfiguringChart } from "../config-types"; export const getDataFilterDimensions = ( chartConfig: ConfiguratorStateConfiguringChart["chartConfig"], diff --git a/app/configurator/interactive-filters/interactive-filters-config-options.tsx b/app/configurator/interactive-filters/interactive-filters-config-options.tsx index 4688eb4ff..645822845 100644 --- a/app/configurator/interactive-filters/interactive-filters-config-options.tsx +++ b/app/configurator/interactive-filters/interactive-filters-config-options.tsx @@ -1,11 +1,10 @@ import { t, Trans } from "@lingui/macro"; import { Box } from "@mui/material"; import { extent } from "d3"; -import get from "lodash/get"; -import { useEffect, useMemo, useRef } from "react"; +import { useEffect, useRef } from "react"; import { getFieldComponentIri } from "@/charts"; -import { Checkbox, Select } from "@/components/form"; +import { Checkbox } from "@/components/form"; import { Loading } from "@/components/hint"; import { ControlSection, @@ -15,19 +14,12 @@ import { import { parseDate } from "@/configurator/components/ui-helpers"; import { ConfiguratorStateConfiguringChart } from "@/configurator/config-types"; import { EditorBrush } from "@/configurator/interactive-filters/editor-time-brush"; -import { - useInteractiveTimeRangeFiltersToggle, - useInteractiveTimeSliderFiltersSelect, -} from "@/configurator/interactive-filters/interactive-filters-config-state"; +import { useInteractiveTimeRangeFiltersToggle } from "@/configurator/interactive-filters/interactive-filters-config-state"; import { InteractiveFilterType } from "@/configurator/interactive-filters/interactive-filters-configurator"; import { useFormatFullDateAuto } from "@/formatters"; -import { TemporalDimension, useComponentsQuery } from "@/graphql/query-hooks"; +import { useComponentsQuery } from "@/graphql/query-hooks"; import { useLocale } from "@/locales/use-locale"; -import { FIELD_VALUE_NONE } from "../constants"; - -import { getTimeSliderFilterDimensions } from "./helpers"; - export const InteractiveFiltersOptions = ({ state, }: { @@ -70,19 +62,6 @@ export const InteractiveFiltersOptions = ({ ); - } else if (activeField === "timeSlider") { - return ( - - - - Time slider - - - - - - - ); } } @@ -190,79 +169,3 @@ const InteractiveTimeRangeFilterOptions = ({ return ; } }; - -const InteractiveTimeSliderFilterOptions = ({ - state: { chartConfig, dataSet, dataSource }, -}: { - state: ConfiguratorStateConfiguringChart; -}) => { - const locale = useLocale(); - const [{ data }] = useComponentsQuery({ - variables: { - iri: dataSet, - sourceType: dataSource.type, - sourceUrl: dataSource.url, - locale, - }, - }); - - const value = - get(chartConfig, "interactiveFiltersConfig.timeSlider.componentIri") || - FIELD_VALUE_NONE; - - if (data?.dataCubeByIri) { - const timeSliderDimensions = getTimeSliderFilterDimensions({ - chartConfig, - dataCubeByIri: data.dataCubeByIri, - }); - - return ( - - ); - } else { - return ; - } -}; - -const InteractiveTimeSliderFilterOptionsSelect = ({ - dimensions, - value, -}: { - dimensions: TemporalDimension[]; - value: string; -}) => { - const fieldProps = useInteractiveTimeSliderFiltersSelect(); - const options = useMemo(() => { - return [ - { - label: t({ - id: "controls.none", - message: "None", - }), - value: FIELD_VALUE_NONE, - isNoneValue: true, - }, - ...dimensions.map((d) => ({ - label: d.label, - value: d.iri, - })), - ]; - }, [dimensions]); - - return ( -