import React, { ComponentType, useCallback, useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { format } from 'd3-format'; import type { IAnalyticType, IComputationFunction, IFilterRule, ISemanticType } from '../../interfaces'; import { profileNonmialField, profileQuantitativeField, wrapComputationWithTag, type IQuantitativeProfileBin } from '../../computation'; import { _unstable_encodeRuleValue, cn, formatDate, isNotEmpty } from '../../utils'; import { parsedOffsetDate } from '../../lib/op/offset'; export interface FieldProfilingProps { field: string; computation: IComputationFunction; } interface ProfilingInteractionProps { analyticType: IAnalyticType; /** the filter currently applied on this field, used to highlight the selection */ rule?: IFilterRule | null; /** toggles a value in a `one of` filter; omitted when filtering is disabled */ onToggleValue?: (value: any) => void; /** filters the field to a range; `extend` merges the range into the current one */ onSelectRange?: (range: [number, number], extend: boolean) => void; } const CHART_HEIGHT = 'h-[58px]'; // Tailwind needs the full class names to be present in the source, so every accent variant is spelled out. const ACCENT = { dimension: { wash: 'bg-dimension/[0.16]', washHover: 'group-hover/value:bg-dimension/30', washOn: 'bg-dimension/40', bar: 'bg-dimension', }, measure: { wash: 'bg-measure/[0.16]', washHover: 'group-hover/value:bg-measure/30', washOn: 'bg-measure/40', bar: 'bg-measure', }, } as const; const formatCount = (n: number) => n.toLocaleString(); const siFormatter = format('.3~s'); const decimalFormatter = format(',.2~f'); const smallFormatter = format('.3~g'); export function formatNumber(x: number) { if (!Number.isFinite(x)) return `${x}`; const abs = Math.abs(x); if (abs >= 1e5) return siFormatter(x); if (abs >= 1 || x === 0) return decimalFormatter(x); return smallFormatter(x); } const approxFormatter = format(',.3~r'); /** A glanceable summary number, e.g. an average. */ function formatApprox(x: number) { if (!Number.isFinite(x)) return `${x}`; return Math.abs(x) >= 1e5 ? siFormatter(x) : approxFormatter(x); } /** Integer percentages that always add up to 100 (largest remainder). */ function toPercents(counts: number[], total: number) { if (!total) return counts.map(() => 0); const raw = counts.map((c) => (c * 100) / total); const result = raw.map(Math.floor); let left = 100 - result.reduce((a, b) => a + b, 0); const order = raw.map((v, i) => [v - result[i], i] as const).sort((a, b) => b[0] - a[0]); for (let k = 0; left > 0 && k < order.length; k++, left--) { result[order[k][1]] += 1; } return result; } /** Keeps the previous result on screen while a new one loads, and drops stale responses. */ function useProfile(load: () => Promise, deps: React.DependencyList) { const [state, setState] = useState<{ data?: T; loading: boolean }>({ loading: true }); useEffect(() => { let alive = true; setState((s) => ({ data: s.data, loading: true })); load().then( (data) => alive && setState({ data, loading: false }), (err) => { if (!alive) return; console.error(err); setState((s) => ({ data: s.data, loading: false })); } ); return () => { alive = false; }; }, deps); return state; } function Caption({ children }: { children?: React.ReactNode }) { return
{children}
; } function Skeleton({ kind }: { kind: 'nominal' | 'quantitative' }) { if (kind === 'quantitative') { return (
{[18, 30, 55, 80, 100, 85, 60, 40, 22, 12].map((h, i) => (
))}
); } return (
{[72, 54, 38].map((w) => (
))}
); } function Constant({ children }: { children: React.ReactNode }) { return
= {children}
; } function NominalProfiling({ computation, field, analyticType, rule, onToggleValue, valueRenderer = (s) => `${s}`, }: FieldProfilingProps & ProfilingInteractionProps & { valueRenderer?: (v: string | number) => string }) { const { t } = useTranslation('translation', { keyPrefix: 'data_table' }); const { data: stat, loading } = useProfile(() => profileNonmialField(wrapComputationWithTag(computation, 'profiling'), field), [computation, field]); if (!isNotEmpty(stat)) { return ( <> ); } const [meta, tops] = stat; const render = (value: string | number) => { const displayValue = valueRenderer(value); if (!displayValue) { return {t('empty_value')}; } return displayValue; }; if (meta.total === 0 || tops.length === 0) { return ( <>
—
); } if (meta.distinctTotal === 1) { return ( <> {render(tops[0].value)} {t('unique_count', { count: 1, value: formatCount(1) })} ); } // shows the top values when the most frequent one is more than 1.3x the average and over 1%, // or when there are fewer than 10 unique values. const showsTops = meta.distinctTotal < 10 || (tops[0].count > (1.3 * meta.total) / meta.distinctTotal && tops[0].count > meta.total / 100); if (!showsTops) { return (
{formatCount(meta.distinctTotal)}
{t('unique_values')}
{t('distinct_ratio', { percent: Math.round((meta.distinctTotal * 100) / meta.total) })}
); } const shown = meta.distinctTotal <= tops.length ? tops : tops.slice(0, 2); const otherCount = meta.total - shown.reduce((sum, x) => sum + x.count, 0); const otherValues = meta.distinctTotal - shown.length; const percents = toPercents(otherValues > 0 ? [...shown.map((x) => x.count), otherCount] : shown.map((x) => x.count), meta.total); const selected = rule?.type === 'one of' || rule?.type === 'not in' ? new Set(rule.value.map(_unstable_encodeRuleValue)) : null; const isOn = (value: unknown) => { if (!selected || !rule) return false; const has = selected.has(_unstable_encodeRuleValue(value)); return rule.type === 'one of' ? has : !has; }; const accent = ACCENT[analyticType]; return (
{shown.map(({ value, count }, i) => { const on = isOn(value); const content = ( <> {render(value)} {percents[i]}% ); const className = 'group/value relative flex h-[17px] w-full items-center justify-between gap-2 rounded-[5px] px-1.5 text-left text-xs'; return onToggleValue ? ( ) : (
{content}
); })} {otherValues > 0 && (
{t('other', { count: otherValues })} {percents[percents.length - 1]}%
)}
{t('unique_count', { count: meta.distinctTotal, value: formatCount(meta.distinctTotal) })}
); } function binIsOn(bin: IQuantitativeProfileBin, rule?: IFilterRule | null) { if (rule?.type !== 'range' || bin.count === 0) return false; const lo = rule.value[0] ?? -Infinity; const hi = rule.value[1] ?? Infinity; return (bin.min ?? bin.from) <= hi && (bin.max ?? bin.to) >= lo; } function QuantitativeProfiling({ computation, field, analyticType, rule, onSelectRange }: FieldProfilingProps & ProfilingInteractionProps) { const { t } = useTranslation('translation', { keyPrefix: 'data_table' }); const { data: stat, loading } = useProfile(() => profileQuantitativeField(wrapComputationWithTag(computation, 'profiling'), field), [computation, field]); if (!isNotEmpty(stat)) { return ( <> ); } if (stat.total === 0 || stat.binValues.length === 0) { return ( <>
—
); } if (stat.min === stat.max) { return ( <> {formatNumber(stat.min)} {t('avg', { value: formatApprox(stat.mean) })} ); } const peak = Math.max(...stat.binValues.map((b) => b.count)); const hasSelection = rule?.type === 'range'; const accent = ACCENT[analyticType]; const lastIndex = stat.binValues.length - 1; return (
{stat.binValues.map((bin, i) => { const on = binIsOn(bin, rule); const lo = bin.min ?? bin.from; const hi = bin.max ?? bin.to; const label = `${formatNumber(bin.from)} – ${formatNumber(bin.to)}`; const interactive = onSelectRange && bin.count > 0; return ( ); })}
{formatNumber(stat.min)} {formatNumber(stat.max)}
{t('avg', { value: formatApprox(stat.mean) })}
); } function LazyLoaded(Component: ComponentType) { return function (props: T & { key?: React.Key }) { const [loaded, setLoaded] = useState(false); const obRef = useRef(null); const ref = useCallback((node: HTMLDivElement) => { obRef.current?.disconnect(); if (node) { const observer = new IntersectionObserver((entries) => { entries.forEach((entry) => { if (entry.isIntersecting) { setLoaded(true); observer.disconnect(); } }); }); observer.observe(node); obRef.current = observer; } }, []); return ( <> {loaded && }
); }; } function FieldProfilingElement( props: FieldProfilingProps & ProfilingInteractionProps & { semanticType: ISemanticType; displayOffset?: number; offset?: number } ) { const { semanticType, displayOffset, offset, ...fieldProps } = props; switch (semanticType) { case 'nominal': case 'ordinal': return ; case 'temporal': { const formatter = (date: string | number) => formatDate(parsedOffsetDate(displayOffset, offset)(date)); // temporal filters are edited as ranges, so picking single dates from the profile is not offered return ; } case 'quantitative': return ; } } export const FieldProfiling = LazyLoaded(FieldProfilingElement);