From efde099bb53f1ef7448b5d292624761c0923c288 Mon Sep 17 00:00:00 2001 From: Bartosz Prusinowski Date: Fri, 22 Nov 2024 14:35:56 +0100 Subject: [PATCH 01/10] feat: Add ConfidenceUpperBoundDimension and ConfidenceLowerBoundDimension --- app/components/data-download.tsx | 2 ++ app/components/metadata-panel.tsx | 8 ++++- app/config-types.ts | 2 ++ app/domain/data.ts | 51 +++++++++++++++++++++++++++++-- app/graphql/query-hooks.ts | 8 ++++- app/graphql/resolver-types.ts | 11 +++++-- app/graphql/resolvers/index.ts | 13 ++++++++ app/graphql/schema.graphql | 8 ++++- app/rdf/parse.ts | 12 +++++--- 9 files changed, 103 insertions(+), 12 deletions(-) diff --git a/app/components/data-download.tsx b/app/components/data-download.tsx index 6d7ae8bc6..e8e8eface 100644 --- a/app/components/data-download.tsx +++ b/app/components/data-download.tsx @@ -461,6 +461,8 @@ const getDimensionParsers = ( return [d.id, (d) => d]; case "NumericalMeasure": case "StandardErrorDimension": + case "ConfidenceUpperBoundDimension": + case "ConfidenceLowerBoundDimension": return [d.id, (d) => +d]; case "OrdinalMeasure": return d.isNumerical ? [d.id, (d) => +d] : [d.id, (d) => d]; diff --git a/app/components/metadata-panel.tsx b/app/components/metadata-panel.tsx index 79d5040da..a0fa0be3d 100644 --- a/app/components/metadata-panel.tsx +++ b/app/components/metadata-panel.tsx @@ -59,6 +59,8 @@ import { import { Component, DimensionValue, + isConfidenceLowerBoundDimension, + isConfidenceUpperBoundDimension, isJoinByComponent, isStandardErrorDimension, TemporalDimension, @@ -862,7 +864,11 @@ const ComponentValues = ({ component }: { component: Component }) => { ) as DimensionValue[]; }, [component]); - if (isStandardErrorDimension(component)) { + if ( + isStandardErrorDimension(component) || + isConfidenceUpperBoundDimension(component) || + isConfidenceLowerBoundDimension(component) + ) { return ; } diff --git a/app/config-types.ts b/app/config-types.ts index 8db0d62bb..865069813 100644 --- a/app/config-types.ts +++ b/app/config-types.ts @@ -16,6 +16,8 @@ const DimensionType = t.union([ t.literal("GeoCoordinatesDimension"), t.literal("GeoShapesDimension"), t.literal("StandardErrorDimension"), + t.literal("ConfidenceUpperBoundDimension"), + t.literal("ConfidenceLowerBoundDimension"), ]); export type DimensionType = t.TypeOf; diff --git a/app/domain/data.ts b/app/domain/data.ts index fff9a7d9f..e47fbaad6 100644 --- a/app/domain/data.ts +++ b/app/domain/data.ts @@ -180,6 +180,18 @@ const ComponentsRenderingConfig: { enableMultiFilter: false, enableSegment: false, }, + ConfidenceUpperBoundDimension: { + enableAnimation: false, + enableCustomSort: false, + enableMultiFilter: false, + enableSegment: false, + }, + ConfidenceLowerBoundDimension: { + enableAnimation: false, + enableCustomSort: false, + enableMultiFilter: false, + enableSegment: false, + }, }; export const ANIMATION_ENABLED_COMPONENTS = Object.entries( @@ -261,7 +273,9 @@ export type Dimension = | TemporalOrdinalDimension | GeoCoordinatesDimension | GeoShapesDimension - | StandardErrorDimension; + | StandardErrorDimension + | ConfidenceUpperBoundDimension + | ConfidenceLowerBoundDimension; export type DimensionType = Dimension["__typename"]; @@ -316,6 +330,14 @@ export type StandardErrorDimension = BaseDimension & { __typename: "StandardErrorDimension"; }; +export type ConfidenceUpperBoundDimension = BaseDimension & { + __typename: "ConfidenceUpperBoundDimension"; +}; + +export type ConfidenceLowerBoundDimension = BaseDimension & { + __typename: "ConfidenceLowerBoundDimension"; +}; + export type Measure = NumericalMeasure | OrdinalMeasure; type BaseMeasure = BaseComponent & { @@ -610,7 +632,15 @@ export const isTemporalDimensionWithTimeUnit = ( }; const isStandardErrorResolvedDimension = (dim: ResolvedDimension) => { - return dim.data?.related.some((x) => x.type === "StandardError"); + return dim.data?.related.some((r) => r.type === "StandardError"); +}; + +const isConfidenceLowerBoundResolvedDimension = (dim: ResolvedDimension) => { + return dim.data?.related.some((r) => r.type === "ConfidenceLowerBound"); +}; + +const isConfidenceUpperBoundResolvedDimension = (dim: ResolvedDimension) => { + return dim.data?.related.some((r) => r.type === "ConfidenceUpperBound"); }; export const isStandardErrorDimension = ( @@ -619,6 +649,18 @@ export const isStandardErrorDimension = ( return dim.__typename === "StandardErrorDimension"; }; +export const isConfidenceUpperBoundDimension = ( + dim: Component +): dim is ConfidenceUpperBoundDimension => { + return dim.__typename === "ConfidenceUpperBoundDimension"; +}; + +export const isConfidenceLowerBoundDimension = ( + dim: Component +): dim is ConfidenceLowerBoundDimension => { + return dim.__typename === "ConfidenceLowerBoundDimension"; +}; + export const findRelatedErrorDimension = ( dimIri: string, dimensions: Component[] @@ -632,8 +674,11 @@ export const shouldLoadMinMaxValues = (dim: ResolvedDimension) => { const { data: { isNumerical, scaleType, dataKind }, } = dim; + return ( (isNumerical && scaleType !== "Ordinal" && dataKind !== "Time") || - isStandardErrorResolvedDimension(dim) + isStandardErrorResolvedDimension(dim) || + isConfidenceUpperBoundResolvedDimension(dim) || + isConfidenceLowerBoundResolvedDimension(dim) ); }; diff --git a/app/graphql/query-hooks.ts b/app/graphql/query-hooks.ts index f3d9884cc..4c252be98 100644 --- a/app/graphql/query-hooks.ts +++ b/app/graphql/query-hooks.ts @@ -229,10 +229,16 @@ export type QueryDataCubeDimensionGeoShapesArgs = { export type RelatedDimension = { __typename: 'RelatedDimension'; - type: Scalars['String']; + type: RelatedDimensionType; id: Scalars['String']; }; +export enum RelatedDimensionType { + StandardError = 'StandardError', + ConfidenceUpperBound = 'ConfidenceUpperBound', + ConfidenceLowerBound = 'ConfidenceLowerBound' +} + export enum ScaleType { Ordinal = 'Ordinal', Nominal = 'Nominal', diff --git a/app/graphql/resolver-types.ts b/app/graphql/resolver-types.ts index 87a4aee32..44fa910f8 100644 --- a/app/graphql/resolver-types.ts +++ b/app/graphql/resolver-types.ts @@ -229,10 +229,16 @@ export type QueryDataCubeDimensionGeoShapesArgs = { export type RelatedDimension = { __typename?: 'RelatedDimension'; - type: Scalars['String']; + type: RelatedDimensionType; id: Scalars['String']; }; +export enum RelatedDimensionType { + StandardError = 'StandardError', + ConfidenceUpperBound = 'ConfidenceUpperBound', + ConfidenceLowerBound = 'ConfidenceLowerBound' +} + export enum ScaleType { Ordinal = 'Ordinal', Nominal = 'Nominal', @@ -379,6 +385,7 @@ export type ResolversTypes = ResolversObject<{ Query: ResolverTypeWrapper<{}>; RawObservation: ResolverTypeWrapper; RelatedDimension: ResolverTypeWrapper; + RelatedDimensionType: RelatedDimensionType; ScaleType: ScaleType; SearchCube: ResolverTypeWrapper; SearchCubeFilter: SearchCubeFilter; @@ -521,7 +528,7 @@ export interface RawObservationScalarConfig extends GraphQLScalarTypeConfig = ResolversObject<{ - type?: Resolver; + type?: Resolver; id?: Resolver; __isTypeOf?: IsTypeOfResolverFn; }>; diff --git a/app/graphql/resolvers/index.ts b/app/graphql/resolvers/index.ts index 32a364ede..b23bf9fb3 100644 --- a/app/graphql/resolvers/index.ts +++ b/app/graphql/resolvers/index.ts @@ -63,8 +63,21 @@ export const resolveDimensionType = ( timeUnit: ResolvedDimension["data"]["timeUnit"] | undefined, related: ResolvedDimension["data"]["related"] ): DimensionType => { + const relatedTypes = Array.from(new Set(related.map((d) => d.type))); + + if (relatedTypes.length > 1) { + console.warn( + `WARNING: dimension has more than 1 related type`, + relatedTypes + ); + } + if (related.some((d) => d.type === "StandardError")) { return "StandardErrorDimension"; + } else if (related.some((d) => d.type === "ConfidenceUpperBound")) { + return "ConfidenceUpperBoundDimension"; + } else if (related.some((d) => d.type === "ConfidenceLowerBound")) { + return "ConfidenceLowerBoundDimension"; } if (dataKind === "Time") { diff --git a/app/graphql/schema.graphql b/app/graphql/schema.graphql index 5527f52e2..468c15cf9 100644 --- a/app/graphql/schema.graphql +++ b/app/graphql/schema.graphql @@ -28,8 +28,14 @@ enum ScaleType { Ratio } +enum RelatedDimensionType { + StandardError + ConfidenceUpperBound + ConfidenceLowerBound +} + type RelatedDimension { - type: String! + type: RelatedDimensionType! id: String! } diff --git a/app/rdf/parse.ts b/app/rdf/parse.ts index 0ff91ab59..8ce727ae1 100644 --- a/app/rdf/parse.ts +++ b/app/rdf/parse.ts @@ -2,7 +2,11 @@ import { CubeDimension } from "rdf-cube-view-query"; import { Term } from "rdf-js"; import { truthy } from "@/domain/types"; -import { ScaleType, TimeUnit } from "@/graphql/query-hooks"; +import { + RelatedDimensionType, + ScaleType, + TimeUnit, +} from "@/graphql/query-hooks"; import { ResolvedDimension } from "@/graphql/shared-types"; import { ExtendedCube } from "@/rdf/extended-cube"; import { timeFormats, timeUnitFormats, timeUnits } from "@/rdf/mappings"; @@ -74,11 +78,11 @@ export const parseDimensionDatatype = (dim: CubeDimension) => { return { isLiteral, dataType, hasUndefinedValues }; }; -type RelationType = "StandardError"; - const sparqlRelationToVisualizeRelation = { "https://cube.link/relation/StandardError": "StandardError", -} as Record; + "https://cube.link/relation/ConfidenceUpperBound": "ConfidenceUpperBound", + "https://cube.link/relation/ConfidenceLowerBound": "ConfidenceLowerBound", +} as Record; export const parseRelatedDimensions = (dim: CubeDimension) => { const relatedDimensionNodes = dim.out(ns.cube`meta/dimensionRelation`); From 1312336c479aa07aa9a37a79a756fd8dcb81cffa Mon Sep 17 00:00:00 2001 From: Bartosz Prusinowski Date: Fri, 22 Nov 2024 14:57:17 +0100 Subject: [PATCH 02/10] feat: Use InfoIconTooltip --- .../components/chart-options-selector.tsx | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/app/configurator/components/chart-options-selector.tsx b/app/configurator/components/chart-options-selector.tsx index 0d6b746ee..f3a13ab87 100644 --- a/app/configurator/components/chart-options-selector.tsx +++ b/app/configurator/components/chart-options-selector.tsx @@ -29,6 +29,7 @@ import { SelectOption, SelectOptionGroup, } from "@/components/form"; +import { InfoIconTooltip } from "@/components/info-icon-tooltip"; import { MaybeTooltip } from "@/components/maybe-tooltip"; import { TooltipTitle } from "@/components/tooltip-utils"; import { GenericField } from "@/config-types"; @@ -439,20 +440,20 @@ const EncodingOptionsPanel = ({ label={t({ id: "controls.section.show-standard-error" })} sx={{ marginRight: 0 }} /> - - Show uncertainties extending from data points to represent - standard errors - + + Show uncertainties extending from data points to + represent standard errors + + } + /> } - > - - - - + /> )} {encoding.options?.useAbbreviations && ( From 9089fd06e95d69731af80f93e469a485892c1c3b Mon Sep 17 00:00:00 2001 From: Bartosz Prusinowski Date: Fri, 22 Nov 2024 15:24:28 +0100 Subject: [PATCH 03/10] fix: Include uncertainty in types --- app/config-types.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/app/config-types.ts b/app/config-types.ts index 865069813..3382dfc1a 100644 --- a/app/config-types.ts +++ b/app/config-types.ts @@ -285,10 +285,17 @@ const ColumnSegmentField = t.intersection([ ]); export type ColumnSegmentField = t.TypeOf; +const UncertaintyFieldExtension = t.partial({ + showStandardError: t.boolean, +}); +export type UncertaintyFieldExtension = t.TypeOf< + typeof UncertaintyFieldExtension +>; + const ColumnFields = t.intersection([ t.type({ x: t.intersection([GenericField, SortingField]), - y: GenericField, + y: t.intersection([GenericField, UncertaintyFieldExtension]), }), t.partial({ segment: ColumnSegmentField, @@ -315,7 +322,7 @@ export type LineSegmentField = t.TypeOf; const LineFields = t.intersection([ t.type({ x: GenericField, - y: GenericField, + y: t.intersection([GenericField, UncertaintyFieldExtension]), }), t.partial({ segment: LineSegmentField, From a3fc6e5e6ba655a85333bdd518457861641d3aea Mon Sep 17 00:00:00 2001 From: Bartosz Prusinowski Date: Fri, 22 Nov 2024 16:04:55 +0100 Subject: [PATCH 04/10] feat: Add a way to enable confidence intervals --- app/charts/chart-config-ui-options.ts | 5 ++ app/config-types.ts | 1 + .../components/chart-options-selector.tsx | 54 ++++++++++++++++++- app/locales/de/messages.po | 9 ++++ app/locales/en/messages.po | 9 ++++ app/locales/fr/messages.po | 8 +++ app/locales/it/messages.po | 9 ++++ 7 files changed, 93 insertions(+), 2 deletions(-) diff --git a/app/charts/chart-config-ui-options.ts b/app/charts/chart-config-ui-options.ts index 3122b1f3c..3ae078c9a 100644 --- a/app/charts/chart-config-ui-options.ts +++ b/app/charts/chart-config-ui-options.ts @@ -111,6 +111,9 @@ type EncodingOption = | { field: "showStandardError"; } + | { + field: "showConfidenceInterval"; + } | { field: "sorting"; } @@ -643,6 +646,7 @@ const chartConfigOptionsUISpec: ChartSpecs = { }, options: { showStandardError: {}, + showConfidenceInterval: {}, }, }, { @@ -776,6 +780,7 @@ const chartConfigOptionsUISpec: ChartSpecs = { filters: false, options: { showStandardError: {}, + showConfidenceInterval: {}, }, }, { diff --git a/app/config-types.ts b/app/config-types.ts index 3382dfc1a..7822e1af2 100644 --- a/app/config-types.ts +++ b/app/config-types.ts @@ -287,6 +287,7 @@ export type ColumnSegmentField = t.TypeOf; const UncertaintyFieldExtension = t.partial({ showStandardError: t.boolean, + showConfidenceInterval: t.boolean, }); export type UncertaintyFieldExtension = t.TypeOf< typeof UncertaintyFieldExtension diff --git a/app/configurator/components/chart-options-selector.tsx b/app/configurator/components/chart-options-selector.tsx index f3a13ab87..5172f32c8 100644 --- a/app/configurator/components/chart-options-selector.tsx +++ b/app/configurator/components/chart-options-selector.tsx @@ -389,13 +389,28 @@ const EncodingOptionsPanel = ({ }, [components, fields, encoding.field]); const hasStandardError = useMemo(() => { - return components.find((d) => + return !!components.find((d) => d.related?.some( (r) => r.type === "StandardError" && r.id === component?.id ) ); }, [components, component]); + const hasConfidenceInterval = useMemo(() => { + const upperBoundComponent = components.find((d) => + d.related?.some( + (r) => r.type === "ConfidenceUpperBound" && r.id === component?.id + ) + ); + const lowerBoundComponent = components.find((d) => + d.related?.some( + (r) => r.type === "ConfidenceLowerBound" && r.id === component?.id + ) + ); + + return !!upperBoundComponent && !!lowerBoundComponent; + }, [components, component]); + const hasColorPalette = !!encoding.options?.colorPalette; const hasSubOptions = @@ -436,7 +451,7 @@ const EncodingOptionsPanel = ({ @@ -456,6 +471,41 @@ const EncodingOptionsPanel = ({ /> )} + {encoding.options?.showConfidenceInterval && + hasConfidenceInterval && ( + + + + Show uncertainties extending from data points to + represent confidence intervals + + } + /> + } + /> + + )} {encoding.options?.useAbbreviations && ( Date: Mon, 25 Nov 2024 09:06:27 +0100 Subject: [PATCH 05/10] refactor: Standardize error formatting and prepare for confidence intervals support --- app/charts/column/columns-grouped-state.tsx | 16 +-- app/charts/column/columns-grouped.tsx | 13 +-- app/charts/column/columns-state.tsx | 15 +-- app/charts/column/columns.tsx | 13 +-- app/charts/line/lines-state.tsx | 14 +-- app/charts/line/lines.tsx | 13 +-- app/charts/shared/chart-state.ts | 105 ++++++++++++++++-- .../shared/interaction/tooltip-content.tsx | 4 +- app/charts/shared/rendering-utils.ts | 7 -- app/configurator/components/ui-helpers.ts | 56 ++++++---- 10 files changed, 158 insertions(+), 98 deletions(-) diff --git a/app/charts/column/columns-grouped-state.tsx b/app/charts/column/columns-grouped-state.tsx index dc6539d94..7c837ecc4 100644 --- a/app/charts/column/columns-grouped-state.tsx +++ b/app/charts/column/columns-grouped-state.tsx @@ -84,10 +84,8 @@ const useColumnsGroupedState = ( yMeasure, getY, getMinY, - showYStandardError, - yErrorMeasure, - getYError, getYErrorRange, + getFormattedYUncertainty, segmentDimension, segmentsByAbbreviationOrLabel, getSegment, @@ -398,14 +396,6 @@ const useColumnsGroupedState = ( topAnchor: !fields.segment, }); - const getError = (d: Observation) => { - if (!showYStandardError || !getYError || getYError(d) == null) { - return; - } - - return `${getYError(d)}${yErrorMeasure?.unit ?? ""}`; - }; - return { xAnchor: xAnchorRaw + (placement.x === "right" ? 0.5 : -0.5) * bw, yAnchor, @@ -414,7 +404,7 @@ const useColumnsGroupedState = ( datum: { label: fields.segment && getSegmentAbbreviationOrLabel(datum), value: yValueFormatter(getY(datum)), - error: getError(datum), + error: getFormattedYUncertainty(datum), color: colors(getSegment(datum)) as string, }, values: sortedTooltipValues.map((td) => ({ @@ -422,7 +412,7 @@ const useColumnsGroupedState = ( value: yMeasure.unit ? `${formatNumber(getY(td))} ${yMeasure.unit}` : formatNumber(getY(td)), - error: getError(td), + error: getFormattedYUncertainty(td), color: colors(getSegment(td)) as string, })), }; diff --git a/app/charts/column/columns-grouped.tsx b/app/charts/column/columns-grouped.tsx index a3b87609d..abbed48d7 100644 --- a/app/charts/column/columns-grouped.tsx +++ b/app/charts/column/columns-grouped.tsx @@ -8,7 +8,6 @@ import { import { useChartState } from "@/charts/shared/chart-state"; import { RenderWhiskerDatum, - filterWithoutErrors, renderContainer, renderWhiskers, } from "@/charts/shared/rendering-utils"; @@ -20,24 +19,24 @@ export const ErrorWhiskers = () => { xScale, xScaleIn, getYErrorRange, - getYError, + getYErrorPresent, yScale, getSegment, grouped, - showYStandardError, + showYUncertainty, } = useChartState() as GroupedColumnsState; const { margins, width, height } = bounds; const ref = useRef(null); const enableTransition = useTransitionStore((state) => state.enable); const transitionDuration = useTransitionStore((state) => state.duration); const renderData: RenderWhiskerDatum[] = useMemo(() => { - if (!getYErrorRange || !showYStandardError) { + if (!getYErrorRange || !showYUncertainty) { return []; } const bandwidth = xScaleIn.bandwidth(); return grouped - .filter((d) => d[1].some(filterWithoutErrors(getYError))) + .filter((d) => d[1].some(getYErrorPresent)) .flatMap(([segment, observations]) => observations.map((d) => { const x0 = xScaleIn(getSegment(d)) as number; @@ -56,9 +55,9 @@ export const ErrorWhiskers = () => { }, [ getSegment, getYErrorRange, - getYError, + getYErrorPresent, grouped, - showYStandardError, + showYUncertainty, xScale, xScaleIn, yScale, diff --git a/app/charts/column/columns-state.tsx b/app/charts/column/columns-state.tsx index 30f96112a..97bc98aeb 100644 --- a/app/charts/column/columns-state.tsx +++ b/app/charts/column/columns-state.tsx @@ -75,10 +75,8 @@ const useColumnsState = ( yMeasure, getY, getMinY, - showYStandardError, - yErrorMeasure, - getYError, getYErrorRange, + getFormattedYUncertainty, } = variables; const { chartData, scalesData, timeRangeData, paddingData, allData } = data; const { fields, interactiveFiltersConfig } = chartConfig; @@ -245,15 +243,6 @@ const useColumnsState = ( formatters[yMeasure.id] ?? formatNumber, yMeasure.unit ); - - const getError = (d: Observation) => { - if (!showYStandardError || !getYError || getYError(d) === null) { - return; - } - - return `${getYError(d)}${yErrorMeasure?.unit ?? ""}`; - }; - const y = getY(d); return { @@ -264,7 +253,7 @@ const useColumnsState = ( datum: { label: undefined, value: y !== null && isNaN(y) ? "-" : `${yValueFormatter(getY(d))}`, - error: getError(d), + error: getFormattedYUncertainty(d), color: "", }, values: undefined, diff --git a/app/charts/column/columns.tsx b/app/charts/column/columns.tsx index cfcc9a256..ed51e6f0e 100644 --- a/app/charts/column/columns.tsx +++ b/app/charts/column/columns.tsx @@ -9,7 +9,6 @@ import { import { useChartState } from "@/charts/shared/chart-state"; import { RenderWhiskerDatum, - filterWithoutErrors, renderContainer, renderWhiskers, } from "@/charts/shared/rendering-utils"; @@ -19,12 +18,12 @@ import { useTheme } from "@/themes"; export const ErrorWhiskers = () => { const { getX, - getYError, + getYErrorPresent, getYErrorRange, chartData, yScale, xScale, - showYStandardError, + showYUncertainty, bounds, } = useChartState() as ColumnsState; const { margins, width, height } = bounds; @@ -32,12 +31,12 @@ export const ErrorWhiskers = () => { const enableTransition = useTransitionStore((state) => state.enable); const transitionDuration = useTransitionStore((state) => state.duration); const renderData: RenderWhiskerDatum[] = useMemo(() => { - if (!getYErrorRange || !showYStandardError) { + if (!getYErrorRange || !showYUncertainty) { return []; } const bandwidth = xScale.bandwidth(); - return chartData.filter(filterWithoutErrors(getYError)).map((d, i) => { + return chartData.filter(getYErrorPresent).map((d, i) => { const x0 = xScale(getX(d)) as number; const barWidth = Math.min(bandwidth, 15); const [y1, y2] = getYErrorRange(d); @@ -53,9 +52,9 @@ export const ErrorWhiskers = () => { }, [ chartData, getX, - getYError, + getYErrorPresent, getYErrorRange, - showYStandardError, + showYUncertainty, xScale, yScale, width, diff --git a/app/charts/line/lines-state.tsx b/app/charts/line/lines-state.tsx index c6d73aa66..24f219675 100644 --- a/app/charts/line/lines-state.tsx +++ b/app/charts/line/lines-state.tsx @@ -80,10 +80,8 @@ const useLinesState = ( getXAsString, yMeasure, getY, - showYStandardError, - getYError, getYErrorRange, - yErrorMeasure, + getFormattedYUncertainty, getMinY, segmentDimension, segmentsByAbbreviationOrLabel, @@ -279,14 +277,6 @@ const useLinesState = ( yMeasure.unit ); - const getError = (d: Observation) => { - if (!showYStandardError || !getYError || getYError(d) === null) { - return; - } - - return `${getYError(d)}${yErrorMeasure?.unit ?? ""}`; - }; - return { xAnchor, yAnchor, @@ -295,7 +285,7 @@ const useLinesState = ( datum: { label: fields.segment && getSegmentAbbreviationOrLabel(datum), value: yValueFormatter(getY(datum)), - error: getError(datum), + error: getFormattedYUncertainty(datum), color: colors(getSegment(datum)) as string, }, values: sortedTooltipValues.map((td) => ({ diff --git a/app/charts/line/lines.tsx b/app/charts/line/lines.tsx index c0a63bd1f..ea4c22078 100644 --- a/app/charts/line/lines.tsx +++ b/app/charts/line/lines.tsx @@ -5,7 +5,6 @@ import { LinesState } from "@/charts/line/lines-state"; import { useChartState } from "@/charts/shared/chart-state"; import { RenderWhiskerDatum, - filterWithoutErrors, renderContainer, renderWhiskers, } from "@/charts/shared/rendering-utils"; @@ -15,12 +14,12 @@ import { useTransitionStore } from "@/stores/transition"; export const ErrorWhiskers = () => { const { getX, - getYError, + getYErrorPresent, getYErrorRange, chartData, yScale, xScale, - showYStandardError, + showYUncertainty, colors, getSegment, bounds, @@ -30,11 +29,11 @@ export const ErrorWhiskers = () => { const enableTransition = useTransitionStore((state) => state.enable); const transitionDuration = useTransitionStore((state) => state.duration); const renderData: RenderWhiskerDatum[] = useMemo(() => { - if (!getYErrorRange || !showYStandardError) { + if (!getYErrorRange || !showYUncertainty) { return []; } - return chartData.filter(filterWithoutErrors(getYError)).map((d, i) => { + return chartData.filter(getYErrorPresent).map((d, i) => { const x0 = xScale(getX(d)) as number; const segment = getSegment(d); const barWidth = 15; @@ -54,9 +53,9 @@ export const ErrorWhiskers = () => { colors, getSegment, getX, - getYError, + getYErrorPresent, getYErrorRange, - showYStandardError, + showYUncertainty, xScale, yScale, ]); diff --git a/app/charts/shared/chart-state.ts b/app/charts/shared/chart-state.ts index c116e74ed..34fc089bf 100644 --- a/app/charts/shared/chart-state.ts +++ b/app/charts/shared/chart-state.ts @@ -42,7 +42,6 @@ import { useErrorVariable, } from "@/configurator/components/ui-helpers"; import { - Component, Dimension, DimensionValue, GeoCoordinatesDimension, @@ -50,7 +49,6 @@ import { Measure, NumericalMeasure, Observation, - ObservationValue, TemporalDimension, TemporalEntityDimension, isNumericalMeasure, @@ -58,6 +56,7 @@ import { isTemporalEntityDimension, } from "@/domain/data"; import { Has } from "@/domain/types"; +import { RelatedDimensionType } from "@/graphql/query-hooks"; import { ScaleType, TimeUnit } from "@/graphql/resolver-types"; import { useChartInteractiveFilters, @@ -342,10 +341,10 @@ export const useNumericalYVariables = ( }; export type NumericalYErrorVariables = { - showYStandardError: boolean; - yErrorMeasure: Component | undefined; - getYError: ((d: Observation) => ObservationValue) | null; + showYUncertainty: boolean; + getYErrorPresent: (d: Observation) => boolean; getYErrorRange: null | ((d: Observation) => [number, number]); + getFormattedYUncertainty: (d: Observation) => string | undefined; }; export const useNumericalYErrorVariables = ( @@ -361,18 +360,102 @@ export const useNumericalYErrorVariables = ( } ): NumericalYErrorVariables => { const showYStandardError = get(y, ["showStandardError"], true); - const yErrorMeasure = useErrorMeasure(y.componentId, { + const yStandardErrorMeasure = useErrorMeasure(y.componentId, { dimensions, measures, + type: RelatedDimensionType.StandardError, }); - const getYErrorRange = useErrorRange(yErrorMeasure, numericalYVariables.getY); - const getYError = useErrorVariable(yErrorMeasure); + const getYStandardError = useErrorVariable(yStandardErrorMeasure); + + const showYConfidenceInterval = get(y, ["showConfidenceInterval"], true); + const yConfidenceIntervalUpperMeasure = useErrorMeasure(y.componentId, { + dimensions, + measures, + type: RelatedDimensionType.ConfidenceUpperBound, + }); + const getYConfidenceIntervalUpper = useErrorVariable( + yConfidenceIntervalUpperMeasure + ); + const yConfidenceIntervalLowerMeasure = useErrorMeasure(y.componentId, { + dimensions, + measures, + type: RelatedDimensionType.ConfidenceLowerBound, + }); + const getYConfidenceIntervalLower = useErrorVariable( + yConfidenceIntervalLowerMeasure + ); + + const getYErrorPresent = useCallback( + (d: Observation) => { + return ( + (showYStandardError && getYStandardError?.(d) !== null) || + (showYConfidenceInterval && + getYConfidenceIntervalUpper?.(d) !== null && + getYConfidenceIntervalLower?.(d) !== null) + ); + }, + [ + showYStandardError, + getYStandardError, + showYConfidenceInterval, + getYConfidenceIntervalUpper, + getYConfidenceIntervalLower, + ] + ); + const getYErrorRange = useErrorRange( + showYStandardError && yStandardErrorMeasure + ? yStandardErrorMeasure + : yConfidenceIntervalUpperMeasure, + showYStandardError && yStandardErrorMeasure + ? yStandardErrorMeasure + : yConfidenceIntervalLowerMeasure, + numericalYVariables.getY + ); + const getFormattedYUncertainty = useCallback( + (d: Observation) => { + if ( + showYStandardError && + getYStandardError && + getYStandardError(d) !== null + ) { + const sd = getYStandardError(d); + const unit = yStandardErrorMeasure?.unit ?? ""; + return ` ± ${sd}${unit}`; + } + + if ( + showYConfidenceInterval && + getYConfidenceIntervalUpper && + getYConfidenceIntervalLower && + getYConfidenceIntervalUpper(d) !== null && + getYConfidenceIntervalLower(d) !== null + ) { + const cil = getYConfidenceIntervalLower(d); + const ciu = getYConfidenceIntervalUpper(d); + const unit = yConfidenceIntervalUpperMeasure?.unit ?? ""; + return ` -${cil}${unit}, +${ciu}${unit}`; + } + }, + [ + showYStandardError, + getYStandardError, + showYConfidenceInterval, + getYConfidenceIntervalUpper, + getYConfidenceIntervalLower, + yStandardErrorMeasure?.unit, + yConfidenceIntervalUpperMeasure?.unit, + ] + ); return { - showYStandardError, - yErrorMeasure, - getYError, + showYUncertainty: + (showYStandardError && !!yStandardErrorMeasure) || + (showYConfidenceInterval && + !!yConfidenceIntervalUpperMeasure && + !!yConfidenceIntervalLowerMeasure), + getYErrorPresent, getYErrorRange, + getFormattedYUncertainty, }; }; diff --git a/app/charts/shared/interaction/tooltip-content.tsx b/app/charts/shared/interaction/tooltip-content.tsx index dd4fc3c89..ac31f967b 100644 --- a/app/charts/shared/interaction/tooltip-content.tsx +++ b/app/charts/shared/interaction/tooltip-content.tsx @@ -34,7 +34,7 @@ export const TooltipSingle = ({ {yValue && ( {yValue} - {yError ? <> ± {yError} : null} + {yError ?? null} )} @@ -62,7 +62,7 @@ export const TooltipMultiple = ({ {segmentValues.map((d, i) => ( ObservationValue) | null -) => { - return (d: Observation): boolean => !!getError?.(d); -}; diff --git a/app/configurator/components/ui-helpers.ts b/app/configurator/components/ui-helpers.ts index 79ee63c3e..273cc7af3 100644 --- a/app/configurator/components/ui-helpers.ts +++ b/app/configurator/components/ui-helpers.ts @@ -21,7 +21,7 @@ import { TemporalDimension, TemporalEntityDimension, } from "@/domain/data"; -import { TimeUnit } from "@/graphql/query-hooks"; +import { RelatedDimensionType, TimeUnit } from "@/graphql/query-hooks"; import { IconName } from "@/icons"; import { getTimeInterval } from "@/intervals"; import { getPalette } from "@/palettes"; @@ -107,13 +107,17 @@ export const getTimeIntervalFormattedSelectOptions = ({ }; export const getErrorMeasure = ( - { dimensions, measures }: Pick, + { + dimensions, + measures, + type, + }: Pick & { + type: RelatedDimensionType; + }, valueIri: string ) => { return [...dimensions, ...measures].find((m) => { - return m.related?.some( - (r) => r.type === "StandardError" && r.id === valueIri - ); + return m.related?.some((r) => r.type === type && r.id === valueIri); }); }; @@ -122,14 +126,16 @@ export const useErrorMeasure = ( { dimensions, measures, + type, }: { dimensions: Dimension[]; measures: Measure[]; + type: RelatedDimensionType; } ) => { return useMemo(() => { - return getErrorMeasure({ dimensions, measures }, componentId); - }, [componentId, dimensions, measures]); + return getErrorMeasure({ dimensions, measures, type }, componentId); + }, [componentId, dimensions, measures, type]); }; export const useErrorVariable = (errorMeasure?: Component) => { @@ -143,26 +149,38 @@ export const useErrorVariable = (errorMeasure?: Component) => { }; export const useErrorRange = ( - errorMeasure: Component | undefined, + upperErrorMeasure: Component | undefined, + lowerErrorMeasure: Component | undefined, valueGetter: (d: Observation) => number | null ) => { return useMemo(() => { - return errorMeasure + return upperErrorMeasure && lowerErrorMeasure ? (d: Observation) => { const v = valueGetter(d) as number; - const errorIri = errorMeasure.id; - let error = - d[errorIri] !== null ? parseFloat(d[errorIri] as string) : null; - if (errorMeasure.unit === "%" && error !== null) { - error = (error * v) / 100; + const upperId = upperErrorMeasure.id; + let upperError = + d[upperId] !== null ? parseFloat(d[upperId] as string) : null; + + if (upperErrorMeasure.unit === "%" && upperError !== null) { + upperError = (upperError * v) / 100; } - return (error === null ? [v, v] : [v - error, v + error]) as [ - number, - number, - ]; + + const lowerId = lowerErrorMeasure.id; + let lowerError = + d[lowerId] !== null ? parseFloat(d[lowerId] as string) : null; + + if (lowerErrorMeasure.unit === "%" && lowerError !== null) { + lowerError = (lowerError * v) / 100; + } + + return ( + upperError === null || lowerError === null + ? [v, v] + : [v - lowerError, v + upperError] + ) as [number, number]; } : null; - }, [errorMeasure, valueGetter]); + }, [lowerErrorMeasure, upperErrorMeasure, valueGetter]); }; export const getIconName = (name: string): IconName => { From 6b11249ef7a5aa798a580af48660502bc65651a8 Mon Sep 17 00:00:00 2001 From: Bartosz Prusinowski Date: Mon, 25 Nov 2024 10:30:58 +0100 Subject: [PATCH 06/10] refactor: Only require what's actually needed --- app/charts/column/columns-grouped-state-props.ts | 2 +- app/charts/column/columns-state-props.ts | 2 +- app/charts/line/lines-state-props.ts | 2 +- app/charts/shared/chart-state.ts | 6 +++--- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/app/charts/column/columns-grouped-state-props.ts b/app/charts/column/columns-grouped-state-props.ts index c14c0f1cb..11642228c 100644 --- a/app/charts/column/columns-grouped-state-props.ts +++ b/app/charts/column/columns-grouped-state-props.ts @@ -62,7 +62,7 @@ export const useColumnsGroupedStateVariables = ( measuresById, }); const numericalYErrorVariables = useNumericalYErrorVariables(y, { - numericalYVariables, + getValue: numericalYVariables.getY, dimensions, measures, }); diff --git a/app/charts/column/columns-state-props.ts b/app/charts/column/columns-state-props.ts index d20e344e4..82e8f7617 100644 --- a/app/charts/column/columns-state-props.ts +++ b/app/charts/column/columns-state-props.ts @@ -57,7 +57,7 @@ export const useColumnsStateVariables = ( measuresById, }); const numericalYErrorVariables = useNumericalYErrorVariables(y, { - numericalYVariables, + getValue: numericalYVariables.getY, dimensions, measures, }); diff --git a/app/charts/line/lines-state-props.ts b/app/charts/line/lines-state-props.ts index 1fb218d6e..d16e71da6 100644 --- a/app/charts/line/lines-state-props.ts +++ b/app/charts/line/lines-state-props.ts @@ -53,7 +53,7 @@ export const useLinesStateVariables = ( measuresById, }); const numericalYErrorVariables = useNumericalYErrorVariables(y, { - numericalYVariables, + getValue: numericalYVariables.getY, dimensions, measures, }); diff --git a/app/charts/shared/chart-state.ts b/app/charts/shared/chart-state.ts index 34fc089bf..6cef8872a 100644 --- a/app/charts/shared/chart-state.ts +++ b/app/charts/shared/chart-state.ts @@ -350,11 +350,11 @@ export type NumericalYErrorVariables = { export const useNumericalYErrorVariables = ( y: GenericField, { - numericalYVariables, + getValue, dimensions, measures, }: { - numericalYVariables: NumericalYVariables; + getValue: NumericalYVariables["getY"]; dimensions: Dimension[]; measures: Measure[]; } @@ -409,7 +409,7 @@ export const useNumericalYErrorVariables = ( showYStandardError && yStandardErrorMeasure ? yStandardErrorMeasure : yConfidenceIntervalLowerMeasure, - numericalYVariables.getY + getValue ); const getFormattedYUncertainty = useCallback( (d: Observation) => { From 7665368fc3627c5e3d2e58a34a5ee4569d665b20 Mon Sep 17 00:00:00 2001 From: Bartosz Prusinowski Date: Mon, 25 Nov 2024 10:34:36 +0100 Subject: [PATCH 07/10] fix: Showing symbol layer error in tooltip --- app/charts/map/map-tooltip.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/charts/map/map-tooltip.tsx b/app/charts/map/map-tooltip.tsx index 7a07a2397..a38364727 100644 --- a/app/charts/map/map-tooltip.tsx +++ b/app/charts/map/map-tooltip.tsx @@ -264,7 +264,7 @@ export const MapTooltip = () => { color: "#000", })} value={symbolTooltipState.value} - error={symbolTooltipState.colors.error} + error={symbolTooltipState.error} /> )} From 3d8a8624ab86cbefd517d225112cccb4de95d4b9 Mon Sep 17 00:00:00 2001 From: Bartosz Prusinowski Date: Mon, 25 Nov 2024 10:59:37 +0100 Subject: [PATCH 08/10] feat: Use shared error logic in maps --- app/charts/map/map-state.tsx | 327 +++++++++++++++++---------------- app/charts/map/map-tooltip.tsx | 36 ++-- app/domain/data.ts | 9 - 3 files changed, 189 insertions(+), 183 deletions(-) diff --git a/app/charts/map/map-state.tsx b/app/charts/map/map-state.tsx index 5945e5827..06edc915e 100644 --- a/app/charts/map/map-state.tsx +++ b/app/charts/map/map-state.tsx @@ -20,7 +20,7 @@ import { } from "geojson"; import keyBy from "lodash/keyBy"; import mapValues from "lodash/mapValues"; -import { useMemo } from "react"; +import { useCallback, useMemo } from "react"; import { ckmeans } from "simple-statistics"; import { ChartMapProps } from "@/charts/map/chart-map"; @@ -36,35 +36,29 @@ import { useOptionalNumericVariable, useStringVariable, } from "@/charts/shared/chart-helpers"; -import { ChartContext, CommonChartState } from "@/charts/shared/chart-state"; +import { + ChartContext, + CommonChartState, + useNumericalYErrorVariables, +} from "@/charts/shared/chart-state"; import { colorToRgbArray } from "@/charts/shared/colors"; import { InteractionProvider } from "@/charts/shared/use-interaction"; import { useSize } from "@/charts/shared/use-size"; import { BBox, - CategoricalColorField, ColorScaleInterpolationType, - FixedColorField, MapSymbolLayer, NumericalColorField, } from "@/config-types"; -import { - getErrorMeasure, - useErrorMeasure, - useErrorVariable, -} from "@/configurator/components/ui-helpers"; import { Component, Dimension, GeoData, Measure, Observation, - ObservationValue, - findRelatedErrorDimension, isGeoShapesDimension, } from "@/domain/data"; import { truthy } from "@/domain/types"; -import { formatNumberWithUnit, useFormatNumber } from "@/formatters"; import { getColorInterpolator } from "@/palettes"; export type MapState = CommonChartState & @@ -92,8 +86,7 @@ export type MapState = CommonChartState & measureLabel: string; getLabel: (d: Observation) => string; getValue: (d: Observation) => number | null; - errorDimension?: Component; - getFormattedError: null | ((d: Observation) => string); + getFormattedError: null | ((d: Observation) => string | undefined); radiusScale: ScalePower; colors: SymbolLayerColors; } @@ -129,7 +122,7 @@ const useMapState = ( const preparedAreaLayerState: MapState["areaLayer"] = useMemo(() => { if (areaLayer?.componentId === undefined) { - return undefined; + return; } return { @@ -157,7 +150,7 @@ const useMapState = ( }); const radiusScale = useMemo(() => { - // Measure dimension is undefined. Can be useful when the user want to + // Measure dimension is undefined. Can be useful when the user wants to // encode only the color of symbols, and the size is irrelevant. if (symbolLayerState.dataDomain[1] === undefined) { return scaleSqrt().range([0, 12]).unknown(12); @@ -170,7 +163,7 @@ const useMapState = ( const preparedSymbolLayerState: MapState["symbolLayer"] = useMemo(() => { if (symbolLayer?.componentId === undefined) { - return undefined; + return; } return { @@ -205,12 +198,12 @@ const useMapState = ( areaLayer?.componentId !== undefined ? filterFeatureCollection( features.areaLayer?.shapes, - (f) => f?.properties?.observation !== undefined + (f) => !!f?.properties?.observation ) : undefined, symbolLayer?.componentId !== undefined ? features.symbolLayer?.points.filter( - (p) => p?.properties?.observation !== undefined + (p) => !!p?.properties?.observation ) : undefined ); @@ -317,113 +310,148 @@ const getNumericalColorScale = ({ } }; -const getFixedColors = (color: FixedColorField) => { - const c = colorToRgbArray(color.value, color.opacity * 2.55); - return { type: "fixed" as const, getColor: (_: Observation) => c }; +const useFixedColors = (colorSpec: MapSymbolLayer["color"] | undefined) => { + return useMemo(() => { + if (colorSpec?.type !== "fixed") { + return; + } + + const color = colorToRgbArray(colorSpec.value, colorSpec.opacity * 2.55); + + return { + type: "fixed" as const, + getColor: () => color, + }; + }, [colorSpec]); }; -const getCategoricalColors = ( - color: CategoricalColorField, - dimensions: Dimension[], - measures: Measure[] +const useCategoricalColors = ( + colorSpec: MapSymbolLayer["color"] | undefined, + { + dimensions, + measures, + }: { + dimensions: Dimension[]; + measures: Measure[]; + } ) => { - const component = [...dimensions, ...measures].find( - (d) => d.id === color.componentId - ) as Component; - const valuesByLabel = keyBy(component.values, (d) => d.label); - const valuesByAbbreviationOrLabel = keyBy( - component.values, - color.useAbbreviations ? (d) => d.alternateName ?? d.label : (d) => d.label - ); - const domain: string[] = component.values.map((d) => `${d.value}`) || []; - const rgbColorMapping = mapValues(color.colorMapping, (c) => - colorToRgbArray(c, color.opacity * 2.55) - ); - const getDimensionValue = (d: Observation) => { - const abbreviationOrLabel = d[color.componentId] as string; + return useMemo(() => { + if (colorSpec?.type !== "categorical") { + return; + } - return ( - valuesByAbbreviationOrLabel[abbreviationOrLabel] ?? - valuesByLabel[abbreviationOrLabel] + const component = [...dimensions, ...measures].find( + (d) => d.id === colorSpec.componentId + ) as Component; + const valuesByLabel = keyBy(component.values, (d) => d.label); + const valuesByAbbreviationOrLabel = keyBy( + component.values, + colorSpec.useAbbreviations + ? (d) => d.alternateName ?? d.label + : (d) => d.label ); - }; + const domain = component.values.map((d) => `${d.value}`) ?? []; + const rgbColorMapping = mapValues(colorSpec.colorMapping, (c) => + colorToRgbArray(c, colorSpec.opacity * 2.55) + ); + const getDimensionValue = (d: Observation) => { + const abbreviationOrLabel = d[colorSpec.componentId] as string; - return { - type: "categorical" as const, - palette: color.palette, - component, - domain, - getValue: color.useAbbreviations - ? (d: Observation) => { - const v = getDimensionValue(d); - - return v.alternateName || v.label; - } - : (d: Observation) => { - return getDimensionValue(d).label; - }, - getColor: (d: Observation) => { - const value = getDimensionValue(d); - return rgbColorMapping[value.value]; - }, - useAbbreviations: color.useAbbreviations ?? false, - }; + return ( + valuesByAbbreviationOrLabel[abbreviationOrLabel] ?? + valuesByLabel[abbreviationOrLabel] + ); + }; + + return { + type: "categorical" as const, + palette: colorSpec.palette, + component, + domain, + getValue: colorSpec.useAbbreviations + ? (d: Observation) => { + const v = getDimensionValue(d); + return v.alternateName || v.label; + } + : (d: Observation) => { + return getDimensionValue(d).label; + }, + getColor: (d: Observation) => { + const value = getDimensionValue(d); + return rgbColorMapping[value.value]; + }, + useAbbreviations: colorSpec.useAbbreviations ?? false, + }; + }, [colorSpec, dimensions, measures]); }; -const getNumericalColors = ( - color: NumericalColorField, - data: Observation[], - dimensions: Dimension[], - measures: Measure[], - { formatNumber }: { formatNumber: ReturnType } -) => { - const component = measures.find((d) => d.id === color.componentId) as Measure; - const domain = extent( - data.map((d) => d[color.componentId]), - (d) => +d! - ) as [number, number]; - const getValue = (d: Observation) => - d[color.componentId] !== null ? Number(d[color.componentId]) : null; - const colorScale = getNumericalColorScale({ - color, - getValue, +const useNumericalColors = ( + colorSpec: MapSymbolLayer["color"] | undefined, + { data, - dataDomain: domain, - }); - const errorDimension = findRelatedErrorDimension( - color.componentId, - dimensions - ); - const errorMeasure = getErrorMeasure( - { dimensions, measures }, - color.componentId + dimensions, + measures, + }: { + data: Observation[]; + dimensions: Dimension[]; + measures: Measure[]; + } +) => { + const componentId = + colorSpec?.type === "numerical" ? colorSpec.componentId : ""; + const getValue = useCallback( + (d: Observation) => + componentId + ? d[componentId] !== null + ? Number(d[componentId]) + : null + : null, + [componentId] ); - const getError = errorMeasure ? (d: Observation) => d[errorMeasure.id] : null; - const getFormattedError = makeErrorFormatter( - getError, - formatNumber, - errorDimension?.unit + const { getFormattedYUncertainty } = useNumericalYErrorVariables( + { componentId }, + { getValue, dimensions, measures } ); - return { - type: "continuous" as const, - palette: color.palette, - component, - scale: colorScale, - interpolationType: color.interpolationType, - getValue, - getColor: (d: Observation) => { - const c = colorScale(+d[color.componentId]!); + return useMemo(() => { + if (colorSpec?.type !== "numerical") { + return; + } - if (c) { - return colorToRgbArray(c, color.opacity * 2.55); - } + const component = measures.find( + (d) => d.id === colorSpec.componentId + ) as Measure; + const domain = extent( + data.map((d) => d[colorSpec.componentId]), + (d) => +d! + ) as [number, number]; + const colorScale = getNumericalColorScale({ + color: colorSpec, + getValue, + data, + dataDomain: domain, + }); - return [0, 0, 0, 255 * 0.1]; - }, - getFormattedError, - domain, - }; + return { + type: "continuous" as const, + palette: colorSpec.palette, + component, + scale: colorScale, + interpolationType: colorSpec.interpolationType, + getValue, + getColor: (d: Observation) => { + const c = colorScale(+d[colorSpec.componentId]!); + + if (c) { + return colorToRgbArray(c, colorSpec.opacity * 2.55); + } + + return [0, 0, 0, 255 * 0.1]; + }, + getFormattedError: getFormattedYUncertainty, + domain, + }; + }, [colorSpec, data, getFormattedYUncertainty, getValue, measures]); }; const useColors = ({ @@ -437,38 +465,31 @@ const useColors = ({ dimensions: Dimension[]; measures: Measure[]; }) => { - const formatNumber = useFormatNumber(); - - return useMemo(() => { - if (!color) { - return undefined; - } + const fixedColors = useFixedColors(color); + const categoricalColors = useCategoricalColors(color, { + dimensions, + measures, + }); + const numericalColors = useNumericalColors(color, { + data, + dimensions, + measures, + }); - switch (color.type) { - case "fixed": - return getFixedColors(color); - case "categorical": - return getCategoricalColors(color, dimensions, measures); - case "numerical": - return getNumericalColors(color, data, dimensions, measures, { - formatNumber, - }); - } - }, [color, data, dimensions, measures, formatNumber]); -}; + if (!color) { + return undefined; + } -const makeErrorFormatter = ( - getter: ((d: Observation) => ObservationValue) | null, - formatter: (n: number) => string, - unit?: string | null -) => { - if (!getter) { - return null; - } else { - return (d: Observation) => { - const error = getter(d); - return formatNumberWithUnit(error as number, formatter, unit); - }; + switch (color.type) { + case "fixed": + return fixedColors; + case "categorical": + return categoricalColors; + case "numerical": + return numericalColors; + default: + const _exhaustiveCheck: never = color; + return _exhaustiveCheck; } }; @@ -528,24 +549,15 @@ const useLayerState = ({ dimensions: Dimension[]; measures: Measure[]; }) => { - const formatNumber = useFormatNumber(); - const getLabel = useStringVariable(componentId); const getValue = useOptionalNumericVariable(measureId); - const measureDimension = measures.find((d) => d.id === measureId); - const errorDimension = findRelatedErrorDimension(measureId, dimensions); - const errorMeasure = useErrorMeasure(measureId, { - dimensions, - measures, - }); - const getError = useErrorVariable(errorMeasure); - const getFormattedError = makeErrorFormatter( - getError, - formatNumber, - errorDimension?.unit - ); + const { showYUncertainty, getFormattedYUncertainty } = + useNumericalYErrorVariables( + { componentId: measureId }, + { getValue, dimensions, measures } + ); const preparedData = usePreparedData({ geoDimensionId: componentId, @@ -568,8 +580,7 @@ const useLayerState = ({ measureLabel: measureDimension?.label || "", getLabel, getValue, - errorDimension, - getFormattedError, + getFormattedError: showYUncertainty ? getFormattedYUncertainty : null, }; }; diff --git a/app/charts/map/map-tooltip.tsx b/app/charts/map/map-tooltip.tsx index a38364727..c6465d4e0 100644 --- a/app/charts/map/map-tooltip.tsx +++ b/app/charts/map/map-tooltip.tsx @@ -53,7 +53,7 @@ export const MapTooltip = () => { useChartState() as MapState; const formatNumber = useFormatNumber(); - const { getFormattedError: formatSymbolError } = symbolLayer || {}; + const { getFormattedError: formatSymbolError } = symbolLayer ?? {}; const formatters = useChartFormatters({ dimensions: [ areaLayer?.colors.type === "continuous" @@ -75,10 +75,8 @@ export const MapTooltip = () => { const { colors } = areaLayer; const value = colors.getValue(obs); if (value !== null) { - const error = - colors.type === "continuous" && colors.getFormattedError - ? ` ± ${colors.getFormattedError(obs)}` - : null; + const formattedError = + colors.type === "continuous" ? colors.getFormattedError?.(obs) : null; const show = identicalLayerComponentIds || hoverObjectType === "area"; const color = rgbArrayToHex(colors.getColor(obs)); const textColor = getTooltipTextColor(color); @@ -94,7 +92,7 @@ export const MapTooltip = () => { return { show, value: formattedValue, - error, + error: formattedError, componentId: colors.component.id, label: colors.component.label, color, @@ -113,11 +111,13 @@ export const MapTooltip = () => { const symbolTooltipState = useMemo(() => { const obs = interaction.d; + if (symbolLayer && obs) { const { colors } = symbolLayer; const value = symbolLayer.getValue(obs); + if (value !== null) { - const error = formatSymbolError ? ` ± ${formatSymbolError(obs)}` : null; + const formattedError = formatSymbolError?.(obs); const show = identicalLayerComponentIds || hoverObjectType === "symbol"; const color = rgbArrayToHex(colors.getColor(obs)); const textColor = getTooltipTextColor(color); @@ -153,7 +153,7 @@ export const MapTooltip = () => { }; } else { const rawValue = obs[colors.component.id] as number; - const rawError = colors.getFormattedError?.(obs); + const formattedError = colors.getFormattedError?.(obs); preparedColors = { type: "continuous", component: colors.component, @@ -162,7 +162,7 @@ export const MapTooltip = () => { formatNumber, colors.component.unit ), - error: rawError ? ` ± ${rawError}` : null, + error: formattedError, color, textColor, sameAsValue: @@ -172,7 +172,7 @@ export const MapTooltip = () => { return { value: formattedValue, - error, + error: formattedError, measureDimension: symbolLayer.measureDimension, show, color, @@ -287,17 +287,21 @@ export const MapTooltip = () => { ); }; -type TooltipRowProps = { +const TooltipRow = ({ + title, + background, + color, + value, + error, + border = "none", +}: { title: string; background: string; color: string; value: string; - error: string | null; + error?: string | null; border?: string; -}; - -const TooltipRow = (props: TooltipRowProps) => { - const { title, background, color, value, error, border = "none" } = props; +}) => { return ( <> diff --git a/app/domain/data.ts b/app/domain/data.ts index e47fbaad6..3ee616b64 100644 --- a/app/domain/data.ts +++ b/app/domain/data.ts @@ -661,15 +661,6 @@ export const isConfidenceLowerBoundDimension = ( return dim.__typename === "ConfidenceLowerBoundDimension"; }; -export const findRelatedErrorDimension = ( - dimIri: string, - dimensions: Component[] -) => { - return dimensions.find((x) => - x.related?.some((r) => r.id === dimIri && r.type === "StandardError") - ); -}; - export const shouldLoadMinMaxValues = (dim: ResolvedDimension) => { const { data: { isNumerical, scaleType, dataKind }, From 129fffc252250e08556005e49b3671b7ac0bf68f Mon Sep 17 00:00:00 2001 From: Bartosz Prusinowski Date: Mon, 25 Nov 2024 11:36:49 +0100 Subject: [PATCH 09/10] fix: Center whisker middle circle --- app/charts/line/lines.tsx | 4 ++++ app/charts/shared/rendering-utils.ts | 5 +++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/app/charts/line/lines.tsx b/app/charts/line/lines.tsx index ea4c22078..f6c0f803c 100644 --- a/app/charts/line/lines.tsx +++ b/app/charts/line/lines.tsx @@ -14,6 +14,7 @@ import { useTransitionStore } from "@/stores/transition"; export const ErrorWhiskers = () => { const { getX, + getY, getYErrorPresent, getYErrorRange, chartData, @@ -37,10 +38,12 @@ export const ErrorWhiskers = () => { const x0 = xScale(getX(d)) as number; const segment = getSegment(d); const barWidth = 15; + const y = getY(d) as number; const [y1, y2] = getYErrorRange(d); return { key: `${i}`, x: x0 - barWidth / 2, + y: yScale(y), y1: yScale(y1), y2: yScale(y2), width: barWidth, @@ -53,6 +56,7 @@ export const ErrorWhiskers = () => { colors, getSegment, getX, + getY, getYErrorPresent, getYErrorRange, showYUncertainty, diff --git a/app/charts/shared/rendering-utils.ts b/app/charts/shared/rendering-utils.ts index b9bf5dc00..1a02aba4e 100644 --- a/app/charts/shared/rendering-utils.ts +++ b/app/charts/shared/rendering-utils.ts @@ -155,6 +155,7 @@ const ERROR_WHISKER_MIDDLE_CIRCLE_RADIUS = 3.5; export type RenderWhiskerDatum = { key: string; x: number; + y: number; y1: number; y2: number; width: number; @@ -215,7 +216,7 @@ export const renderWhiskers = ( .append("circle") .attr("class", "middle-circle") .attr("cx", (d) => d.x + d.width / 2) - .attr("cy", (d) => (d.y1 + d.y2) / 2) + .attr("cy", (d) => d.y) .attr("r", ERROR_WHISKER_MIDDLE_CIRCLE_RADIUS) .attr("fill", (d) => d.fill ?? "black") .attr("stroke", "none") @@ -259,7 +260,7 @@ export const renderWhiskers = ( g .select(".middle-circle") .attr("cx", (d) => d.x + d.width / 2) - .attr("cy", (d) => (d.y1 + d.y2) / 2) + .attr("cy", (d) => d.y) .attr("r", ERROR_WHISKER_MIDDLE_CIRCLE_RADIUS) .attr("fill", (d) => d.fill ?? "black") .attr("stroke", "none") From 2623209696a6f634e3e5fa63f242e9c47341f27c Mon Sep 17 00:00:00 2001 From: Bartosz Prusinowski Date: Mon, 25 Nov 2024 11:43:27 +0100 Subject: [PATCH 10/10] docs: Update CHANGELOG --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c1ff5a9b..1f34057ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ You can also check the application will try to use dual-line chart with measures from different cubes) - Added tooltip that explains why a given chart type can't be selected + - Added support for confidence intervals - Fixes - Introduced a `componentId` concept which makes the dimensions and measures unique by adding an unversioned cube iri to the unversioned component iri on @@ -30,6 +31,7 @@ You can also check the - Map legend is now correctly updated (in some cases it was rendered incorrectly on the initial render) - Vertical Axis measure names are now correctly displayed in the left panel + - Uncertainties are now correctly displayed in map symbol layer tooltip - Performance - We no longer load non-key dimensions when initializing a chart - Maintenance