From aeb13541df92384319dd7e2dda2bf47697ff024e Mon Sep 17 00:00:00 2001 From: Brian Holmes Date: Mon, 2 Feb 2026 19:19:55 -0500 Subject: [PATCH 01/36] wip --- .../time-series-chart/BarChart.svelte | 95 ++ .../time-series-chart/TimeSeriesChart.svelte | 216 ++++ .../canvas/components/kpi/KPIDisplay.tsx | 0 .../big-number/MeasureBigNumber.svelte | 96 +- .../big-number/MeasuresContainer.svelte | 49 +- .../time-dimension-details/TDDTable.svelte | 20 +- .../TimeDimensionDisplay.svelte | 27 +- .../charts/TDDAlternateChart.svelte | 42 +- .../time-dimension-data-store.ts | 8 +- .../time-dimension-details/types.ts | 7 - .../dashboards/time-dimension-details/util.ts | 7 +- .../time-series/MeasureChart.svelte | 4 +- .../dashboards/time-series/MeasurePan.svelte | 73 -- .../MetricsTimeSeriesCharts.svelte | 511 ++++------ .../time-series/TimeSeriesAxis.svelte | 43 + .../measure-chart/ExplainButton.svelte | 18 + .../measure-chart/MeasureChart.svelte | 932 ++++++++++++++++++ .../MeasureChartAnnotationMarkers.svelte | 75 ++ .../MeasureChartAnnotationPopover.svelte | 115 +++ .../measure-chart/MeasureChartLines.svelte | 217 ++++ .../MeasureChartPointIndicator.svelte | 35 + .../measure-chart/MeasureChartScrub.svelte | 141 +++ .../measure-chart/MeasureChartTooltip.svelte | 290 ++++++ .../measure-chart/MeasurePan.svelte | 25 + .../measure-chart/PanButton.svelte | 22 + .../measure-chart/annotation-utils.ts | 143 +++ .../time-series/measure-chart/bisect.ts | 173 ++++ .../time-series/measure-chart/index.ts | 51 + .../time-series/measure-chart/interactions.ts | 269 +++++ .../time-series/measure-chart/scales.ts | 209 ++++ .../time-series/measure-chart/types.ts | 169 ++++ .../measure-chart/use-dimension-data.ts | 169 ++++ .../measure-chart/use-measure-time-series.ts | 174 ++++ .../measure-chart/use-measure-totals.ts | 163 +++ .../measure-selection/ExplainButton.svelte | 39 - .../features/dashboards/time-series/utils.ts | 38 - web-common/src/features/feature-flags.ts | 2 +- 37 files changed, 4073 insertions(+), 594 deletions(-) create mode 100644 web-common/src/components/time-series-chart/BarChart.svelte create mode 100644 web-common/src/components/time-series-chart/TimeSeriesChart.svelte create mode 100644 web-common/src/features/canvas/components/kpi/KPIDisplay.tsx delete mode 100644 web-common/src/features/dashboards/time-series/MeasurePan.svelte create mode 100644 web-common/src/features/dashboards/time-series/TimeSeriesAxis.svelte create mode 100644 web-common/src/features/dashboards/time-series/measure-chart/ExplainButton.svelte create mode 100644 web-common/src/features/dashboards/time-series/measure-chart/MeasureChart.svelte create mode 100644 web-common/src/features/dashboards/time-series/measure-chart/MeasureChartAnnotationMarkers.svelte create mode 100644 web-common/src/features/dashboards/time-series/measure-chart/MeasureChartAnnotationPopover.svelte create mode 100644 web-common/src/features/dashboards/time-series/measure-chart/MeasureChartLines.svelte create mode 100644 web-common/src/features/dashboards/time-series/measure-chart/MeasureChartPointIndicator.svelte create mode 100644 web-common/src/features/dashboards/time-series/measure-chart/MeasureChartScrub.svelte create mode 100644 web-common/src/features/dashboards/time-series/measure-chart/MeasureChartTooltip.svelte create mode 100644 web-common/src/features/dashboards/time-series/measure-chart/MeasurePan.svelte create mode 100644 web-common/src/features/dashboards/time-series/measure-chart/PanButton.svelte create mode 100644 web-common/src/features/dashboards/time-series/measure-chart/annotation-utils.ts create mode 100644 web-common/src/features/dashboards/time-series/measure-chart/bisect.ts create mode 100644 web-common/src/features/dashboards/time-series/measure-chart/index.ts create mode 100644 web-common/src/features/dashboards/time-series/measure-chart/interactions.ts create mode 100644 web-common/src/features/dashboards/time-series/measure-chart/scales.ts create mode 100644 web-common/src/features/dashboards/time-series/measure-chart/types.ts create mode 100644 web-common/src/features/dashboards/time-series/measure-chart/use-dimension-data.ts create mode 100644 web-common/src/features/dashboards/time-series/measure-chart/use-measure-time-series.ts create mode 100644 web-common/src/features/dashboards/time-series/measure-chart/use-measure-totals.ts delete mode 100644 web-common/src/features/dashboards/time-series/measure-selection/ExplainButton.svelte diff --git a/web-common/src/components/time-series-chart/BarChart.svelte b/web-common/src/components/time-series-chart/BarChart.svelte new file mode 100644 index 00000000000..392b4f8e6b0 --- /dev/null +++ b/web-common/src/components/time-series-chart/BarChart.svelte @@ -0,0 +1,95 @@ + + +{#if stacked} + {#each { length: visibleCount } as _, slot (slot)} + {@const ptIdx = visibleStart + slot} + {@const cx = plotLeft + (slot + 0.5) * slotWidth} + {@const bx = cx - bandWidth / 2} + {@const stackValues = series.map((s) => ({ + value: s.values[ptIdx] ?? 0, + color: s.color, + id: s.id, + }))} + {#each stackValues as seg, segIdx (seg.id)} + {#if seg.value !== 0} + {@const yBottom = yScale( + stackValues.slice(0, segIdx).reduce((sum, sv) => sum + sv.value, 0), + )} + {@const yTop = yScale( + stackValues + .slice(0, segIdx + 1) + .reduce((sum, sv) => sum + sv.value, 0), + )} + + {/if} + {/each} + {/each} +{:else} + {@const barCount = series.length} + {@const singleBarWidth = bandWidth / barCount} + {#each { length: visibleCount } as _, slot (slot)} + {@const ptIdx = visibleStart + slot} + {@const cx = plotLeft + (slot + 0.5) * slotWidth} + {#each series as s, sIdx (s.id)} + {@const v = s.values[ptIdx] ?? null} + {#if v !== null} + {@const bx = cx - bandWidth / 2 + sIdx * singleBarWidth} + {@const by = Math.min(zeroY, yScale(v))} + {@const bh = Math.abs(zeroY - yScale(v))} + + {/if} + {/each} + {/each} +{/if} diff --git a/web-common/src/components/time-series-chart/TimeSeriesChart.svelte b/web-common/src/components/time-series-chart/TimeSeriesChart.svelte new file mode 100644 index 00000000000..dc6640bbe96 --- /dev/null +++ b/web-common/src/components/time-series-chart/TimeSeriesChart.svelte @@ -0,0 +1,216 @@ + + + + {#if primarySeries?.areaGradient} + + + + + + + + + {/if} + + {#if primarySeries} + + {#each primarySegments as seg (seg.startIndex)} + {@const x = scales.x(seg.startIndex)} + {@const width = scales.x(seg.endIndex) - x} + + {/each} + + {/if} + + {#if hasScrubSelection && scrubStartIndex !== null && scrubEndIndex !== null} + + + + {/if} + + + + {#if primarySeries?.areaGradient} + + {/if} + + + {#each series.slice(1) as s (s.id)} + + {#if hasScrubSelection && scrubStartIndex !== null && scrubEndIndex !== null} + + {/if} + {/each} + + + {#if primarySeries} + + + + {#each primarySingletons as idx (idx)} + {@const v = primarySeries.values[idx] ?? 0} + + + {/each} + {/if} + + + {#if hasScrubSelection && scrubStartIndex !== null && scrubEndIndex !== null && primarySeries} + {#if primarySeries.areaGradient} + + {/if} + + {/if} diff --git a/web-common/src/features/canvas/components/kpi/KPIDisplay.tsx b/web-common/src/features/canvas/components/kpi/KPIDisplay.tsx new file mode 100644 index 00000000000..e69de29bb2d diff --git a/web-common/src/features/dashboards/big-number/MeasureBigNumber.svelte b/web-common/src/features/dashboards/big-number/MeasureBigNumber.svelte index 20246bd36bf..03faefd433c 100644 --- a/web-common/src/features/dashboards/big-number/MeasureBigNumber.svelte +++ b/web-common/src/features/dashboards/big-number/MeasureBigNumber.svelte @@ -11,7 +11,11 @@ import { FormatPreset } from "@rilldata/web-common/lib/number-formatting/humanizer-types"; import { formatMeasurePercentageDifference } from "@rilldata/web-common/lib/number-formatting/percentage-formatter"; import { numberPartsToString } from "@rilldata/web-common/lib/number-formatting/utils/number-parts-utils"; - import { type MetricsViewSpecMeasure } from "@rilldata/web-common/runtime-client"; + import { + type MetricsViewSpecMeasure, + createQueryServiceMetricsViewAggregation, + type V1Expression, + } from "@rilldata/web-common/runtime-client"; import { cellInspectorStore } from "../stores/cell-inspector-store"; import { crossfade, @@ -20,16 +24,94 @@ type FlyParams, } from "svelte/transition"; import BigNumberTooltipContent from "./BigNumberTooltipContent.svelte"; + import { keepPreviousData, type QueryClient } from "@tanstack/svelte-query"; export let measure: MetricsViewSpecMeasure; - export let value: number | null; - export let comparisonValue: number | undefined = undefined; - export let showComparison = false; - export let status: EntityStatus; - export let errorMessage: string | undefined = undefined; export let withTimeseries = true; export let isMeasureExpanded = false; + // Query-context props + export let instanceId: string; + export let metricsViewName: string; + export let where: V1Expression | undefined = undefined; + export let timeDimension: string | undefined = undefined; + export let timeStart: string | undefined = undefined; + export let timeEnd: string | undefined = undefined; + export let comparisonTimeStart: string | undefined = undefined; + export let comparisonTimeEnd: string | undefined = undefined; + export let showComparison = false; + export let ready: boolean = true; + + $: measureName = measure.name ?? ""; + + // Primary totals query + $: primaryQuery = createQueryServiceMetricsViewAggregation( + instanceId, + metricsViewName, + { + measures: [{ name: measureName }], + where, + timeRange: { + start: timeStart, + end: timeEnd, + timeDimension, + }, + }, + { + query: { + enabled: ready && !!timeStart && !!measureName, + placeholderData: keepPreviousData, + refetchOnMount: false, + }, + }, + ); + + // Comparison totals query + $: comparisonQuery = createQueryServiceMetricsViewAggregation( + instanceId, + metricsViewName, + { + measures: [{ name: measureName }], + where, + timeRange: { + start: comparisonTimeStart, + end: comparisonTimeEnd, + timeDimension, + }, + }, + { + query: { + enabled: + ready && showComparison && !!comparisonTimeStart && !!measureName, + placeholderData: keepPreviousData, + refetchOnMount: false, + }, + }, + ); + + // Derive value, comparisonValue, status, errorMessage from queries + $: value = + ($primaryQuery.data?.data?.[0]?.[measureName] as number | null) ?? null; + $: comparisonValue = showComparison + ? ($comparisonQuery.data?.data?.[0]?.[measureName] as number | undefined) + : undefined; + + $: isFetching = + $primaryQuery.isFetching || (showComparison && $comparisonQuery.isFetching); + $: isError = $primaryQuery.isError || $comparisonQuery.isError; + + $: status = isError + ? EntityStatus.Error + : isFetching + ? EntityStatus.Running + : EntityStatus.Idle; + + $: errorMessage = isError + ? (($primaryQuery.error as any)?.response?.data?.message ?? + ($comparisonQuery.error as any)?.response?.data?.message ?? + undefined) + : undefined; + $: comparisonPercChange = comparisonValue && value !== undefined && value !== null ? (value - comparisonValue) / comparisonValue @@ -242,7 +324,7 @@ diff --git a/web-common/src/features/dashboards/time-series/MetricsTimeSeriesCharts.svelte b/web-common/src/features/dashboards/time-series/MetricsTimeSeriesCharts.svelte index 0b9bf1b985f..951854ac547 100644 --- a/web-common/src/features/dashboards/time-series/MetricsTimeSeriesCharts.svelte +++ b/web-common/src/features/dashboards/time-series/MetricsTimeSeriesCharts.svelte @@ -1,14 +1,9 @@ + +
+ + {#each ticks as tick} + + {formatTick(tick)} + + {/each} + +
diff --git a/web-common/src/features/dashboards/time-series/measure-chart/ExplainButton.svelte b/web-common/src/features/dashboards/time-series/measure-chart/ExplainButton.svelte new file mode 100644 index 00000000000..79a652ea2b5 --- /dev/null +++ b/web-common/src/features/dashboards/time-series/measure-chart/ExplainButton.svelte @@ -0,0 +1,18 @@ + + + diff --git a/web-common/src/features/dashboards/time-series/measure-chart/MeasureChart.svelte b/web-common/src/features/dashboards/time-series/measure-chart/MeasureChart.svelte new file mode 100644 index 00000000000..d0ce8aea3a3 --- /dev/null +++ b/web-common/src/features/dashboards/time-series/measure-chart/MeasureChart.svelte @@ -0,0 +1,932 @@ + + +
+ {#if !$visible || (isFetching && data.length === 0)} +
+ +
+ {:else if isError} +
+ {error ?? "Error loading data"} +
+ {:else if tddChartType !== TDDChart.DEFAULT && data.length > 0} +
+ +
+ {:else if data.length > 0} + { + handlers.onMouseMove(e); + checkAnnotationHover(e); + }} + on:mouseleave={handleSvgMouseLeave} + on:mousedown={handleMouseDown} + on:mouseup={handleMouseUp} + on:click={handleChartClick} + > + + + + + + + + + {#each yTicks as tick (tick)} + + {axisFormatter(tick)} + + + {/each} + + + + + + + + {#if mode === "line"} + + {:else} + + {/if} + + + {#if !isScrubbing && hoveredPoint} + 0} + formatter={valueFormatter} + /> + {/if} + + + {#if singleSelectIdx !== null && singleSelectX !== null && isThisMeasureSelected} + {@const selPt = data[singleSelectIdx]} + {#if selPt?.value !== null && selPt?.value !== undefined} + + {/if} + {/if} + + + + {#if isLocallyHovered} + + {/if} + + {#if annotationGroups.length > 0} + + {/if} + + + + {#if !isScrubbing && isComparingDimension && isLocallyHovered && hoveredPoint && dimTooltipEntries.length > 0} + {@const tooltipX = scales.x(hoveredIndex) + 10} + {@const tooltipY = config.plotBounds.top + config.plotBounds.height / 2} +
+ {#each dimTooltipEntries as entry (entry.label)} +
+ + {entry.label}: + {valueFormatter(entry.value)} +
+ {/each} +
+ {/if} + + {#if annotationGroups.length > 0} + + {/if} + + + {#if !isScrubbing && explainX !== null} + + measureSelection.startAnomalyExplanationChat(metricsViewName)} + /> + {/if} + {:else} +
+ No data available +
+ {/if} +
+ + diff --git a/web-common/src/features/dashboards/time-series/measure-chart/MeasureChartAnnotationMarkers.svelte b/web-common/src/features/dashboards/time-series/measure-chart/MeasureChartAnnotationMarkers.svelte new file mode 100644 index 00000000000..4f590fdfd9f --- /dev/null +++ b/web-common/src/features/dashboards/time-series/measure-chart/MeasureChartAnnotationMarkers.svelte @@ -0,0 +1,75 @@ + + +{#each groups as group (group.left)} + {@const hovered = hoveredGroup === group} + {@const cx = group.left} + {@const cy = group.top + AnnotationHeight / 2} + +{/each} + +{#if hasRange && hoveredGroup} + + + + + + + + +{/if} diff --git a/web-common/src/features/dashboards/time-series/measure-chart/MeasureChartAnnotationPopover.svelte b/web-common/src/features/dashboards/time-series/measure-chart/MeasureChartAnnotationPopover.svelte new file mode 100644 index 00000000000..b6811436787 --- /dev/null +++ b/web-common/src/features/dashboards/time-series/measure-chart/MeasureChartAnnotationPopover.svelte @@ -0,0 +1,115 @@ + + +{#if hoveredGroup} +
+ (showingMore = false)} + > + + + + +
onHover(true)} + on:mouseleave={() => onHover(false)} + role="menu" + tabindex="-1" + > + {#each annotationsToShow as annotation, i (i)} +
+
+ {annotation.description} +
+
+ {annotation.formattedTimeOrRange} +
+
+ {/each} + {#if hasMoreAnnotations && !showingMore} + + {/if} +
+
+
+
+{/if} diff --git a/web-common/src/features/dashboards/time-series/measure-chart/MeasureChartLines.svelte b/web-common/src/features/dashboards/time-series/measure-chart/MeasureChartLines.svelte new file mode 100644 index 00000000000..7e52cc0be5d --- /dev/null +++ b/web-common/src/features/dashboards/time-series/measure-chart/MeasureChartLines.svelte @@ -0,0 +1,217 @@ + + + + + + + + + + + + + + + + + + + {#each segments as segment (segment[0].ts.getTime())} + {@const x = scales.x(segment[0].tsPosition)} + {@const width = scales.x(segment[segment.length - 1].tsPosition) - x} + + {/each} + + + + {#if hasScrubSelection && scrubStart && scrubEnd} + + + + {/if} + + + +{#if isComparingDimension} + {#each dimensionData as dim (dim.dimensionValue)} + {#if dim.data.length > 0} + + {/if} + {/each} +{:else} + + + + + {#if showComparison} + + {/if} + + + + + + {#each singletons as singleton (singleton.ts.getTime())} + + + + {/each} + + + {#if hasScrubSelection && scrubStart && scrubEnd} + + + + + + {/if} +{/if} diff --git a/web-common/src/features/dashboards/time-series/measure-chart/MeasureChartPointIndicator.svelte b/web-common/src/features/dashboards/time-series/measure-chart/MeasureChartPointIndicator.svelte new file mode 100644 index 00000000000..d17134063aa --- /dev/null +++ b/web-common/src/features/dashboards/time-series/measure-chart/MeasureChartPointIndicator.svelte @@ -0,0 +1,35 @@ + + + + + + {value} + diff --git a/web-common/src/features/dashboards/time-series/measure-chart/MeasureChartScrub.svelte b/web-common/src/features/dashboards/time-series/measure-chart/MeasureChartScrub.svelte new file mode 100644 index 00000000000..75bfeb117b5 --- /dev/null +++ b/web-common/src/features/dashboards/time-series/measure-chart/MeasureChartScrub.svelte @@ -0,0 +1,141 @@ + + + + + + + + + + +{#if hasSelection && orderedStartIdx !== null && orderedEndIdx !== null} + + + + + + + {#if showLabels} + + {formatLabel(Math.round(orderedStartIdx))} + + + + + {formatLabel(Math.round(orderedEndIdx))} + + + {/if} + + + + +{/if} + + diff --git a/web-common/src/features/dashboards/time-series/measure-chart/MeasureChartTooltip.svelte b/web-common/src/features/dashboards/time-series/measure-chart/MeasureChartTooltip.svelte new file mode 100644 index 00000000000..68c3bd8a8e7 --- /dev/null +++ b/web-common/src/features/dashboards/time-series/measure-chart/MeasureChartTooltip.svelte @@ -0,0 +1,290 @@ + + +{#if hoveredPoint} + + + + {formatDateTimeByGrain(hoveredPoint.ts, timeGrain)} + + {#if showComparison && hoveredPoint.comparisonTs} + + {formatDateTimeByGrain(hoveredPoint.comparisonTs, timeGrain)} prev. + + {/if} + + + + {#if !currentPointIsNull && !isComparingDimension} + + {/if} + + + {#if isComparingDimension} + + {#each dimensionData as dim} + {@const pt = dim.data[hoveredIndex]} + {#if pt?.value !== null && pt?.value !== undefined} + + {/if} + {/each} + {/if} + + {#if !isComparingDimension} + + + {#if showComparison && hasValidComparisonPoint && !comparisonPointIsNull && currentPointIsNull} + + + + + no current data + + + {formatter(comparisonY ?? null)} prev. + + + {:else if showComparison && hasValidComparisonPoint && !currentPointIsNull && !comparisonPointIsNull} + {@const yDiff = Math.abs($tweenedY - $tweenedComparisonY)} + {#if yDiff > 8} + {@const bufferSize = yDiff > 16 ? 8 : 4} + {@const sign = comparisonIsPositive ? 1 : -1} + {@const yBuffer = sign * bufferSize} + + + + + + + {#if yDiff > 16} + {@const yLoc = $tweenedY + bufferSize * sign} + {@const dist = 3} + {@const signedDist = sign * dist} + + + {/if} + {/if} + + + + + + + {#if !currentPointIsNull && isDiffValid} + + {formatter(y ?? null)} + + ({diffLabel}) + + + {/if} + + + + {#if comparisonPointIsNull} + no comparison data + {:else} + {formatter(comparisonY ?? null)} prev. + {/if} + + + {:else if !currentPointIsNull} + + + {formatter(y ?? null)} + + {:else} + + + no current data + + {/if} + + {/if} +{/if} diff --git a/web-common/src/features/dashboards/time-series/measure-chart/MeasurePan.svelte b/web-common/src/features/dashboards/time-series/measure-chart/MeasurePan.svelte new file mode 100644 index 00000000000..dcd1c2688e9 --- /dev/null +++ b/web-common/src/features/dashboards/time-series/measure-chart/MeasurePan.svelte @@ -0,0 +1,25 @@ + + +{#if canPanLeft && onPanLeft} + + + +{/if} +{#if canPanRight && onPanRight} + + + +{/if} diff --git a/web-common/src/features/dashboards/time-series/measure-chart/PanButton.svelte b/web-common/src/features/dashboards/time-series/measure-chart/PanButton.svelte new file mode 100644 index 00000000000..367cc886540 --- /dev/null +++ b/web-common/src/features/dashboards/time-series/measure-chart/PanButton.svelte @@ -0,0 +1,22 @@ + + + + + + + diff --git a/web-common/src/features/dashboards/time-series/measure-chart/annotation-utils.ts b/web-common/src/features/dashboards/time-series/measure-chart/annotation-utils.ts new file mode 100644 index 00000000000..b02434c46ca --- /dev/null +++ b/web-common/src/features/dashboards/time-series/measure-chart/annotation-utils.ts @@ -0,0 +1,143 @@ +import type { Annotation } from "@rilldata/web-common/components/data-graphic/marks/annotations"; +import type { ChartScales, ChartConfig, TimeSeriesPoint } from "./types"; +import type { V1TimeGrain } from "@rilldata/web-common/runtime-client"; +import { V1TimeGrainToDateTimeUnit } from "@rilldata/web-common/lib/time/new-grains"; +import { DateTime } from "luxon"; + +export type AnnotationGroup = { + items: Annotation[]; + /** Data index this group maps to */ + index: number; + /** Pixel x of the group diamond */ + left: number; + /** Pixel x of rightmost annotation end (for range annotations) */ + right: number; + /** Pixel y top of diamond area */ + top: number; + /** Pixel y bottom of diamond area */ + bottom: number; + /** Whether any item in the group has a range (endTime) */ + hasRange: boolean; +}; + +export const AnnotationWidth = 10; +export const AnnotationHeight = 10; + +function dateToIndex( + data: TimeSeriesPoint[], + date: Date, +): number | null { + if (data.length === 0) return null; + const ms = date.getTime(); + let best = 0; + let bestDist = Infinity; + for (let i = 0; i < data.length; i++) { + const dist = Math.abs(data[i].ts.toMillis() - ms); + if (dist < bestDist) { + bestDist = dist; + best = i; + } + } + return best; +} + +/** + * Group annotations by time grain bucket, then compute pixel positions. + * All annotations whose startTime truncates to the same grain boundary + * (in the given timezone) are grouped together. + */ +export function groupAnnotations( + annotations: Annotation[], + scales: ChartScales, + data: TimeSeriesPoint[], + config: ChartConfig, + timeGrain: V1TimeGrain | undefined, + timeZone: string, +): AnnotationGroup[] { + if (annotations.length === 0 || data.length === 0) return []; + + const unit = timeGrain ? V1TimeGrainToDateTimeUnit[timeGrain] : "day"; + const diamondY = + config.plotBounds.top + config.plotBounds.height - AnnotationHeight; + + // Bucket annotations by their truncated grain key + const buckets = new Map< + string, + { annotations: Annotation[]; hasRange: boolean } + >(); + + for (const a of annotations) { + const dt = DateTime.fromJSDate(a.startTime, { zone: timeZone }); + const key = dt.startOf(unit).toISO() ?? dt.toISO() ?? String(a.startTime.getTime()); + + let bucket = buckets.get(key); + if (!bucket) { + bucket = { annotations: [], hasRange: false }; + buckets.set(key, bucket); + } + bucket.annotations.push(a); + if (a.endTime) bucket.hasRange = true; + } + + // Convert buckets to groups with pixel positions + const groups: AnnotationGroup[] = []; + + for (const [, bucket] of buckets) { + // Use the first annotation's startTime for positioning + const startIdx = dateToIndex(data, bucket.annotations[0].startTime); + if (startIdx === null) continue; + + const left = scales.x(startIdx); + + // Compute right edge from the widest range annotation in the bucket + let right = left + AnnotationWidth; + for (const a of bucket.annotations) { + if (a.endTime) { + const endIdx = dateToIndex(data, a.endTime); + if (endIdx !== null) { + right = Math.max(right, scales.x(endIdx)); + } + } + } + + // Filter out-of-bounds groups + if ( + left < config.plotBounds.left || + left > config.plotBounds.left + config.plotBounds.width + ) { + continue; + } + + groups.push({ + items: bucket.annotations, + index: startIdx, + left, + right: Math.min(right, config.plotBounds.left + config.plotBounds.width), + top: diamondY, + bottom: diamondY + AnnotationHeight, + hasRange: bucket.hasRange, + }); + } + + // Sort by x position + groups.sort((a, b) => a.left - b.left); + + return groups; +} + +export function findHoveredGroup( + groups: AnnotationGroup[], + mouseX: number, + mouseY: number, +): AnnotationGroup | null { + for (const group of groups) { + if ( + mouseY >= group.top - 2 && + mouseX >= group.left - AnnotationWidth / 2 && + mouseX <= group.left + AnnotationWidth / 2 + 2 + ) { + return group; + } + } + return null; +} diff --git a/web-common/src/features/dashboards/time-series/measure-chart/bisect.ts b/web-common/src/features/dashboards/time-series/measure-chart/bisect.ts new file mode 100644 index 00000000000..ec635b9129e --- /dev/null +++ b/web-common/src/features/dashboards/time-series/measure-chart/bisect.ts @@ -0,0 +1,173 @@ +import { bisector } from "d3-array"; +import type { DateTimeUnit } from "luxon"; +import type { + TimeSeriesPoint, + DimensionSeriesData, + BisectedPoint, +} from "./types"; +import { + roundDownToTimeUnit, + roundToNearestTimeUnit, +} from "../round-to-nearest-time-unit"; +import { TIME_GRAIN } from "@rilldata/web-common/lib/time/config"; +import type { V1TimeGrain } from "@rilldata/web-common/runtime-client"; + +/** + * D3 bisector for TimeSeriesPoint arrays, using the ts field. + */ +export const timeSeriesBisector = bisector( + (d) => d.ts, +); + +/** + * Get the time grain label from V1TimeGrain. + */ +export function getTimeGrainLabel(timeGrain: V1TimeGrain): DateTimeUnit { + return TIME_GRAIN[timeGrain]?.label as DateTimeUnit; +} + +/** + * Find the nearest point in the data array to the given date. + * Uses optional time grain rounding for stable tooltip behavior. + */ +export function bisectTimeSeriesPoint( + data: TimeSeriesPoint[], + date: Date, + timeGrain: V1TimeGrain, + roundStrategy: "nearest" | "down" = "down", +): BisectedPoint { + if (!data.length || !date) { + return { point: null, index: -1, roundedTs: null }; + } + + const grainLabel = getTimeGrainLabel(timeGrain); + if (!grainLabel) { + return { point: null, index: -1, roundedTs: null }; + } + + // Round to time grain for stable tooltip + const roundedTs = + roundStrategy === "down" + ? roundDownToTimeUnit(date, grainLabel) + : roundToNearestTimeUnit(date, grainLabel); + + // Find nearest point using bisector center + const index = timeSeriesBisector.center(data, roundedTs); + + // Clamp index to valid range + const clampedIndex = Math.max(0, Math.min(data.length - 1, index)); + const point = data[clampedIndex] ?? null; + + return { point, index: clampedIndex, roundedTs }; +} + +/** + * Find values for all dimension series at a given time. + * Returns a map from dimension value to the data point. + */ +export function bisectDimensionData( + dimensionData: DimensionSeriesData[], + date: Date, + timeGrain: V1TimeGrain, +): Map { + const results = new Map< + string | null, + { value: number | null; color: string; point: TimeSeriesPoint | null } + >(); + + for (const dim of dimensionData) { + const { point } = bisectTimeSeriesPoint(dim.data, date, timeGrain); + results.set(dim.dimensionValue, { + value: point?.value ?? null, + color: dim.color, + point, + }); + } + + return results; +} + +/** + * Check if a date is within the data's time range. + */ +export function isDateInDataRange( + date: Date, + data: TimeSeriesPoint[], +): boolean { + if (!data.length) return false; + + const firstTs = data[0].ts; + const lastTs = data[data.length - 1].ts; + + return date >= firstTs && date <= lastTs; +} + +/** + * Find the closest data point to the given screen X coordinate. + * Useful for mouse interactions. + */ +export function bisectByScreenX( + screenX: number, + data: TimeSeriesPoint[], + xScale: (date: Date) => number, + timeGrain: V1TimeGrain, +): BisectedPoint { + if (!data.length) { + return { point: null, index: -1, roundedTs: null }; + } + + // Get the time range + const firstTs = data[0].ts; + const lastTs = data[data.length - 1].ts; + + // Convert screen X to date via inverse of scale + // We need to approximate this since we don't have the inverse scale here + const firstX = xScale(firstTs); + const lastX = xScale(lastTs); + + // Linear interpolation to get approximate date + const ratio = (screenX - firstX) / (lastX - firstX); + const timeRange = lastTs.getTime() - firstTs.getTime(); + const approximateTime = new Date(firstTs.getTime() + ratio * timeRange); + + return bisectTimeSeriesPoint(data, approximateTime, timeGrain); +} + +/** + * Compute line segments from data, handling gaps (null values). + * Returns an array of segments, where each segment is a contiguous + * run of non-null data points. + */ +export function computeLineSegments( + data: TimeSeriesPoint[], +): TimeSeriesPoint[][] { + const segments: TimeSeriesPoint[][] = []; + let currentSegment: TimeSeriesPoint[] = []; + + for (const point of data) { + if (point.value !== null) { + currentSegment.push(point); + } else if (currentSegment.length > 0) { + segments.push(currentSegment); + currentSegment = []; + } + } + + // Don't forget the last segment + if (currentSegment.length > 0) { + segments.push(currentSegment); + } + + return segments; +} + +/** + * Find singleton points (segments with only one point). + * These need to be rendered as circles instead of lines. + */ +export function findSingletonPoints(data: TimeSeriesPoint[]): TimeSeriesPoint[] { + const segments = computeLineSegments(data); + return segments + .filter((segment) => segment.length === 1) + .map((segment) => segment[0]); +} diff --git a/web-common/src/features/dashboards/time-series/measure-chart/index.ts b/web-common/src/features/dashboards/time-series/measure-chart/index.ts new file mode 100644 index 00000000000..6091f8b19ab --- /dev/null +++ b/web-common/src/features/dashboards/time-series/measure-chart/index.ts @@ -0,0 +1,51 @@ +// Main component +export { default as MeasureChart } from "./MeasureChart.svelte"; + +// Sub-components +export { default as MeasureChartTooltip } from "./MeasureChartTooltip.svelte"; +export { default as MeasureChartScrub } from "./MeasureChartScrub.svelte"; + +// Types +export type { + TimeSeriesPoint, + DimensionSeriesData, + ChartConfig, + ChartScales, + ChartSeries, + ChartMode, + ScrubState, + HoverState, + BisectedPoint, + InteractionState, + MeasureChartProps, +} from "./types"; + +// Utilities +export { + computeChartConfig, + computeNiceYExtent, + computeYExtent, + computeXExtent, + createScales, + createXScale, + createYScale, +} from "./scales"; + +export { + createChartInteractions, + createVisibilityObserver, + getOrderedDates, +} from "./interactions"; + +// Data fetching hooks +export { + useMeasureTimeSeries, + useMeasureTimeSeriesData, + transformTimeSeriesData, +} from "./use-measure-time-series"; + +export { + useMeasureTotals, + useMeasureTotalsData, + computeComparisonMetrics, +} from "./use-measure-totals"; diff --git a/web-common/src/features/dashboards/time-series/measure-chart/interactions.ts b/web-common/src/features/dashboards/time-series/measure-chart/interactions.ts new file mode 100644 index 00000000000..4bf2ba9997b --- /dev/null +++ b/web-common/src/features/dashboards/time-series/measure-chart/interactions.ts @@ -0,0 +1,269 @@ +import { derived, writable, get, type Readable, type Writable } from "svelte/store"; +import type { + ChartScales, + PlotBounds, + HoverState, + ScrubState, + BisectedPoint, + InteractionState, + InteractionHandlers, +} from "./types"; + +/** + * Create an IntersectionObserver-based visibility store. + */ +export function createVisibilityObserver( + rootMargin = "120px", +): { + visible: Writable; + observe: (element: HTMLElement, root?: HTMLElement | null) => () => void; +} { + const visible = writable(false); + + function observe( + element: HTMLElement, + root: HTMLElement | null = null, + ): () => void { + const observer = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting) { + visible.set(true); + observer.unobserve(element); + } + }, + { root, rootMargin, threshold: 0 }, + ); + observer.observe(element); + return () => observer.disconnect(); + } + + return { visible, observe }; +} + +const EMPTY_HOVER_STATE: HoverState = { + index: null, + screenX: null, + isHovered: false, +}; + +const EMPTY_SCRUB_STATE: ScrubState = { + startIndex: null, + endIndex: null, + isScrubbing: false, +}; + +const EMPTY_BISECTED: BisectedPoint = { index: -1 }; + +type ScrubMode = "none" | "create" | "resize-start" | "resize-end" | "move"; + +/** + * Index-based chart interactions. + * x scale is linear (domain = [0, N-1]), so bisection is just Math.round(xScale.invert(px)). + */ +export function createChartInteractions( + scalesStore: Readable, + visibleRangeStore: Readable<[number, number]>, + plotBoundsStore: Readable, + externalScrubState?: Writable, +): { + state: Readable; + handlers: InteractionHandlers; + resetScrub: () => void; +} { + const hoverState = writable(EMPTY_HOVER_STATE); + const internalScrubState = writable(EMPTY_SCRUB_STATE); + const scrubState = externalScrubState ?? internalScrubState; + + let scrubMode: ScrubMode = "none"; + let scrubMoveStartX: number | null = null; + let scrubMoveStartIndices: { start: number | null; end: number | null } | null = null; + + const EDGE_THRESHOLD = 5; + + /** Snap fractional index to nearest valid visible index. */ + function snap(fractionalIndex: number, range: [number, number]): number { + return Math.max(range[0], Math.min(range[1], Math.round(fractionalIndex))); + } + + const bisectedPoint = derived( + [hoverState, scalesStore, visibleRangeStore], + ([$hover, _$scales, $range]) => { + if ($hover.index === null || $range[0] === $range[1] && $range[0] === 0) return EMPTY_BISECTED; + return { index: snap($hover.index, $range) }; + }, + ); + + const cursorStyle = derived( + [scrubState, hoverState, scalesStore], + ([$scrub, $hover, $scales]) => { + if ($scrub.isScrubbing) return "cursor-ew-resize"; + + if ($scrub.startIndex !== null && $scrub.endIndex !== null && $hover.screenX !== null) { + const startX = $scales.x($scrub.startIndex); + const endX = $scales.x($scrub.endIndex); + const hx = $hover.screenX; + + if (Math.abs(hx - startX) <= EDGE_THRESHOLD || Math.abs(hx - endX) <= EDGE_THRESHOLD) { + return "cursor-ew-resize"; + } + const minX = Math.min(startX, endX); + const maxX = Math.max(startX, endX); + if (hx > minX + EDGE_THRESHOLD && hx < maxX - EDGE_THRESHOLD) { + return "cursor-grab"; + } + } + return "cursor-crosshair"; + }, + ); + + const state = derived( + [hoverState, scrubState, bisectedPoint, cursorStyle], + ([$hover, $scrub, $bisected, $cursor]) => ({ + hover: $hover, + scrub: $scrub, + bisectedPoint: $bisected, + cursorStyle: $cursor, + }), + ); + + function getScrubMode(hoverX: number): ScrubMode { + const $scrub = get(scrubState); + const $scales = get(scalesStore); + + if ($scrub.startIndex === null || $scrub.endIndex === null) return "create"; + + const startX = $scales.x($scrub.startIndex); + const endX = $scales.x($scrub.endIndex); + + if (Math.abs(hoverX - startX) <= EDGE_THRESHOLD) return "resize-start"; + if (Math.abs(hoverX - endX) <= EDGE_THRESHOLD) return "resize-end"; + + const minX = Math.min(startX, endX); + const maxX = Math.max(startX, endX); + if (hoverX > minX + EDGE_THRESHOLD && hoverX < maxX - EDGE_THRESHOLD) return "move"; + + return "create"; + } + + function resetScrub(): void { + scrubState.set(EMPTY_SCRUB_STATE); + scrubMode = "none"; + scrubMoveStartX = null; + scrubMoveStartIndices = null; + } + + const handlers: InteractionHandlers = { + onMouseMove(event: MouseEvent) { + const $scales = get(scalesStore); + const $bounds = get(plotBoundsStore); + + const x = Math.max($bounds.left, Math.min($bounds.left + $bounds.width, event.offsetX)); + const fractionalIndex = $scales.x.invert(x); + + hoverState.set({ + index: fractionalIndex, + screenX: x, + isHovered: true, + }); + + const $scrub = get(scrubState); + const $range = get(visibleRangeStore); + if ($scrub.isScrubbing) { + const snappedIndex = snap(fractionalIndex, $range); + switch (scrubMode) { + case "create": + case "resize-end": + scrubState.update((s) => ({ ...s, endIndex: snappedIndex })); + break; + case "resize-start": + scrubState.update((s) => ({ ...s, startIndex: snappedIndex })); + break; + case "move": + if (scrubMoveStartX !== null && scrubMoveStartIndices) { + const deltaX = x - scrubMoveStartX; + const startPx = $scales.x(scrubMoveStartIndices.start!); + const endPx = $scales.x(scrubMoveStartIndices.end!); + scrubState.update((s) => ({ + ...s, + startIndex: snap($scales.x.invert(startPx + deltaX), $range), + endIndex: snap($scales.x.invert(endPx + deltaX), $range), + })); + } + break; + } + } + }, + + onMouseLeave() { + hoverState.set(EMPTY_HOVER_STATE); + }, + + onMouseDown(event: MouseEvent) { + if (event.button !== 0) return; + const $scales = get(scalesStore); + const $scrub = get(scrubState); + const $range = get(visibleRangeStore); + const x = event.offsetX; + const idx = snap($scales.x.invert(x), $range); + + scrubMode = getScrubMode(x); + + if (scrubMode === "move") { + scrubMoveStartX = x; + scrubMoveStartIndices = { start: $scrub.startIndex, end: $scrub.endIndex }; + scrubState.update((s) => ({ ...s, isScrubbing: true })); + } else if (scrubMode === "create") { + scrubState.set({ startIndex: idx, endIndex: idx, isScrubbing: true }); + } else { + scrubState.update((s) => ({ ...s, isScrubbing: true })); + } + }, + + onMouseUp() { + const $scrub = get(scrubState); + if ($scrub.isScrubbing) { + if ( + $scrub.startIndex !== null && + $scrub.endIndex !== null && + Math.abs($scrub.startIndex - $scrub.endIndex) < 0.5 + ) { + resetScrub(); + } else { + scrubState.update((s) => ({ ...s, isScrubbing: false })); + } + } + scrubMode = "none"; + scrubMoveStartX = null; + scrubMoveStartIndices = null; + }, + + onClick(event: MouseEvent) { + const $scrub = get(scrubState); + const $scales = get(scalesStore); + + if ($scrub.startIndex !== null && $scrub.endIndex !== null && !$scrub.isScrubbing) { + const clickIdx = $scales.x.invert(event.offsetX); + const [min, max] = + $scrub.startIndex < $scrub.endIndex + ? [$scrub.startIndex, $scrub.endIndex] + : [$scrub.endIndex, $scrub.startIndex]; + if (clickIdx < min || clickIdx > max) { + resetScrub(); + } + } + }, + }; + + return { state, handlers, resetScrub }; +} + +/** + * Helper to get ordered start/end dates. + */ +export function getOrderedDates( + start: Date | null, + end: Date | null, +): { start: Date | null; end: Date | null } { + if (!start || !end) return { start, end }; + return start.getTime() > end.getTime() ? { start: end, end: start } : { start, end }; +} diff --git a/web-common/src/features/dashboards/time-series/measure-chart/scales.ts b/web-common/src/features/dashboards/time-series/measure-chart/scales.ts new file mode 100644 index 00000000000..98a8bd03c79 --- /dev/null +++ b/web-common/src/features/dashboards/time-series/measure-chart/scales.ts @@ -0,0 +1,209 @@ +import { scaleLinear } from "d3-scale"; +import { max, min } from "d3-array"; +import type { + TimeSeriesPoint, + DimensionSeriesData, + ChartConfig, + ChartScales, + PlotBounds, +} from "./types"; + +interface ExtentConfig { + includeZero: boolean; + paddingFactor: number; + minRange?: number; +} + +/** + * Default extent configuration. + */ +const DEFAULT_EXTENT_CONFIG: ExtentConfig = { + includeZero: true, + paddingFactor: 6 / 5, // 20% padding on the upper bound +}; + +/** + * Compute nice Y extent with padding. + * Sets extents to 0 if it makes sense; otherwise, inflates each extent component. + * Ported from utils.ts niceMeasureExtents. + */ +export function computeNiceYExtent( + smallest: number, + largest: number, + config: ExtentConfig = DEFAULT_EXTENT_CONFIG, +): [number, number] { + const { includeZero, paddingFactor, minRange } = config; + + // Handle edge case where both are 0 + if (smallest === 0 && largest === 0) { + return [0, 1]; + } + + // Handle NaN or invalid values + if (!Number.isFinite(smallest) || !Number.isFinite(largest)) { + return [0, 1]; + } + + let yMin: number; + let yMax: number; + + if (includeZero) { + // Include zero in the extent when appropriate + yMin = smallest < 0 ? smallest * paddingFactor : 0; + yMax = largest > 0 ? largest * paddingFactor : 0; + } else { + yMin = smallest * paddingFactor; + yMax = largest * paddingFactor; + } + + // Ensure minimum range if specified + if (minRange !== undefined && yMax - yMin < minRange) { + const mid = (yMin + yMax) / 2; + yMin = mid - minRange / 2; + yMax = mid + minRange / 2; + } + + return [yMin, yMax]; +} + +/** + * Compute combined Y extent from all data sources. + * Includes main data, comparison data, and dimension data. + */ +export function computeYExtent( + data: TimeSeriesPoint[], + dimensionData: DimensionSeriesData[], + showComparison: boolean, +): [number, number] { + const values: number[] = []; + const hasDimensionData = dimensionData.length > 0; + + // Main data values — skip when dimension comparison is active, + // since only individual dimension series are rendered (not the aggregate). + if (!hasDimensionData) { + for (const d of data) { + if (d.value !== null && Number.isFinite(d.value)) { + values.push(d.value); + } + if ( + showComparison && + d.comparisonValue !== null && + d.comparisonValue !== undefined && + Number.isFinite(d.comparisonValue) + ) { + values.push(d.comparisonValue); + } + } + } + + // Dimension data values + for (const dim of dimensionData) { + for (const d of dim.data) { + if (d.value !== null && Number.isFinite(d.value)) { + values.push(d.value); + } + } + } + + if (values.length === 0) { + return [0, 1]; + } + + return [min(values) ?? 0, max(values) ?? 1]; +} + +/** + * Compute X extent as index range [0, N-1]. + */ +export function computeXExtent(data: TimeSeriesPoint[]): [number, number] { + return [0, Math.max(0, data.length - 1)]; +} + +/** + * Create index-based X scale. + */ +export function createXScale( + data: TimeSeriesPoint[], + plotBounds: PlotBounds, +): ChartScales["x"] { + return scaleLinear() + .domain(computeXExtent(data)) + .range([plotBounds.left, plotBounds.left + plotBounds.width]); +} + +/** + * Create Y scale from value extent. + */ +export function createYScale( + yExtent: [number, number], + plotBounds: PlotBounds, +): ChartScales["y"] { + return scaleLinear() + .domain(yExtent) + .range([plotBounds.top + plotBounds.height, plotBounds.top]); // Inverted for SVG +} + +/** + * Create both X and Y scales. + */ +export function createScales( + data: TimeSeriesPoint[], + dimensionData: DimensionSeriesData[], + showComparison: boolean, + plotBounds: PlotBounds, +): ChartScales { + const yRawExtent = computeYExtent(data, dimensionData, showComparison); + const yExtent = computeNiceYExtent(yRawExtent[0], yRawExtent[1]); + + return { + x: createXScale(data, plotBounds), + y: createYScale(yExtent, plotBounds), + }; +} + +/** + * Compute chart configuration from dimensions. + */ +export function computeChartConfig( + width: number, + height: number, + isExpanded: boolean, +): ChartConfig { + const margin = { + top: 4, + right: 40, + bottom: isExpanded ? 25 : 10, + left: 0, + }; + + const plotWidth = Math.max(0, width - margin.left - margin.right); + const plotHeight = Math.max(0, height - margin.top - margin.bottom); + + return { + width, + height, + margin, + plotBounds: { + left: margin.left, + right: margin.left + plotWidth, + top: margin.top, + bottom: margin.top + plotHeight, + width: plotWidth, + height: plotHeight, + }, + }; +} + +/** + * Update scales with new data while preserving animation continuity. + */ +export function updateScalesWithData( + existingScales: ChartScales | null, + data: TimeSeriesPoint[], + dimensionData: DimensionSeriesData[], + showComparison: boolean, + plotBounds: PlotBounds, +): ChartScales { + // Always recompute scales - animation is handled by tweening the domain values + return createScales(data, dimensionData, showComparison, plotBounds); +} diff --git a/web-common/src/features/dashboards/time-series/measure-chart/types.ts b/web-common/src/features/dashboards/time-series/measure-chart/types.ts new file mode 100644 index 00000000000..ca9d0cda441 --- /dev/null +++ b/web-common/src/features/dashboards/time-series/measure-chart/types.ts @@ -0,0 +1,169 @@ +import type { ScaleLinear } from "d3-scale"; +import type { MetricsViewSpecMeasure } from "@rilldata/web-common/runtime-client"; +import type { DateTime } from "luxon"; + +/** + * Strongly-typed time series data point. + * Replaces the string-based accessor pattern from TimeSeriesDatum. + */ +export interface TimeSeriesPoint { + /** Primary timestamp for the data point */ + ts: DateTime; + /** The measure value (nullable for gaps) */ + value: number | null; + /** Comparison value when time comparison is active */ + comparisonValue?: number | null; + /** Comparison timestamp */ + comparisonTs?: DateTime; +} + +/** + * Dimension comparison data item. + */ +export interface DimensionSeriesData { + /** Dimension value (e.g., "USA", "Canada") */ + dimensionValue: string | null; + /** Color for this dimension series */ + color: string; + /** Time series data for this dimension */ + data: TimeSeriesPoint[]; + /** Loading state */ + isFetching: boolean; + /** Total value for percent calculations */ + total?: number; +} + +/** + * Chart margin configuration. + */ +export interface ChartMargin { + top: number; + right: number; + bottom: number; + left: number; +} + +/** + * Computed plot bounds within the SVG. + */ +export interface PlotBounds { + left: number; + right: number; + top: number; + bottom: number; + width: number; + height: number; +} + +/** + * Chart configuration (replaces GraphicContext props). + */ +export interface ChartConfig { + width: number; + height: number; + margin: ChartMargin; + plotBounds: PlotBounds; +} + +/** + * Scale types for the chart. + */ +export interface ChartScales { + x: ScaleLinear; + y: ScaleLinear; +} + +/** + * Scrub/selection state. + */ +export interface ScrubState { + /** Start index (fractional, from xScale.invert) */ + startIndex: number | null; + /** End index (fractional, from xScale.invert) */ + endIndex: number | null; + isScrubbing: boolean; +} + +/** + * Mouseover/hover state. + */ +export interface HoverState { + /** Hovered index (fractional) */ + index: number | null; + /** Screen x coordinate */ + screenX: number | null; + /** Is mouse currently over the chart */ + isHovered: boolean; +} + +/** + * Bisected point — just the snapped index. + */ +export interface BisectedPoint { + /** Nearest data index */ + index: number; +} + +/** + * Combined interaction state. + */ +export interface InteractionState { + hover: HoverState; + scrub: ScrubState; + bisectedPoint: BisectedPoint; + cursorStyle: string; +} + +/** + * Event handlers for chart interactions. + */ +export interface InteractionHandlers { + onMouseMove: (event: MouseEvent) => void; + onMouseLeave: () => void; + onMouseDown: (event: MouseEvent) => void; + onMouseUp: (event: MouseEvent) => void; + onClick: (event: MouseEvent) => void; +} + +/** + * Props for the new MeasureChart component. + * Reduced from 41 to ~15 essential props. + */ +export interface MeasureChartProps { + /** The measure specification */ + measure: MetricsViewSpecMeasure; + /** Explorer name for state management */ + exploreName: string; + /** Whether to show time comparison overlay */ + showComparison?: boolean; + /** Whether showing expanded TDD view */ + showTimeDimensionDetail?: boolean; + /** Chart width (auto-calculated if not provided) */ + width?: number; + /** Chart height (auto-calculated if not provided) */ + height?: number; +} + +/** + * A generic series descriptor for the pure TimeSeriesChart renderer. + * Decoupled from measure/dimension semantics. + */ +export interface ChartSeries { + /** Unique identifier for this series */ + id: string; + /** Values array — one per bucket, null for gaps */ + values: (number | null)[]; + /** Stroke/fill color */ + color: string; + /** Dash pattern for the stroke, e.g. "4,4" for comparison lines */ + strokeDasharray?: string; + /** Opacity override (default 1) */ + opacity?: number; + /** Area gradient colors — only the first/primary series typically gets this */ + areaGradient?: { dark: string; light: string }; +} + +/** + * Rendering mode for TimeSeriesChart. + */ +export type ChartMode = "line" | "bar" | "stacked-bar"; diff --git a/web-common/src/features/dashboards/time-series/measure-chart/use-dimension-data.ts b/web-common/src/features/dashboards/time-series/measure-chart/use-dimension-data.ts new file mode 100644 index 00000000000..5cc9f842f42 --- /dev/null +++ b/web-common/src/features/dashboards/time-series/measure-chart/use-dimension-data.ts @@ -0,0 +1,169 @@ +import { + createAndExpression, + createInExpression, + filterExpressions, + sanitiseExpression, +} from "@rilldata/web-common/features/dashboards/stores/filter-utils"; +import { + createQueryServiceMetricsViewAggregation, + type V1Expression, + type V1MetricsViewAggregationResponse, + type V1TimeGrain, + type V1TimeSeriesValue, +} from "@rilldata/web-common/runtime-client"; +import type { HTTPError } from "@rilldata/web-common/runtime-client/fetchWrapper"; +import { + keepPreviousData, + type CreateQueryResult, +} from "@tanstack/svelte-query"; +import { + transformAggregateDimensionData, + prepareTimeSeries, +} from "../utils"; +import { COMPARIONS_COLORS } from "@rilldata/web-common/features/dashboards/config"; +import { TIME_GRAIN } from "@rilldata/web-common/lib/time/config"; +import { DateTime } from "luxon"; +import type { DimensionSeriesData, TimeSeriesPoint } from "./types"; +import type { V1MetricsViewTimeSeriesResponse } from "@rilldata/web-common/runtime-client"; +import { queryClient } from "@rilldata/web-common/lib/svelte-query/globalQueryClient"; + +/** + * Creates an aggregation query for dimension comparison data. + * Used by MeasureChart to fetch per-dimension-value time series. + */ +export function createDimensionAggregationQuery( + instanceId: string, + metricsViewName: string, + measureName: string, + dimensionName: string, + dimensionValues: (string | null)[], + where: V1Expression | undefined, + timeDimension: string, + timeStart: string | undefined, + timeEnd: string | undefined, + timeGranularity: V1TimeGrain, + timeZone: string, + enabled: boolean, +): CreateQueryResult { + const updatedFilter = where + ? (filterExpressions(where, () => true) ?? createAndExpression([])) + : createAndExpression([]); + updatedFilter.cond?.exprs?.push( + createInExpression(dimensionName, dimensionValues), + ); + + return createQueryServiceMetricsViewAggregation( + instanceId, + metricsViewName, + { + measures: [{ name: measureName }], + dimensions: [ + { name: dimensionName }, + { name: timeDimension, timeGrain: timeGranularity, timeZone }, + ], + where: sanitiseExpression(updatedFilter, undefined), + timeRange: { + start: timeStart, + end: timeEnd, + timeDimension, + }, + sort: [ + { desc: true, name: measureName }, + { desc: false, name: timeDimension }, + ], + limit: "10000", + offset: "0", + }, + { + query: { + enabled: enabled && dimensionValues.length > 0, + placeholderData: keepPreviousData, + }, + }, + queryClient, + ); +} + +/** + * Pure function: transforms aggregation response data into DimensionSeriesData[]. + */ +export function buildDimensionSeriesData( + measureName: string, + dimensionName: string, + dimensionValues: (string | null)[], + timeDimension: string, + timeGranularity: V1TimeGrain, + timeZone: string, + primaryTimeSeriesData: V1MetricsViewTimeSeriesResponse["data"], + aggData: V1MetricsViewAggregationResponse["data"], + comparisonTimeSeriesData: V1MetricsViewTimeSeriesResponse["data"] | undefined, + compAggData: V1MetricsViewAggregationResponse["data"] | undefined, + isFetching: boolean, +): DimensionSeriesData[] { + if (!dimensionValues.length || !primaryTimeSeriesData?.length) return []; + + const measures = [measureName]; + + const transformedData = transformAggregateDimensionData( + timeDimension, + dimensionName, + measures, + dimensionValues, + primaryTimeSeriesData, + aggData || [], + ); + + let comparisonData: V1TimeSeriesValue[][] = []; + if (comparisonTimeSeriesData && compAggData) { + comparisonData = transformAggregateDimensionData( + timeDimension, + dimensionName, + measures, + dimensionValues, + comparisonTimeSeriesData, + compAggData, + ); + } + + const grainDuration = TIME_GRAIN[timeGranularity]?.duration; + const results: DimensionSeriesData[] = []; + + for (let i = 0; i < dimensionValues.length; i++) { + const prepData = prepareTimeSeries( + transformedData[i], + comparisonData[i], + grainDuration, + timeZone, + ); + + const data: TimeSeriesPoint[] = prepData.map((datum) => { + const compKey = `comparison.${measureName}`; + const compTsKey = "comparison.ts"; + return { + ts: datum.ts + ? DateTime.fromJSDate(datum.ts, { zone: timeZone }) + : DateTime.invalid("missing"), + value: (datum[measureName] as number | null) ?? null, + comparisonValue: + compKey in datum + ? ((datum[compKey] as number | null) ?? null) + : undefined, + comparisonTs: + compTsKey in datum && datum[compTsKey] + ? DateTime.fromJSDate(datum[compTsKey] as Date, { + zone: timeZone, + }) + : undefined, + }; + }); + + results.push({ + dimensionValue: dimensionValues[i], + color: COMPARIONS_COLORS[i] || "", + data, + isFetching, + }); + } + + return results; +} diff --git a/web-common/src/features/dashboards/time-series/measure-chart/use-measure-time-series.ts b/web-common/src/features/dashboards/time-series/measure-chart/use-measure-time-series.ts new file mode 100644 index 00000000000..83e78c12c06 --- /dev/null +++ b/web-common/src/features/dashboards/time-series/measure-chart/use-measure-time-series.ts @@ -0,0 +1,174 @@ +import { derived, type Readable } from "svelte/store"; +import { + createQueryServiceMetricsViewTimeSeries, + type V1MetricsViewTimeSeriesResponse, +} from "@rilldata/web-common/runtime-client"; +import type { HTTPError } from "@rilldata/web-common/runtime-client/fetchWrapper"; +import { + keepPreviousData, + type CreateQueryResult, +} from "@tanstack/svelte-query"; +import { mergeDimensionAndMeasureFilters } from "@rilldata/web-common/features/dashboards/filters/measure-filters/measure-filter-utils"; +import { sanitiseExpression } from "@rilldata/web-common/features/dashboards/stores/filter-utils"; +import { useTimeControlStore } from "@rilldata/web-common/features/dashboards/time-controls/time-control-store"; +import type { StateManagers } from "@rilldata/web-common/features/dashboards/state-managers/state-managers"; +import { isGrainAllowed } from "@rilldata/web-common/lib/time/new-grains"; +import type { TimeSeriesPoint } from "./types"; +import { DateTime } from "luxon"; + +/** + * Create a time series query for a single measure. + * Each MeasureChart component creates its own query, enabling: + * - Lazy loading (only fetch when visible) + * - Per-measure error handling + * - Independent loading states + */ +export function useMeasureTimeSeries( + ctx: StateManagers, + measureName: string, + visible: Readable, + isComparison = false, +): CreateQueryResult { + return derived( + [ + ctx.runtime, + ctx.metricsViewName, + ctx.dashboardStore, + useTimeControlStore(ctx), + visible, + ], + ( + [runtime, metricsViewName, dashboardStore, timeControls, isVisible], + set, + ) => { + const timeGrain = isGrainAllowed( + timeControls.selectedTimeRange?.interval, + timeControls.minTimeGrain, + ) + ? timeControls.selectedTimeRange?.interval + : timeControls.minTimeGrain; + + return createQueryServiceMetricsViewTimeSeries( + runtime.instanceId, + metricsViewName, + { + measureNames: [measureName], // Single measure! + where: sanitiseExpression( + mergeDimensionAndMeasureFilters( + dashboardStore.whereFilter, + dashboardStore.dimensionThresholdFilters, + ), + undefined, + ), + timeStart: isComparison + ? timeControls.comparisonAdjustedStart + : timeControls.adjustedStart, + timeEnd: isComparison + ? timeControls.comparisonAdjustedEnd + : timeControls.adjustedEnd, + timeGranularity: timeGrain, + timeZone: dashboardStore.selectedTimezone, + }, + { + query: { + // Only fetch when visible AND time controls are ready + enabled: + isVisible && + !!timeControls.ready && + !!ctx.dashboardStore && + (!isComparison || !!timeControls.comparisonAdjustedStart), + placeholderData: keepPreviousData, + refetchOnMount: false, + }, + }, + ctx.queryClient, + ).subscribe(set); + }, + ); +} + +/** + * Transform raw API time series data to typed TimeSeriesPoint[]. + * Minimal processing: just extract ts, value, and comparison fields. + * No intermediate position computation — rendering uses indices directly. + */ +export function transformTimeSeriesData( + primary: V1MetricsViewTimeSeriesResponse["data"], + comparison: V1MetricsViewTimeSeriesResponse["data"] | undefined, + measureName: string, + timezone: string, +): TimeSeriesPoint[] { + if (!primary) return []; + + return primary.map((originalPt, i) => { + const comparisonPt = comparison?.[i]; + + if (!originalPt?.ts) { + return { ts: DateTime.invalid("Invalid timestamp"), value: null }; + } + + const ts = DateTime.fromISO(originalPt.ts, { zone: timezone }); + + if (!ts || typeof ts === "string") { + return { ts: DateTime.invalid("Invalid timestamp"), value: null }; + } + + const value = (originalPt.records?.[measureName] as number | null) ?? null; + + let comparisonValue: number | null | undefined = undefined; + let comparisonTs: DateTime | undefined = undefined; + + if (comparisonPt?.ts) { + comparisonValue = + (comparisonPt.records?.[measureName] as number | null) ?? null; + comparisonTs = DateTime.fromISO(comparisonPt.ts, { zone: timezone }); + } + + return { ts, value, comparisonValue, comparisonTs }; + }); +} + +/** + * Create a derived store that transforms raw query data to TimeSeriesPoint[]. + */ +export function useMeasureTimeSeriesData( + ctx: StateManagers, + measureName: string, + visible: Readable, + showComparison: Readable, +): Readable<{ + data: TimeSeriesPoint[]; + isFetching: boolean; + isError: boolean; + error: string | undefined; +}> { + const primaryQuery = useMeasureTimeSeries(ctx, measureName, visible, false); + + return derived( + [primaryQuery, showComparison, ctx.dashboardStore], + ([$primary, _$showComparison, $dashboard]) => { + if ($primary.isFetching || !$primary.data?.data) { + return { + data: [], + isFetching: $primary.isFetching, + isError: $primary.isError, + error: ($primary.error as HTTPError)?.response?.data?.message, + }; + } + + const data = transformTimeSeriesData( + $primary.data.data, + undefined, + measureName, + $dashboard.selectedTimezone, + ); + + return { + data, + isFetching: false, + isError: $primary.isError, + error: ($primary.error as HTTPError)?.response?.data?.message, + }; + }, + ); +} diff --git a/web-common/src/features/dashboards/time-series/measure-chart/use-measure-totals.ts b/web-common/src/features/dashboards/time-series/measure-chart/use-measure-totals.ts new file mode 100644 index 00000000000..f9475dd809c --- /dev/null +++ b/web-common/src/features/dashboards/time-series/measure-chart/use-measure-totals.ts @@ -0,0 +1,163 @@ +import { derived, type Readable } from "svelte/store"; +import { + createQueryServiceMetricsViewAggregation, + type V1MetricsViewAggregationResponse, +} from "@rilldata/web-common/runtime-client"; +import type { HTTPError } from "@rilldata/web-common/runtime-client/fetchWrapper"; +import { keepPreviousData, type CreateQueryResult } from "@tanstack/svelte-query"; +import { mergeDimensionAndMeasureFilters } from "@rilldata/web-common/features/dashboards/filters/measure-filters/measure-filter-utils"; +import { sanitiseExpression } from "@rilldata/web-common/features/dashboards/stores/filter-utils"; +import { useTimeControlStore } from "@rilldata/web-common/features/dashboards/time-controls/time-control-store"; +import type { StateManagers } from "@rilldata/web-common/features/dashboards/state-managers/state-managers"; + +/** + * Totals data for a single measure. + */ +export interface MeasureTotalsData { + value: number | null; + comparisonValue: number | null; + isFetching: boolean; + isError: boolean; + error: string | undefined; +} + +/** + * Create a totals query for a single measure. + * Used for the big number display above each chart. + */ +export function useMeasureTotals( + ctx: StateManagers, + measureName: string, + visible: Readable, + isComparison = false, +): CreateQueryResult { + return derived( + [ + ctx.runtime, + ctx.metricsViewName, + useTimeControlStore(ctx), + ctx.dashboardStore, + visible, + ], + ([runtime, metricsViewName, timeControls, dashboard, isVisible], set) => + createQueryServiceMetricsViewAggregation( + runtime.instanceId, + metricsViewName, + { + measures: [{ name: measureName }], + where: sanitiseExpression( + mergeDimensionAndMeasureFilters( + dashboard.whereFilter, + dashboard.dimensionThresholdFilters, + ), + undefined, + ), + timeRange: { + start: isComparison + ? timeControls?.comparisonTimeStart + : timeControls.timeStart, + end: isComparison + ? timeControls?.comparisonTimeEnd + : timeControls.timeEnd, + }, + }, + { + query: { + enabled: + isVisible && + !!timeControls.ready && + !!ctx.dashboardStore && + (!isComparison || !!timeControls.comparisonTimeStart), + placeholderData: keepPreviousData, + refetchOnMount: false, + }, + }, + ctx.queryClient, + ).subscribe(set), + ); +} + +/** + * Create a derived store that provides totals data for a measure. + * Includes both primary and comparison values. + */ +export function useMeasureTotalsData( + ctx: StateManagers, + measureName: string, + visible: Readable, + showComparison: Readable, +): Readable { + const primaryQuery = useMeasureTotals(ctx, measureName, visible, false); + + // Create comparison query only when comparison is enabled + const comparisonQuery = derived( + [showComparison, visible], + ([$showComparison, $visible]) => { + if (!$showComparison) { + return null; + } + return useMeasureTotals(ctx, measureName, visible, true); + }, + ); + + return derived( + [primaryQuery, comparisonQuery, showComparison], + ([$primary, $comparisonQueryStore, $showComparison]) => { + // Get primary value + const primaryValue = + ($primary.data?.data?.[0]?.[measureName] as number | null) ?? null; + + // Get comparison value if applicable + let comparisonValue: number | null = null; + let comparisonIsFetching = false; + let comparisonIsError = false; + let comparisonError: string | undefined; + + if ($showComparison && $comparisonQueryStore) { + // Note: This is a simplified version. In full implementation, + // we'd need to properly subscribe to the comparison query. + // For now, comparison is handled at the parent level. + } + + return { + value: primaryValue, + comparisonValue, + isFetching: $primary.isFetching || comparisonIsFetching, + isError: $primary.isError || comparisonIsError, + error: + ($primary.error as HTTPError)?.response?.data?.message ?? + comparisonError, + }; + }, + ); +} + +/** + * Compute comparison metrics from primary and comparison values. + */ +export function computeComparisonMetrics( + value: number | null, + comparisonValue: number | null, +): { + delta: number | null; + deltaPercent: number | null; + isPositive: boolean | null; +} { + if (value === null || comparisonValue === null || comparisonValue === 0) { + return { + delta: null, + deltaPercent: null, + isPositive: null, + }; + } + + const delta = value - comparisonValue; + const deltaPercent = (delta / Math.abs(comparisonValue)) * 100; + const isPositive = delta >= 0; + + return { + delta, + deltaPercent, + isPositive, + }; +} diff --git a/web-common/src/features/dashboards/time-series/measure-selection/ExplainButton.svelte b/web-common/src/features/dashboards/time-series/measure-selection/ExplainButton.svelte deleted file mode 100644 index 1b16eb5e7b0..00000000000 --- a/web-common/src/features/dashboards/time-series/measure-selection/ExplainButton.svelte +++ /dev/null @@ -1,39 +0,0 @@ - - -{#if forThisMeasure} -
- -
-{/if} diff --git a/web-common/src/features/dashboards/time-series/utils.ts b/web-common/src/features/dashboards/time-series/utils.ts index 40b292f724d..f6bb1b9fccd 100644 --- a/web-common/src/features/dashboards/time-series/utils.ts +++ b/web-common/src/features/dashboards/time-series/utils.ts @@ -6,14 +6,12 @@ import { filterExpressions, matchExpressionByName, } from "@rilldata/web-common/features/dashboards/stores/filter-utils"; -import { chartInteractionColumn } from "@rilldata/web-common/features/dashboards/time-dimension-details/time-dimension-data-store"; import type { V1Expression, V1MetricsViewAggregationResponseDataItem, V1TimeSeriesValue, } from "@rilldata/web-common/runtime-client"; import type { DateTimeUnit } from "luxon"; -import { get } from "svelte/store"; import { convertISOStringToJSDateWithSameTimeAsSelectedTimeZone, removeZoneOffset, @@ -58,42 +56,6 @@ export function toComparisonKeys(d, offsetDuration: string, zone: string) { }, {}); } -export function updateChartInteractionStore( - xHoverValue: undefined | number | Date, - yHoverValue: undefined | string | null, - isAllTime: boolean, - formattedData: TimeSeriesDatum[], -) { - let xHoverColNum: number | undefined = undefined; - - const slicedData = isAllTime - ? formattedData?.slice(1) - : formattedData?.slice(1, -1); - - if (xHoverValue && xHoverValue instanceof Date) { - const { position } = bisectData( - xHoverValue, - "center", - "ts_position", - slicedData, - ); - xHoverColNum = position; - } - - const currentCol = get(chartInteractionColumn); - - if ( - currentCol?.xHover !== xHoverColNum || - currentCol?.yHover !== yHoverValue - ) { - chartInteractionColumn.update((state) => ({ - ...state, - yHover: yHoverValue, - xHover: xHoverColNum, - })); - } -} - export function prepareTimeSeries( original: V1TimeSeriesValue[], comparison: V1TimeSeriesValue[] | undefined, diff --git a/web-common/src/features/feature-flags.ts b/web-common/src/features/feature-flags.ts index 8240d6369ca..eb5f4242201 100644 --- a/web-common/src/features/feature-flags.ts +++ b/web-common/src/features/feature-flags.ts @@ -62,7 +62,7 @@ class FeatureFlags { alerts = new FeatureFlag("user", true); reports = new FeatureFlag("user", true); chat = new FeatureFlag("user", true); - dashboardChat = new FeatureFlag("user", false); + dashboardChat = new FeatureFlag("user", true); developerChat = new FeatureFlag("user", false); deploy = new FeatureFlag("user", true); generateCanvas = new FeatureFlag("user", false); From 874f98fb066bfdd204e065661284651a7c079c7c Mon Sep 17 00:00:00 2001 From: Brian Holmes Date: Tue, 3 Feb 2026 13:37:23 -0500 Subject: [PATCH 02/36] more work --- .../data-graphic/elements/Body.svelte | 117 ----- .../components/data-graphic/elements/index.ts | 1 - .../data-graphic/guides/Axis.svelte | 208 -------- .../data-graphic/guides/Grid.svelte | 66 --- .../components/data-graphic/guides/index.ts | 2 - .../time-series-chart/BarChart.svelte | 31 +- .../time-series-chart/TimeSeriesChart.svelte | 152 +++--- .../dashboards/time-series/ChartBody.svelte | 168 ------- .../time-series/ChartInteractions.svelte | 1 + .../DimensionValueMouseover.svelte | 149 ------ .../time-series/MeasureChart.svelte | 465 ------------------ .../time-series/MeasureScrub.svelte | 308 ------------ .../time-series/MeasureValueMouseover.svelte | 229 --------- .../MetricsTimeSeriesCharts.svelte | 57 ++- .../measure-chart/MeasureChart.svelte | 445 ++++++++++------- .../MeasureChartHoverTooltip.svelte | 186 +++++++ .../measure-chart/MeasureChartLines.svelte | 217 -------- .../MeasureChartPointIndicator.svelte | 25 +- .../measure-chart/MeasureChartTooltip.svelte | 285 +++-------- .../measure-chart/ScrubController.ts | 199 ++++++++ .../time-series/measure-chart/index.ts | 4 +- .../time-series/measure-chart/interactions.ts | 228 +-------- .../time-series/measure-chart/scales.ts | 4 +- .../time-series/measure-chart/types.ts | 8 +- .../measure-selection/MeasureSelection.svelte | 82 --- 25 files changed, 882 insertions(+), 2755 deletions(-) delete mode 100644 web-common/src/components/data-graphic/elements/Body.svelte delete mode 100644 web-common/src/components/data-graphic/guides/Axis.svelte delete mode 100644 web-common/src/components/data-graphic/guides/Grid.svelte delete mode 100644 web-common/src/features/dashboards/time-series/ChartBody.svelte delete mode 100644 web-common/src/features/dashboards/time-series/DimensionValueMouseover.svelte delete mode 100644 web-common/src/features/dashboards/time-series/MeasureChart.svelte delete mode 100644 web-common/src/features/dashboards/time-series/MeasureScrub.svelte delete mode 100644 web-common/src/features/dashboards/time-series/MeasureValueMouseover.svelte create mode 100644 web-common/src/features/dashboards/time-series/measure-chart/MeasureChartHoverTooltip.svelte delete mode 100644 web-common/src/features/dashboards/time-series/measure-chart/MeasureChartLines.svelte create mode 100644 web-common/src/features/dashboards/time-series/measure-chart/ScrubController.ts delete mode 100644 web-common/src/features/dashboards/time-series/measure-selection/MeasureSelection.svelte diff --git a/web-common/src/components/data-graphic/elements/Body.svelte b/web-common/src/components/data-graphic/elements/Body.svelte deleted file mode 100644 index f3eeab87649..00000000000 --- a/web-common/src/components/data-graphic/elements/Body.svelte +++ /dev/null @@ -1,117 +0,0 @@ - - - - - - - -{#if bg} - -{/if} - - - - - - {#if leftBorder} - - {/if} - {#if rightBorder} - - {/if} - {#if topBorder} - - {/if} - {#if bottomBorder} - - {/if} - diff --git a/web-common/src/components/data-graphic/elements/index.ts b/web-common/src/components/data-graphic/elements/index.ts index fcf67660cca..ba37f42c62b 100644 --- a/web-common/src/components/data-graphic/elements/index.ts +++ b/web-common/src/components/data-graphic/elements/index.ts @@ -1,4 +1,3 @@ -export { default as Body } from "./Body.svelte"; export { default as GraphicContext } from "./GraphicContext.svelte"; export { default as SimpleDataGraphic } from "./SimpleDataGraphic.svelte"; export { default as SimpleSVGContainer } from "./SimpleSVGContainer.svelte"; diff --git a/web-common/src/components/data-graphic/guides/Axis.svelte b/web-common/src/components/data-graphic/guides/Axis.svelte deleted file mode 100644 index 3e17e2a0a3c..00000000000 --- a/web-common/src/components/data-graphic/guides/Axis.svelte +++ /dev/null @@ -1,208 +0,0 @@ - - - - - {#each ticks as tick, i} - {@const tickPlacement = placeTick(side, tick)} - - {formatterFunction(tick)} - - {#if showTicks} - - - {/if} - {#if superLabelFormatter && shouldPlaceSuperLabel(superLabelFormatter(tick), i)} - - - {superLabelFormatter(tick)} - - {/if} - {/each} - diff --git a/web-common/src/components/data-graphic/guides/Grid.svelte b/web-common/src/components/data-graphic/guides/Grid.svelte deleted file mode 100644 index 0479f52a82f..00000000000 --- a/web-common/src/components/data-graphic/guides/Grid.svelte +++ /dev/null @@ -1,66 +0,0 @@ - - - - - {#if showX} - {#each xTicks as tick} - - {/each} - {/if} - {#if showY} - {#each yTicks as tick} - - {/each} - {/if} - diff --git a/web-common/src/components/data-graphic/guides/index.ts b/web-common/src/components/data-graphic/guides/index.ts index 3715a7e4e1c..a69391976d3 100644 --- a/web-common/src/components/data-graphic/guides/index.ts +++ b/web-common/src/components/data-graphic/guides/index.ts @@ -4,8 +4,6 @@ * Currently, they're a pastiche of components, without much * thought to how they should be organized or generalized. */ -export { default as Axis } from "./Axis.svelte"; export { default as DynamicallyPlacedLabel } from "./DynamicallyPlacedLabel.svelte"; -export { default as Grid } from "./Grid.svelte"; export { default as PointLabel } from "./PointLabel.svelte"; export { default as TimeSeriesMouseover } from "./TimeSeriesMouseover.svelte"; diff --git a/web-common/src/components/time-series-chart/BarChart.svelte b/web-common/src/components/time-series-chart/BarChart.svelte index 392b4f8e6b0..bd2b6623682 100644 --- a/web-common/src/components/time-series-chart/BarChart.svelte +++ b/web-common/src/components/time-series-chart/BarChart.svelte @@ -70,24 +70,39 @@ {/each} {:else} {@const barCount = series.length} - {@const singleBarWidth = bandWidth / barCount} + {@const barGap = barCount > 1 ? 2 : 0} + {@const totalGaps = barGap * (barCount - 1)} + {@const singleBarWidth = (bandWidth - totalGaps) / barCount} + {@const radius = 4} {#each { length: visibleCount } as _, slot (slot)} {@const ptIdx = visibleStart + slot} {@const cx = plotLeft + (slot + 0.5) * slotWidth} {#each series as s, sIdx (s.id)} {@const v = s.values[ptIdx] ?? null} {#if v !== null} - {@const bx = cx - bandWidth / 2 + sIdx * singleBarWidth} + {@const bx = cx - bandWidth / 2 + sIdx * (singleBarWidth + barGap)} {@const by = Math.min(zeroY, yScale(v))} {@const bh = Math.abs(zeroY - yScale(v))} - = 0} + {/if} {/each} diff --git a/web-common/src/components/time-series-chart/TimeSeriesChart.svelte b/web-common/src/components/time-series-chart/TimeSeriesChart.svelte index dc6640bbe96..92779f6e392 100644 --- a/web-common/src/components/time-series-chart/TimeSeriesChart.svelte +++ b/web-common/src/components/time-series-chart/TimeSeriesChart.svelte @@ -41,7 +41,8 @@ segStart = -1; } } - if (segStart !== -1) segments.push({ startIndex: segStart, endIndex: values.length - 1 }); + if (segStart !== -1) + segments.push({ startIndex: segStart, endIndex: values.length - 1 }); return segments; } @@ -55,14 +56,18 @@ // Direct path computation — no tweening $: primaryLinePath = primarySeries - ? lineGen(primarySeries.values) ?? "" + ? (lineGen(primarySeries.values) ?? "") : ""; $: primaryAreaPath = primarySeries - ? areaGen(primarySeries.values) ?? "" + ? (areaGen(primarySeries.values) ?? "") : ""; - $: primarySegments = primarySeries ? computeSegments(primarySeries.values) : []; - $: primarySingletons = primarySeries ? findSingletonIndices(primarySeries.values) : []; + $: primarySegments = primarySeries + ? computeSegments(primarySeries.values) + : []; + $: primarySingletons = primarySeries + ? findSingletonIndices(primarySeries.values) + : []; // Scrub clip region (index-based) $: scrubClipX = @@ -91,7 +96,6 @@ ? "var(--color-gray-50)" : primarySeries.areaGradient.light : "transparent"; - @@ -137,80 +141,86 @@ - {#if primarySeries?.areaGradient} - - {/if} - - - {#each series.slice(1) as s (s.id)} +{#if primarySeries?.areaGradient} + +{/if} + + +{#each series.slice(1) as s (s.id)} + + {#if hasScrubSelection && scrubStartIndex !== null && scrubEndIndex !== null} - {#if hasScrubSelection && scrubStartIndex !== null && scrubEndIndex !== null} - - {/if} - {/each} - - - {#if primarySeries} - - - - {#each primarySingletons as idx (idx)} - {@const v = primarySeries.values[idx] ?? 0} - - - {/each} {/if} +{/each} + + +{#if primarySeries} + + + + {#each primarySingletons as idx (idx)} + {@const v = primarySeries.values[idx] ?? 0} + + + {/each} +{/if} - - {#if hasScrubSelection && scrubStartIndex !== null && scrubEndIndex !== null && primarySeries} - {#if primarySeries.areaGradient} - - {/if} + +{#if hasScrubSelection && scrubStartIndex !== null && scrubEndIndex !== null && primarySeries} + {#if primarySeries.areaGradient} {/if} + +{/if} + + diff --git a/web-common/src/features/dashboards/time-series/ChartBody.svelte b/web-common/src/features/dashboards/time-series/ChartBody.svelte deleted file mode 100644 index be487570070..00000000000 --- a/web-common/src/features/dashboards/time-series/ChartBody.svelte +++ /dev/null @@ -1,168 +0,0 @@ - - - -{#key $timeRangeKey} - {#if dimensionData?.length} - {#each dimensionData as d} - {@const isHighlighted = d?.value === dimensionValue} - - - - {#if isHighlighted && showComparison} - - - - {/if} - {/each} - {:else} - {#if showComparison} - - - - {/if} - - {#if hasSubrangeSelected} - - {/if} - {/if} -{/key} diff --git a/web-common/src/features/dashboards/time-series/ChartInteractions.svelte b/web-common/src/features/dashboards/time-series/ChartInteractions.svelte index 278d7da4969..12c9292895f 100644 --- a/web-common/src/features/dashboards/time-series/ChartInteractions.svelte +++ b/web-common/src/features/dashboards/time-series/ChartInteractions.svelte @@ -117,6 +117,7 @@ } function zoomScrub() { + console.log("zoom"); if ( selectedScrubRange?.start instanceof Date && selectedScrubRange?.end instanceof Date diff --git a/web-common/src/features/dashboards/time-series/DimensionValueMouseover.svelte b/web-common/src/features/dashboards/time-series/DimensionValueMouseover.svelte deleted file mode 100644 index 537e2f98058..00000000000 --- a/web-common/src/features/dashboards/time-series/DimensionValueMouseover.svelte +++ /dev/null @@ -1,149 +0,0 @@ - - -{#if pointSet.length} - - - -{/if} diff --git a/web-common/src/features/dashboards/time-series/MeasureChart.svelte b/web-common/src/features/dashboards/time-series/MeasureChart.svelte deleted file mode 100644 index 8e3709590ce..00000000000 --- a/web-common/src/features/dashboards/time-series/MeasureChart.svelte +++ /dev/null @@ -1,465 +0,0 @@ - - - diff --git a/web-common/src/features/dashboards/time-series/MeasureScrub.svelte b/web-common/src/features/dashboards/time-series/MeasureScrub.svelte deleted file mode 100644 index 1d23b473ee1..00000000000 --- a/web-common/src/features/dashboards/time-series/MeasureScrub.svelte +++ /dev/null @@ -1,308 +0,0 @@ - - -{#if start && stop} - - {@const numStart = Number(start)} - {@const numStop = Number(stop)} - {@const xStart = xScale(Math.min(numStart, numStop))} - {@const xEnd = xScale(Math.max(numStart, numStop))} - - {#if showLabels} - - {mouseoverTimeFormat(Math.min(numStart, numStop))} - - - - {mouseoverTimeFormat(Math.max(numStart, numStop))} - - - {/if} - - - - onMouseUp()} - > - - - -{/if} - - - - - - - - - - diff --git a/web-common/src/features/dashboards/time-series/MeasureValueMouseover.svelte b/web-common/src/features/dashboards/time-series/MeasureValueMouseover.svelte deleted file mode 100644 index 35925a3f2b8..00000000000 --- a/web-common/src/features/dashboards/time-series/MeasureValueMouseover.svelte +++ /dev/null @@ -1,229 +0,0 @@ - - - - - - {#if !(currentPointIsNull || comparisonPointIsNull) && x !== undefined && y !== undefined} - {#if showComparison && Math.abs(output.y - output.dy) > 8} - {@const bufferSize = Math.abs(output.y - output.dy) > 16 ? 8 : 4} - {@const yBuffer = !hasValidComparisonPoint - ? 0 - : !comparisonIsPositive - ? -bufferSize - : bufferSize} - - {@const sign = !comparisonIsPositive ? -1 : 1} - {@const dist = 3} - {@const signedDist = sign * dist} - {@const yLoc = output.y + bufferSize * sign} - {@const show = - Math.abs(output.y - output.dy) > 16 && hasValidComparisonPoint} - arrows - - {#if show} - - - {/if} - - - - - - - - - - - - - {/if} - {/if} - {#if !showComparison && x !== undefined && y !== null && y !== undefined && !currentPointIsNull} - - {/if} - - - - - diff --git a/web-common/src/features/dashboards/time-series/MetricsTimeSeriesCharts.svelte b/web-common/src/features/dashboards/time-series/MetricsTimeSeriesCharts.svelte index 951854ac547..02f51fe43ae 100644 --- a/web-common/src/features/dashboards/time-series/MetricsTimeSeriesCharts.svelte +++ b/web-common/src/features/dashboards/time-series/MetricsTimeSeriesCharts.svelte @@ -42,6 +42,7 @@ import { featureFlags } from "../../feature-flags"; import MeasureBigNumber from "../big-number/MeasureBigNumber.svelte"; import { MeasureChart } from "./measure-chart"; + import { ScrubController } from "./measure-chart/ScrubController"; import { getAnnotationsForMeasure } from "./annotations-selectors"; import ChartInteractions from "./ChartInteractions.svelte"; import { chartHoveredTime } from "../time-dimension-details/time-dimension-data-store"; @@ -59,6 +60,9 @@ // Shared hover index store — all MeasureChart instances read/write this const sharedHoverIndex = writable(undefined); + // Singleton scrub controller — shared across all charts + const scrubController = new ScrubController(); + export let exploreName: string; export let hideStartPivotButton = false; @@ -77,17 +81,24 @@ const timeControlsStore = useTimeControlStore(ctx); $: ({ - adjustedStart, - adjustedEnd, selectedTimeRange, + selectedComparisonTimeRange, timeDimension, ready, - comparisonAdjustedStart, - comparisonAdjustedEnd, minTimeGrain, showTimeComparison, + timeEnd, + timeStart, + comparisonTimeEnd, + comparisonTimeStart, } = $timeControlsStore); + // Use the full selected time range for chart data fetching (not modified by scrub) + $: chartTimeStart = selectedTimeRange?.start?.toISOString(); + $: chartTimeEnd = selectedTimeRange?.end?.toISOString(); + $: chartComparisonTimeStart = selectedComparisonTimeRange?.start?.toISOString(); + $: chartComparisonTimeEnd = selectedComparisonTimeRange?.end?.toISOString(); + $: exploreState = useExploreState(exploreName); $: activePage = $exploreState?.activePage; @@ -157,16 +168,6 @@ undefined, ); - $: chartPrimaryTimeStart = selectedTimeRange?.start - ? DateTime.fromJSDate(selectedTimeRange.start, { - zone: chartTimeZone, - }) - : undefined; - $: chartPrimaryTimeEnd = selectedTimeRange?.end - ? DateTime.fromJSDate(selectedTimeRange.end, { - zone: chartTimeZone, - }) - : undefined; $: chartTimeZone = $dashboardStore.selectedTimezone; $: chartReady = !!ready; @@ -371,16 +372,17 @@ metricsViewName={chartMetricsViewName} where={chartWhere} {timeDimension} - timeStart={adjustedStart} - timeEnd={adjustedEnd} - comparisonTimeStart={comparisonAdjustedStart} - comparisonTimeEnd={comparisonAdjustedEnd} + {timeStart} + {timeEnd} + {comparisonTimeStart} + {comparisonTimeEnd} ready={chartReady} /> {#if effectiveGrain} { - chartHoveredTime.set(dt?.toJSDate()); + if (dt) { + // Convert to JS Date matching table's timezone handling: + // keepLocalTime: true preserves wall clock time when shifting to system zone + const systemTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; + chartHoveredTime.set(dt.setZone(systemTimeZone, { keepLocalTime: true }).toJSDate()); + } else { + chartHoveredTime.set(undefined); + } }} onScrub={handleScrub} onScrubClear={() => { diff --git a/web-common/src/features/dashboards/time-series/measure-chart/MeasureChart.svelte b/web-common/src/features/dashboards/time-series/measure-chart/MeasureChart.svelte index d0ce8aea3a3..d8ecb73ad1a 100644 --- a/web-common/src/features/dashboards/time-series/measure-chart/MeasureChart.svelte +++ b/web-common/src/features/dashboards/time-series/measure-chart/MeasureChart.svelte @@ -1,26 +1,24 @@ + +
+ {#if isComparingDimension} +
+
+ {formatDateTimeByGrain(currentTs, timeGranularity)} +
+ {#each dimTooltipEntries as entry (entry.label)} +
+ + {entry.label} + {formatter(entry.value)} +
+ {/each} +
+ {:else} +
+
+ {formatter(currentValue)} + + {formatDateTimeByGrain(currentTs, timeGranularity)} +
+ +
+
+ vs +
+ +
+ {formatter(comparisonValue)} + + {#if comparisonTs} + {formatDateTimeByGrain(comparisonTs, timeGranularity)} + {/if} + +
+
+ + {#if absoluteDelta !== null && deltaLabel} + + {/if} + {/if} +
+ + diff --git a/web-common/src/features/dashboards/time-series/measure-chart/MeasureChartLines.svelte b/web-common/src/features/dashboards/time-series/measure-chart/MeasureChartLines.svelte deleted file mode 100644 index 7e52cc0be5d..00000000000 --- a/web-common/src/features/dashboards/time-series/measure-chart/MeasureChartLines.svelte +++ /dev/null @@ -1,217 +0,0 @@ - - - - - - - - - - - - - - - - - - - {#each segments as segment (segment[0].ts.getTime())} - {@const x = scales.x(segment[0].tsPosition)} - {@const width = scales.x(segment[segment.length - 1].tsPosition) - x} - - {/each} - - - - {#if hasScrubSelection && scrubStart && scrubEnd} - - - - {/if} - - - -{#if isComparingDimension} - {#each dimensionData as dim (dim.dimensionValue)} - {#if dim.data.length > 0} - - {/if} - {/each} -{:else} - - - - - {#if showComparison} - - {/if} - - - - - - {#each singletons as singleton (singleton.ts.getTime())} - - - - {/each} - - - {#if hasScrubSelection && scrubStart && scrubEnd} - - - - - - {/if} -{/if} diff --git a/web-common/src/features/dashboards/time-series/measure-chart/MeasureChartPointIndicator.svelte b/web-common/src/features/dashboards/time-series/measure-chart/MeasureChartPointIndicator.svelte index d17134063aa..3f8db7fdf91 100644 --- a/web-common/src/features/dashboards/time-series/measure-chart/MeasureChartPointIndicator.svelte +++ b/web-common/src/features/dashboards/time-series/measure-chart/MeasureChartPointIndicator.svelte @@ -7,25 +7,26 @@ + + {value} + + diff --git a/web-common/src/features/dashboards/time-series/measure-chart/MeasureChartTooltip.svelte b/web-common/src/features/dashboards/time-series/measure-chart/MeasureChartTooltip.svelte index 68c3bd8a8e7..ff65c719616 100644 --- a/web-common/src/features/dashboards/time-series/measure-chart/MeasureChartTooltip.svelte +++ b/web-common/src/features/dashboards/time-series/measure-chart/MeasureChartTooltip.svelte @@ -1,17 +1,12 @@ {#if hoveredPoint} - - - - {formatDateTimeByGrain(hoveredPoint.ts, timeGrain)} - - {#if showComparison && hoveredPoint.comparisonTs} - - {formatDateTimeByGrain(hoveredPoint.comparisonTs, timeGrain)} prev. - - {/if} - + + {#if !currentPointIsNull && !isComparingDimension && !showComparison} + + {/if} - - {#if !currentPointIsNull && !isComparingDimension} - + + {#if !isComparingDimension && showComparison && !currentPointIsNull} + {@const primaryBarX = isBarMode + ? slotCenterX - bandWidth / 2 + (singleBarWidth + barGap) + singleBarWidth / 2 + : $tweenedX} + {/if} @@ -122,11 +91,14 @@ stroke-width="1" stroke-dasharray="2,2" /> - {#each dimensionData as dim} + {#each dimensionData as dim, i (i)} {@const pt = dim.data[hoveredIndex]} + {@const barX = isBarMode + ? slotCenterX - bandWidth / 2 + i * (singleBarWidth + barGap) + singleBarWidth / 2 + : $tweenedX} {#if pt?.value !== null && pt?.value !== undefined} - - {#if showComparison && hasValidComparisonPoint && !comparisonPointIsNull && currentPointIsNull} - - - - - no current data - - - {formatter(comparisonY ?? null)} prev. - - - {:else if showComparison && hasValidComparisonPoint && !currentPointIsNull && !comparisonPointIsNull} - {@const yDiff = Math.abs($tweenedY - $tweenedComparisonY)} - {#if yDiff > 8} - {@const bufferSize = yDiff > 16 ? 8 : 4} - {@const sign = comparisonIsPositive ? 1 : -1} - {@const yBuffer = sign * bufferSize} - - - - - - - {#if yDiff > 16} - {@const yLoc = $tweenedY + bufferSize * sign} - {@const dist = 3} - {@const signedDist = sign * dist} - - - {/if} - {/if} - - - - - - - {#if !currentPointIsNull && isDiffValid} - - {formatter(y ?? null)} - - ({diffLabel}) - - - {/if} - - - - {#if comparisonPointIsNull} - no comparison data - {:else} - {formatter(comparisonY ?? null)} prev. - {/if} - - - {:else if !currentPointIsNull} - - - {formatter(y ?? null)} - - {:else} - - - no current data - - {/if} - + + {#if !isComparingDimension && showComparison && hasValidComparisonPoint} + {@const compBarX = isBarMode + ? slotCenterX - bandWidth / 2 + singleBarWidth / 2 + : $tweenedX} + {/if} {/if} + + diff --git a/web-common/src/features/dashboards/time-series/measure-chart/ScrubController.ts b/web-common/src/features/dashboards/time-series/measure-chart/ScrubController.ts new file mode 100644 index 00000000000..2cf588edd72 --- /dev/null +++ b/web-common/src/features/dashboards/time-series/measure-chart/ScrubController.ts @@ -0,0 +1,199 @@ +import { writable, get, type Readable, type Writable } from "svelte/store"; +import type { ScrubState } from "./types"; +import type { ScaleLinear } from "d3-scale"; + +export type { ScrubState }; + +type ScrubMode = "none" | "create" | "resize-start" | "resize-end" | "move"; + +const EMPTY_SCRUB: ScrubState = { + startIndex: null, + endIndex: null, + isScrubbing: false, +}; + +const EDGE_THRESHOLD_PX = 5; + +type XScale = ScaleLinear; + +/** + * Controller for chart scrub/brush selection interactions. + * Designed as a singleton shared across all measure charts. + * + * All charts share the same x-scale domain (0 to dataLength-1), + * so scrub state is shared and any chart can drive interactions. + */ +export class ScrubController { + private _state: Writable; + private mode: ScrubMode = "none"; + private moveStartX: number | null = null; + private moveStartIndices: { start: number; end: number } | null = null; + private dataLength: number = 0; + + readonly state: Readable; + + constructor(externalState?: Writable) { + this._state = externalState ?? writable(EMPTY_SCRUB); + this.state = this._state; + } + + /** Update the data length (used for clamping indices). */ + setDataLength(length: number): void { + this.dataLength = length; + } + + /** Clamp index to valid range [0, dataLength-1]. */ + private clamp(index: number): number { + if (this.dataLength === 0) return 0; + return Math.max(0, Math.min(this.dataLength - 1, Math.round(index))); + } + + /** Determine scrub mode based on click position relative to existing selection. */ + private detectMode(screenX: number, xScale: XScale): ScrubMode { + const state = get(this._state); + + if (state.startIndex === null || state.endIndex === null) { + return "create"; + } + + const startX = xScale(state.startIndex); + const endX = xScale(state.endIndex); + + if (Math.abs(screenX - startX) <= EDGE_THRESHOLD_PX) return "resize-start"; + if (Math.abs(screenX - endX) <= EDGE_THRESHOLD_PX) return "resize-end"; + + const minX = Math.min(startX, endX); + const maxX = Math.max(startX, endX); + if (screenX > minX + EDGE_THRESHOLD_PX && screenX < maxX - EDGE_THRESHOLD_PX) { + return "move"; + } + + return "create"; + } + + /** Get cursor style based on hover position relative to selection. */ + getCursorStyle(screenX: number | null, xScale: XScale): string { + const state = get(this._state); + + if (state.isScrubbing) return "cursor-ew-resize"; + + if (state.startIndex === null || state.endIndex === null || screenX === null) { + return "cursor-crosshair"; + } + + const startX = xScale(state.startIndex); + const endX = xScale(state.endIndex); + + if (Math.abs(screenX - startX) <= EDGE_THRESHOLD_PX || + Math.abs(screenX - endX) <= EDGE_THRESHOLD_PX) { + return "cursor-ew-resize"; + } + + const minX = Math.min(startX, endX); + const maxX = Math.max(startX, endX); + if (screenX > minX + EDGE_THRESHOLD_PX && screenX < maxX - EDGE_THRESHOLD_PX) { + return "cursor-grab"; + } + + return "cursor-crosshair"; + } + + /** Start a scrub interaction. */ + start(screenX: number, xScale: XScale): void { + const state = get(this._state); + const index = this.clamp(xScale.invert(screenX)); + + this.mode = this.detectMode(screenX, xScale); + + if (this.mode === "move") { + this.moveStartX = screenX; + this.moveStartIndices = { + start: state.startIndex!, + end: state.endIndex! + }; + this._state.update((s) => ({ ...s, isScrubbing: true })); + } else if (this.mode === "create") { + this._state.set({ startIndex: index, endIndex: index, isScrubbing: true }); + } else { + this._state.update((s) => ({ ...s, isScrubbing: true })); + } + } + + /** Update scrub position during drag. */ + update(screenX: number, xScale: XScale): void { + const state = get(this._state); + if (!state.isScrubbing) return; + + const index = this.clamp(xScale.invert(screenX)); + + switch (this.mode) { + case "create": + case "resize-end": + this._state.update((s) => ({ ...s, endIndex: index })); + break; + + case "resize-start": + this._state.update((s) => ({ ...s, startIndex: index })); + break; + + case "move": + if (this.moveStartX !== null && this.moveStartIndices) { + const deltaX = screenX - this.moveStartX; + const startPx = xScale(this.moveStartIndices.start); + const endPx = xScale(this.moveStartIndices.end); + this._state.update((s) => ({ + ...s, + startIndex: this.clamp(xScale.invert(startPx + deltaX)), + endIndex: this.clamp(xScale.invert(endPx + deltaX)), + })); + } + break; + } + } + + /** End scrub interaction. Returns true if selection was kept, false if cleared. */ + end(): boolean { + const state = get(this._state); + + if (!state.isScrubbing) { + this.clearMoveState(); + return state.startIndex !== null; + } + + // Clear if selection is too small (single point click) + if (state.startIndex !== null && state.endIndex !== null && + Math.abs(state.startIndex - state.endIndex) < 0.5) { + this.reset(); + return false; + } + + this._state.update((s) => ({ ...s, isScrubbing: false })); + this.clearMoveState(); + return true; + } + + /** Check if a click is outside the current selection. */ + isClickOutside(screenX: number, xScale: XScale): boolean { + const state = get(this._state); + if (state.startIndex === null || state.endIndex === null) return false; + + const clickIndex = xScale.invert(screenX); + const [min, max] = state.startIndex < state.endIndex + ? [state.startIndex, state.endIndex] + : [state.endIndex, state.startIndex]; + + return clickIndex < min || clickIndex > max; + } + + /** Reset scrub state completely. */ + reset(): void { + this._state.set(EMPTY_SCRUB); + this.clearMoveState(); + } + + private clearMoveState(): void { + this.mode = "none"; + this.moveStartX = null; + this.moveStartIndices = null; + } +} diff --git a/web-common/src/features/dashboards/time-series/measure-chart/index.ts b/web-common/src/features/dashboards/time-series/measure-chart/index.ts index 6091f8b19ab..a17fbf845fd 100644 --- a/web-common/src/features/dashboards/time-series/measure-chart/index.ts +++ b/web-common/src/features/dashboards/time-series/measure-chart/index.ts @@ -32,11 +32,13 @@ export { } from "./scales"; export { - createChartInteractions, createVisibilityObserver, + createHoverState, getOrderedDates, } from "./interactions"; +export { ScrubController } from "./ScrubController"; + // Data fetching hooks export { useMeasureTimeSeries, diff --git a/web-common/src/features/dashboards/time-series/measure-chart/interactions.ts b/web-common/src/features/dashboards/time-series/measure-chart/interactions.ts index 4bf2ba9997b..bd29e396066 100644 --- a/web-common/src/features/dashboards/time-series/measure-chart/interactions.ts +++ b/web-common/src/features/dashboards/time-series/measure-chart/interactions.ts @@ -1,16 +1,9 @@ -import { derived, writable, get, type Readable, type Writable } from "svelte/store"; -import type { - ChartScales, - PlotBounds, - HoverState, - ScrubState, - BisectedPoint, - InteractionState, - InteractionHandlers, -} from "./types"; +import { writable, type Writable } from "svelte/store"; +import type { HoverState } from "./types"; /** * Create an IntersectionObserver-based visibility store. + * Used for lazy-loading chart data when the chart scrolls into view. */ export function createVisibilityObserver( rootMargin = "120px", @@ -40,223 +33,22 @@ export function createVisibilityObserver( return { visible, observe }; } -const EMPTY_HOVER_STATE: HoverState = { +const EMPTY_HOVER: HoverState = { index: null, screenX: null, + screenY: null, isHovered: false, }; -const EMPTY_SCRUB_STATE: ScrubState = { - startIndex: null, - endIndex: null, - isScrubbing: false, -}; - -const EMPTY_BISECTED: BisectedPoint = { index: -1 }; - -type ScrubMode = "none" | "create" | "resize-start" | "resize-end" | "move"; - /** - * Index-based chart interactions. - * x scale is linear (domain = [0, N-1]), so bisection is just Math.round(xScale.invert(px)). + * Create a simple hover state store. */ -export function createChartInteractions( - scalesStore: Readable, - visibleRangeStore: Readable<[number, number]>, - plotBoundsStore: Readable, - externalScrubState?: Writable, -): { - state: Readable; - handlers: InteractionHandlers; - resetScrub: () => void; -} { - const hoverState = writable(EMPTY_HOVER_STATE); - const internalScrubState = writable(EMPTY_SCRUB_STATE); - const scrubState = externalScrubState ?? internalScrubState; - - let scrubMode: ScrubMode = "none"; - let scrubMoveStartX: number | null = null; - let scrubMoveStartIndices: { start: number | null; end: number | null } | null = null; - - const EDGE_THRESHOLD = 5; - - /** Snap fractional index to nearest valid visible index. */ - function snap(fractionalIndex: number, range: [number, number]): number { - return Math.max(range[0], Math.min(range[1], Math.round(fractionalIndex))); - } - - const bisectedPoint = derived( - [hoverState, scalesStore, visibleRangeStore], - ([$hover, _$scales, $range]) => { - if ($hover.index === null || $range[0] === $range[1] && $range[0] === 0) return EMPTY_BISECTED; - return { index: snap($hover.index, $range) }; - }, - ); - - const cursorStyle = derived( - [scrubState, hoverState, scalesStore], - ([$scrub, $hover, $scales]) => { - if ($scrub.isScrubbing) return "cursor-ew-resize"; - - if ($scrub.startIndex !== null && $scrub.endIndex !== null && $hover.screenX !== null) { - const startX = $scales.x($scrub.startIndex); - const endX = $scales.x($scrub.endIndex); - const hx = $hover.screenX; - - if (Math.abs(hx - startX) <= EDGE_THRESHOLD || Math.abs(hx - endX) <= EDGE_THRESHOLD) { - return "cursor-ew-resize"; - } - const minX = Math.min(startX, endX); - const maxX = Math.max(startX, endX); - if (hx > minX + EDGE_THRESHOLD && hx < maxX - EDGE_THRESHOLD) { - return "cursor-grab"; - } - } - return "cursor-crosshair"; - }, - ); - - const state = derived( - [hoverState, scrubState, bisectedPoint, cursorStyle], - ([$hover, $scrub, $bisected, $cursor]) => ({ - hover: $hover, - scrub: $scrub, - bisectedPoint: $bisected, - cursorStyle: $cursor, - }), - ); - - function getScrubMode(hoverX: number): ScrubMode { - const $scrub = get(scrubState); - const $scales = get(scalesStore); - - if ($scrub.startIndex === null || $scrub.endIndex === null) return "create"; - - const startX = $scales.x($scrub.startIndex); - const endX = $scales.x($scrub.endIndex); - - if (Math.abs(hoverX - startX) <= EDGE_THRESHOLD) return "resize-start"; - if (Math.abs(hoverX - endX) <= EDGE_THRESHOLD) return "resize-end"; - - const minX = Math.min(startX, endX); - const maxX = Math.max(startX, endX); - if (hoverX > minX + EDGE_THRESHOLD && hoverX < maxX - EDGE_THRESHOLD) return "move"; - - return "create"; - } - - function resetScrub(): void { - scrubState.set(EMPTY_SCRUB_STATE); - scrubMode = "none"; - scrubMoveStartX = null; - scrubMoveStartIndices = null; - } - - const handlers: InteractionHandlers = { - onMouseMove(event: MouseEvent) { - const $scales = get(scalesStore); - const $bounds = get(plotBoundsStore); - - const x = Math.max($bounds.left, Math.min($bounds.left + $bounds.width, event.offsetX)); - const fractionalIndex = $scales.x.invert(x); - - hoverState.set({ - index: fractionalIndex, - screenX: x, - isHovered: true, - }); - - const $scrub = get(scrubState); - const $range = get(visibleRangeStore); - if ($scrub.isScrubbing) { - const snappedIndex = snap(fractionalIndex, $range); - switch (scrubMode) { - case "create": - case "resize-end": - scrubState.update((s) => ({ ...s, endIndex: snappedIndex })); - break; - case "resize-start": - scrubState.update((s) => ({ ...s, startIndex: snappedIndex })); - break; - case "move": - if (scrubMoveStartX !== null && scrubMoveStartIndices) { - const deltaX = x - scrubMoveStartX; - const startPx = $scales.x(scrubMoveStartIndices.start!); - const endPx = $scales.x(scrubMoveStartIndices.end!); - scrubState.update((s) => ({ - ...s, - startIndex: snap($scales.x.invert(startPx + deltaX), $range), - endIndex: snap($scales.x.invert(endPx + deltaX), $range), - })); - } - break; - } - } - }, - - onMouseLeave() { - hoverState.set(EMPTY_HOVER_STATE); - }, - - onMouseDown(event: MouseEvent) { - if (event.button !== 0) return; - const $scales = get(scalesStore); - const $scrub = get(scrubState); - const $range = get(visibleRangeStore); - const x = event.offsetX; - const idx = snap($scales.x.invert(x), $range); - - scrubMode = getScrubMode(x); - - if (scrubMode === "move") { - scrubMoveStartX = x; - scrubMoveStartIndices = { start: $scrub.startIndex, end: $scrub.endIndex }; - scrubState.update((s) => ({ ...s, isScrubbing: true })); - } else if (scrubMode === "create") { - scrubState.set({ startIndex: idx, endIndex: idx, isScrubbing: true }); - } else { - scrubState.update((s) => ({ ...s, isScrubbing: true })); - } - }, - - onMouseUp() { - const $scrub = get(scrubState); - if ($scrub.isScrubbing) { - if ( - $scrub.startIndex !== null && - $scrub.endIndex !== null && - Math.abs($scrub.startIndex - $scrub.endIndex) < 0.5 - ) { - resetScrub(); - } else { - scrubState.update((s) => ({ ...s, isScrubbing: false })); - } - } - scrubMode = "none"; - scrubMoveStartX = null; - scrubMoveStartIndices = null; - }, - - onClick(event: MouseEvent) { - const $scrub = get(scrubState); - const $scales = get(scalesStore); - - if ($scrub.startIndex !== null && $scrub.endIndex !== null && !$scrub.isScrubbing) { - const clickIdx = $scales.x.invert(event.offsetX); - const [min, max] = - $scrub.startIndex < $scrub.endIndex - ? [$scrub.startIndex, $scrub.endIndex] - : [$scrub.endIndex, $scrub.startIndex]; - if (clickIdx < min || clickIdx > max) { - resetScrub(); - } - } - }, - }; - - return { state, handlers, resetScrub }; +export function createHoverState(): Writable { + return writable(EMPTY_HOVER); } +export { EMPTY_HOVER }; + /** * Helper to get ordered start/end dates. */ diff --git a/web-common/src/features/dashboards/time-series/measure-chart/scales.ts b/web-common/src/features/dashboards/time-series/measure-chart/scales.ts index 98a8bd03c79..41a3ad20f06 100644 --- a/web-common/src/features/dashboards/time-series/measure-chart/scales.ts +++ b/web-common/src/features/dashboards/time-series/measure-chart/scales.ts @@ -19,7 +19,7 @@ interface ExtentConfig { */ const DEFAULT_EXTENT_CONFIG: ExtentConfig = { includeZero: true, - paddingFactor: 6 / 5, // 20% padding on the upper bound + paddingFactor: 1.3, }; /** @@ -170,7 +170,7 @@ export function computeChartConfig( isExpanded: boolean, ): ChartConfig { const margin = { - top: 4, + top: 4, // Space for data readout labels right: 40, bottom: isExpanded ? 25 : 10, left: 0, diff --git a/web-common/src/features/dashboards/time-series/measure-chart/types.ts b/web-common/src/features/dashboards/time-series/measure-chart/types.ts index ca9d0cda441..ff980274c4d 100644 --- a/web-common/src/features/dashboards/time-series/measure-chart/types.ts +++ b/web-common/src/features/dashboards/time-series/measure-chart/types.ts @@ -92,6 +92,8 @@ export interface HoverState { index: number | null; /** Screen x coordinate */ screenX: number | null; + /** Screen y coordinate */ + screenY: number | null; /** Is mouse currently over the chart */ isHovered: boolean; } @@ -155,15 +157,17 @@ export interface ChartSeries { values: (number | null)[]; /** Stroke/fill color */ color: string; - /** Dash pattern for the stroke, e.g. "4,4" for comparison lines */ + /** Dash pattern for the stroke */ strokeDasharray?: string; /** Opacity override (default 1) */ opacity?: number; /** Area gradient colors — only the first/primary series typically gets this */ areaGradient?: { dark: string; light: string }; + /** Stroke width */ + strokeWidth?: number; } /** * Rendering mode for TimeSeriesChart. */ -export type ChartMode = "line" | "bar" | "stacked-bar"; +export type ChartMode = "line" | "bar"; diff --git a/web-common/src/features/dashboards/time-series/measure-selection/MeasureSelection.svelte b/web-common/src/features/dashboards/time-series/measure-selection/MeasureSelection.svelte deleted file mode 100644 index 7b9f8ccfcef..00000000000 --- a/web-common/src/features/dashboards/time-series/measure-selection/MeasureSelection.svelte +++ /dev/null @@ -1,82 +0,0 @@ - - -{#if showLine} - d[xAccessor]} value={$start} let:point> - {#if point && inBounds(internalXMin, internalXMax, point[xAccessor])} - - {/if} - -{:else if showBox && $start && $end} - {@const xStart = $xScale($start)} - {@const xEnd = $xScale($end)} - - - - - - - - - - - -{/if} From df78ad855bef125f5fd4a621c14d294a96762cbb Mon Sep 17 00:00:00 2001 From: Brian Holmes Date: Tue, 3 Feb 2026 14:04:08 -0500 Subject: [PATCH 03/36] cleanup --- .../time-series/ChartInteractions.svelte | 1 - .../measure-chart/MeasureChart.svelte | 46 +++++++++++++------ .../measure-chart/ScrubController.ts | 9 ++++ web-common/src/features/feature-flags.ts | 2 +- 4 files changed, 41 insertions(+), 17 deletions(-) diff --git a/web-common/src/features/dashboards/time-series/ChartInteractions.svelte b/web-common/src/features/dashboards/time-series/ChartInteractions.svelte index 12c9292895f..278d7da4969 100644 --- a/web-common/src/features/dashboards/time-series/ChartInteractions.svelte +++ b/web-common/src/features/dashboards/time-series/ChartInteractions.svelte @@ -117,7 +117,6 @@ } function zoomScrub() { - console.log("zoom"); if ( selectedScrubRange?.start instanceof Date && selectedScrubRange?.end instanceof Date diff --git a/web-common/src/features/dashboards/time-series/measure-chart/MeasureChart.svelte b/web-common/src/features/dashboards/time-series/measure-chart/MeasureChart.svelte index d8ecb73ad1a..5ed1facbe67 100644 --- a/web-common/src/features/dashboards/time-series/measure-chart/MeasureChart.svelte +++ b/web-common/src/features/dashboards/time-series/measure-chart/MeasureChart.svelte @@ -577,6 +577,21 @@ config.plotBounds.left, Math.min(config.plotBounds.left + config.plotBounds.width, e.offsetX), ); + + // If there's a visual selection from external scrubRange but controller is empty, + // initialize the controller so edge-resize and move detection work + const controllerState = get(scrubController.state); + if ( + controllerState.startIndex === null && + externalScrubStartIndex !== null && + externalScrubEndIndex !== null + ) { + scrubController.initFromExternal( + externalScrubStartIndex, + externalScrubEndIndex, + ); + } + scrubController.start(x, xScale); } @@ -848,7 +863,7 @@ {/if} - {#if !isScrubbing && hoveredPoint && !isLocallyHovered && !isComparingDimension} + {#if !isScrubbing && hoveredPoint && (!showComparison || !isLocallyHovered) && !isComparingDimension} {@const showDelta = showComparison && tooltipComparisonValue !== null && @@ -866,20 +881,21 @@ - - {valueFormatter(tooltipCurrentValue)} - {#if showComparison} + + {valueFormatter(tooltipCurrentValue)} + + - vs {valueFormatter(tooltipComparisonValue)} + vs {valueFormatter(tooltipComparisonValue)} + {#if showDelta} {/if} - {/if} - + + {/if} {/if} diff --git a/web-common/src/features/dashboards/time-series/measure-chart/ScrubController.ts b/web-common/src/features/dashboards/time-series/measure-chart/ScrubController.ts index 2cf588edd72..75021b6d593 100644 --- a/web-common/src/features/dashboards/time-series/measure-chart/ScrubController.ts +++ b/web-common/src/features/dashboards/time-series/measure-chart/ScrubController.ts @@ -42,6 +42,15 @@ export class ScrubController { this.dataLength = length; } + /** Initialize controller state from external selection (e.g., from URL/props). */ + initFromExternal(startIndex: number, endIndex: number): void { + this._state.set({ + startIndex, + endIndex, + isScrubbing: false, + }); + } + /** Clamp index to valid range [0, dataLength-1]. */ private clamp(index: number): number { if (this.dataLength === 0) return 0; diff --git a/web-common/src/features/feature-flags.ts b/web-common/src/features/feature-flags.ts index eb5f4242201..8240d6369ca 100644 --- a/web-common/src/features/feature-flags.ts +++ b/web-common/src/features/feature-flags.ts @@ -62,7 +62,7 @@ class FeatureFlags { alerts = new FeatureFlag("user", true); reports = new FeatureFlag("user", true); chat = new FeatureFlag("user", true); - dashboardChat = new FeatureFlag("user", true); + dashboardChat = new FeatureFlag("user", false); developerChat = new FeatureFlag("user", false); deploy = new FeatureFlag("user", true); generateCanvas = new FeatureFlag("user", false); From d5a875552f9664ef6ead1242908e3ad8e382a46c Mon Sep 17 00:00:00 2001 From: Brian Holmes Date: Tue, 3 Feb 2026 14:23:24 -0500 Subject: [PATCH 04/36] cleanup and prettier --- web-common/src/components/vega/vega-config.ts | 4 +- .../canvas/components/kpi/KPIDisplay.tsx | 0 .../field-config/ColorPaletteSelector.svelte | 4 +- .../features/components/charts/combo/spec.ts | 8 +-- .../src/features/components/charts/util.ts | 8 +-- web-common/src/features/dashboards/config.ts | 2 +- .../DimensionFilterGutter.svelte | 4 +- .../LeaderboardItemFilterIcon.svelte | 4 +- .../time-dimension-details/TDDTable.svelte | 4 +- .../TimeDimensionDisplay.svelte | 19 +++--- .../charts/TDDAlternateChart.svelte | 63 ++++++++++++------- .../charts/patch-vega-spec.ts | 4 +- .../charts/tdd-tooltip-formatter.ts | 4 +- .../measure-chart/MeasureChart.svelte | 33 +++++++--- .../MeasureChartAnnotationMarkers.svelte | 9 ++- .../MeasureChartAnnotationPopover.svelte | 12 +--- .../measure-chart/MeasureChartScrub.svelte | 61 +++++++++++++----- .../measure-chart/MeasureChartTooltip.svelte | 14 ++++- .../measure-chart/PanButton.svelte | 5 +- .../measure-chart/ScrubController.ts | 44 +++++++++---- .../measure-chart/annotation-utils.ts | 8 +-- .../time-series/measure-chart/bisect.ts | 13 ++-- .../time-series/measure-chart/interactions.ts | 8 +-- .../measure-chart/use-dimension-data.ts | 9 +-- .../measure-chart/use-measure-totals.ts | 5 +- .../time-series/multiple-dimension-queries.ts | 4 +- 26 files changed, 220 insertions(+), 133 deletions(-) delete mode 100644 web-common/src/features/canvas/components/kpi/KPIDisplay.tsx diff --git a/web-common/src/components/vega/vega-config.ts b/web-common/src/components/vega/vega-config.ts index 506a7608fd0..5a2bb743e4c 100644 --- a/web-common/src/components/vega/vega-config.ts +++ b/web-common/src/components/vega/vega-config.ts @@ -1,4 +1,4 @@ -import { COMPARIONS_COLORS } from "@rilldata/web-common/features/dashboards/config"; +import { COMPARISON_COLORS } from "@rilldata/web-common/features/dashboards/config"; import { getSequentialColorsAsHex } from "@rilldata/web-common/features/themes/palette-store"; import { themeManager } from "@rilldata/web-common/features/themes/theme-manager"; import { getChroma } from "@rilldata/web-common/features/themes/theme-utils"; @@ -170,7 +170,7 @@ export const getRillTheme: ( }, range: { category: (() => { - const defaultColors = COMPARIONS_COLORS.map((color) => + const defaultColors = COMPARISON_COLORS.map((color) => color.startsWith("var(") ? resolveCSSVariable(color, isDarkMode) : color, diff --git a/web-common/src/features/canvas/components/kpi/KPIDisplay.tsx b/web-common/src/features/canvas/components/kpi/KPIDisplay.tsx deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/web-common/src/features/canvas/inspector/chart/field-config/ColorPaletteSelector.svelte b/web-common/src/features/canvas/inspector/chart/field-config/ColorPaletteSelector.svelte index 927142fb63f..c2ad5ff383d 100644 --- a/web-common/src/features/canvas/inspector/chart/field-config/ColorPaletteSelector.svelte +++ b/web-common/src/features/canvas/inspector/chart/field-config/ColorPaletteSelector.svelte @@ -11,7 +11,7 @@ getColorForValues, resolveCSSVariable, } from "@rilldata/web-common/features/components/charts/util"; - import { COMPARIONS_COLORS } from "@rilldata/web-common/features/dashboards/config"; + import { COMPARISON_COLORS } from "@rilldata/web-common/features/dashboards/config"; import { ChevronDown, ChevronRight } from "lucide-svelte"; import { slide } from "svelte/transition"; @@ -40,7 +40,7 @@ function handleColorChange(value: string, newColor: string) { const valueIndex = colorValues.findIndex((v) => v === value); const defaultColorVar = - COMPARIONS_COLORS[valueIndex % COMPARIONS_COLORS.length]; + COMPARISON_COLORS[valueIndex % COMPARISON_COLORS.length]; // Convert the color back to a CSS variable reference if it matches a palette color const colorToSave = colorToVariableReference(newColor); diff --git a/web-common/src/features/components/charts/combo/spec.ts b/web-common/src/features/components/charts/combo/spec.ts index fb1c8f9eee9..c1cd497ed0f 100644 --- a/web-common/src/features/components/charts/combo/spec.ts +++ b/web-common/src/features/components/charts/combo/spec.ts @@ -4,7 +4,7 @@ import type { ColorMapping, } from "@rilldata/web-common/features/components/charts/types"; import { resolveCSSVariable } from "@rilldata/web-common/features/components/charts/util"; -import { COMPARIONS_COLORS } from "@rilldata/web-common/features/dashboards/config"; +import { COMPARISON_COLORS } from "@rilldata/web-common/features/dashboards/config"; import type { VisualizationSpec } from "svelte-vega"; import type { ColorDef, Field } from "vega-lite/build/src/channeldef"; import type { LayerSpec } from "vega-lite/build/src/spec/layer"; @@ -33,11 +33,11 @@ function getColorForField( } // Use qualitative palette colors for the two measures - if (encoding === "y1") return COMPARIONS_COLORS[0]; - if (encoding === "y2") return COMPARIONS_COLORS[1]; + if (encoding === "y1") return COMPARISON_COLORS[0]; + if (encoding === "y2") return COMPARISON_COLORS[1]; // Fallback to qualitative palette color 3 - return COMPARIONS_COLORS[2]; + return COMPARISON_COLORS[2]; } export function generateVLComboChartSpec( diff --git a/web-common/src/features/components/charts/util.ts b/web-common/src/features/components/charts/util.ts index 5bf9421a3af..cee08d5dd0c 100644 --- a/web-common/src/features/components/charts/util.ts +++ b/web-common/src/features/components/charts/util.ts @@ -1,5 +1,5 @@ import { CHART_CONFIG } from "@rilldata/web-common/features/components/charts/config"; -import { COMPARIONS_COLORS } from "@rilldata/web-common/features/dashboards/config"; +import { COMPARISON_COLORS } from "@rilldata/web-common/features/dashboards/config"; import { type V1MetricsViewAggregationResponseDataItem } from "@rilldata/web-common/runtime-client"; import type { Color } from "chroma-js"; import chroma from "chroma-js"; @@ -188,7 +188,7 @@ export function getColorForValues( ); const colorVar = overrideColor?.color || - COMPARIONS_COLORS[index % COMPARIONS_COLORS.length]; + COMPARISON_COLORS[index % COMPARISON_COLORS.length]; return { value, @@ -301,8 +301,8 @@ export function colorToVariableReference( if (!resolvedColor || typeof window === "undefined") return resolvedColor; // Check all comparison colors (qualitative palette) - for (let i = 0; i < COMPARIONS_COLORS.length; i++) { - const varRef = COMPARIONS_COLORS[i]; + for (let i = 0; i < COMPARISON_COLORS.length; i++) { + const varRef = COMPARISON_COLORS[i]; const resolved = resolveCSSVariable(varRef, isDarkMode); // Compare colors (normalize by converting both to chroma and back) diff --git a/web-common/src/features/dashboards/config.ts b/web-common/src/features/dashboards/config.ts index 332b87f3c91..1fdef3a0962 100644 --- a/web-common/src/features/dashboards/config.ts +++ b/web-common/src/features/dashboards/config.ts @@ -18,7 +18,7 @@ export const MEASURE_CONFIG = { * Comparison colors using the qualitative palette * For categorical distinction when comparing different dimension values */ -export const COMPARIONS_COLORS = [ +export const COMPARISON_COLORS = [ "var(--color-qualitative-1)", "var(--color-qualitative-2)", "var(--color-qualitative-3)", diff --git a/web-common/src/features/dashboards/dimension-table/DimensionFilterGutter.svelte b/web-common/src/features/dashboards/dimension-table/DimensionFilterGutter.svelte index 7e929db555c..45cb575d687 100644 --- a/web-common/src/features/dashboards/dimension-table/DimensionFilterGutter.svelte +++ b/web-common/src/features/dashboards/dimension-table/DimensionFilterGutter.svelte @@ -4,7 +4,7 @@ import CheckCircle from "@rilldata/web-common/components/icons/CheckCircle.svelte"; import Spacer from "@rilldata/web-common/components/icons/Spacer.svelte"; import { - COMPARIONS_COLORS, + COMPARISON_COLORS, SELECTED_NOT_COMPARED_COLOR, } from "@rilldata/web-common/features/dashboards/config"; import StickyHeader from "@rilldata/web-common/components/virtualized-table/core/StickyHeader.svelte"; @@ -23,7 +23,7 @@ function getColor(i: number) { const posInSelection = selectedIndex.indexOf(i); if (posInSelection >= 7) return SELECTED_NOT_COMPARED_COLOR; - return COMPARIONS_COLORS[posInSelection]; + return COMPARISON_COLORS[posInSelection]; } const config: VirtualizedTableConfig = getContext("config"); diff --git a/web-common/src/features/dashboards/leaderboard/LeaderboardItemFilterIcon.svelte b/web-common/src/features/dashboards/leaderboard/LeaderboardItemFilterIcon.svelte index db90f31d1a3..a7592cdcf5f 100644 --- a/web-common/src/features/dashboards/leaderboard/LeaderboardItemFilterIcon.svelte +++ b/web-common/src/features/dashboards/leaderboard/LeaderboardItemFilterIcon.svelte @@ -4,7 +4,7 @@ import CheckCircle from "@rilldata/web-common/components/icons/CheckCircle.svelte"; import Spacer from "@rilldata/web-common/components/icons/Spacer.svelte"; import { - COMPARIONS_COLORS, + COMPARISON_COLORS, SELECTED_NOT_COMPARED_COLOR, } from "@rilldata/web-common/features/dashboards/config"; @@ -16,7 +16,7 @@ function getColor(i: number) { if (i >= 7) return SELECTED_NOT_COMPARED_COLOR; - return COMPARIONS_COLORS[i]; + return COMPARISON_COLORS[i]; } diff --git a/web-common/src/features/dashboards/time-dimension-details/TDDTable.svelte b/web-common/src/features/dashboards/time-dimension-details/TDDTable.svelte index 552ca864235..52f41b46897 100644 --- a/web-common/src/features/dashboards/time-dimension-details/TDDTable.svelte +++ b/web-common/src/features/dashboards/time-dimension-details/TDDTable.svelte @@ -1,6 +1,6 @@ {#each groups as group (group.left)} diff --git a/web-common/src/features/dashboards/time-series/measure-chart/MeasureChartAnnotationPopover.svelte b/web-common/src/features/dashboards/time-series/measure-chart/MeasureChartAnnotationPopover.svelte index b6811436787..832b0986af0 100644 --- a/web-common/src/features/dashboards/time-series/measure-chart/MeasureChartAnnotationPopover.svelte +++ b/web-common/src/features/dashboards/time-series/measure-chart/MeasureChartAnnotationPopover.svelte @@ -59,10 +59,7 @@ {#if hoveredGroup}
- (showingMore = false)} - > + (showingMore = false)}> - +
onHover(true)} diff --git a/web-common/src/features/dashboards/time-series/measure-chart/MeasureChartScrub.svelte b/web-common/src/features/dashboards/time-series/measure-chart/MeasureChartScrub.svelte index 75bfeb117b5..9e3b00896ea 100644 --- a/web-common/src/features/dashboards/time-series/measure-chart/MeasureChartScrub.svelte +++ b/web-common/src/features/dashboards/time-series/measure-chart/MeasureChartScrub.svelte @@ -32,12 +32,14 @@ $: hasSelection = startIndex !== null && endIndex !== null; - $: orderedStartIdx = startIndex !== null && endIndex !== null - ? Math.min(startIndex, endIndex) - : null; - $: orderedEndIdx = startIndex !== null && endIndex !== null - ? Math.max(startIndex, endIndex) - : null; + $: orderedStartIdx = + startIndex !== null && endIndex !== null + ? Math.min(startIndex, endIndex) + : null; + $: orderedEndIdx = + startIndex !== null && endIndex !== null + ? Math.max(startIndex, endIndex) + : null; $: xStart = orderedStartIdx !== null ? scales.x(orderedStartIdx) : 0; $: xEnd = orderedEndIdx !== null ? scales.x(orderedEndIdx) : 0; @@ -50,7 +52,10 @@ - + @@ -58,11 +63,7 @@ {#if hasSelection && orderedStartIdx !== null && orderedEndIdx !== null} - + - - + + {#if showLabels} {/if} - - + + {/if} diff --git a/web-common/src/features/dashboards/time-series/measure-chart/MeasureChartTooltip.svelte b/web-common/src/features/dashboards/time-series/measure-chart/MeasureChartTooltip.svelte index ff65c719616..a8081051c63 100644 --- a/web-common/src/features/dashboards/time-series/measure-chart/MeasureChartTooltip.svelte +++ b/web-common/src/features/dashboards/time-series/measure-chart/MeasureChartTooltip.svelte @@ -29,7 +29,9 @@ // Bar count: dimension comparison uses dimensionData.length, time comparison uses 2 $: barCount = isComparingDimension ? dimensionData.length - : (showComparison ? 2 : 1); + : showComparison + ? 2 + : 1; $: barGap = barCount > 1 ? 2 : 0; $: totalGaps = barGap * (barCount - 1); $: singleBarWidth = (bandWidth - totalGaps) / barCount; @@ -70,7 +72,10 @@ {#if !isComparingDimension && showComparison && !currentPointIsNull} {@const primaryBarX = isBarMode - ? slotCenterX - bandWidth / 2 + (singleBarWidth + barGap) + singleBarWidth / 2 + ? slotCenterX - + bandWidth / 2 + + (singleBarWidth + barGap) + + singleBarWidth / 2 : $tweenedX} - + minX + EDGE_THRESHOLD_PX && screenX < maxX - EDGE_THRESHOLD_PX) { + if ( + screenX > minX + EDGE_THRESHOLD_PX && + screenX < maxX - EDGE_THRESHOLD_PX + ) { return "move"; } @@ -86,21 +89,30 @@ export class ScrubController { if (state.isScrubbing) return "cursor-ew-resize"; - if (state.startIndex === null || state.endIndex === null || screenX === null) { + if ( + state.startIndex === null || + state.endIndex === null || + screenX === null + ) { return "cursor-crosshair"; } const startX = xScale(state.startIndex); const endX = xScale(state.endIndex); - if (Math.abs(screenX - startX) <= EDGE_THRESHOLD_PX || - Math.abs(screenX - endX) <= EDGE_THRESHOLD_PX) { + if ( + Math.abs(screenX - startX) <= EDGE_THRESHOLD_PX || + Math.abs(screenX - endX) <= EDGE_THRESHOLD_PX + ) { return "cursor-ew-resize"; } const minX = Math.min(startX, endX); const maxX = Math.max(startX, endX); - if (screenX > minX + EDGE_THRESHOLD_PX && screenX < maxX - EDGE_THRESHOLD_PX) { + if ( + screenX > minX + EDGE_THRESHOLD_PX && + screenX < maxX - EDGE_THRESHOLD_PX + ) { return "cursor-grab"; } @@ -118,11 +130,15 @@ export class ScrubController { this.moveStartX = screenX; this.moveStartIndices = { start: state.startIndex!, - end: state.endIndex! + end: state.endIndex!, }; this._state.update((s) => ({ ...s, isScrubbing: true })); } else if (this.mode === "create") { - this._state.set({ startIndex: index, endIndex: index, isScrubbing: true }); + this._state.set({ + startIndex: index, + endIndex: index, + isScrubbing: true, + }); } else { this._state.update((s) => ({ ...s, isScrubbing: true })); } @@ -170,8 +186,11 @@ export class ScrubController { } // Clear if selection is too small (single point click) - if (state.startIndex !== null && state.endIndex !== null && - Math.abs(state.startIndex - state.endIndex) < 0.5) { + if ( + state.startIndex !== null && + state.endIndex !== null && + Math.abs(state.startIndex - state.endIndex) < 0.5 + ) { this.reset(); return false; } @@ -187,9 +206,10 @@ export class ScrubController { if (state.startIndex === null || state.endIndex === null) return false; const clickIndex = xScale.invert(screenX); - const [min, max] = state.startIndex < state.endIndex - ? [state.startIndex, state.endIndex] - : [state.endIndex, state.startIndex]; + const [min, max] = + state.startIndex < state.endIndex + ? [state.startIndex, state.endIndex] + : [state.endIndex, state.startIndex]; return clickIndex < min || clickIndex > max; } diff --git a/web-common/src/features/dashboards/time-series/measure-chart/annotation-utils.ts b/web-common/src/features/dashboards/time-series/measure-chart/annotation-utils.ts index b02434c46ca..4332dedc367 100644 --- a/web-common/src/features/dashboards/time-series/measure-chart/annotation-utils.ts +++ b/web-common/src/features/dashboards/time-series/measure-chart/annotation-utils.ts @@ -23,10 +23,7 @@ export type AnnotationGroup = { export const AnnotationWidth = 10; export const AnnotationHeight = 10; -function dateToIndex( - data: TimeSeriesPoint[], - date: Date, -): number | null { +function dateToIndex(data: TimeSeriesPoint[], date: Date): number | null { if (data.length === 0) return null; const ms = date.getTime(); let best = 0; @@ -68,7 +65,8 @@ export function groupAnnotations( for (const a of annotations) { const dt = DateTime.fromJSDate(a.startTime, { zone: timeZone }); - const key = dt.startOf(unit).toISO() ?? dt.toISO() ?? String(a.startTime.getTime()); + const key = + dt.startOf(unit).toISO() ?? dt.toISO() ?? String(a.startTime.getTime()); let bucket = buckets.get(key); if (!bucket) { diff --git a/web-common/src/features/dashboards/time-series/measure-chart/bisect.ts b/web-common/src/features/dashboards/time-series/measure-chart/bisect.ts index ec635b9129e..5d59efbd7ec 100644 --- a/web-common/src/features/dashboards/time-series/measure-chart/bisect.ts +++ b/web-common/src/features/dashboards/time-series/measure-chart/bisect.ts @@ -15,9 +15,7 @@ import type { V1TimeGrain } from "@rilldata/web-common/runtime-client"; /** * D3 bisector for TimeSeriesPoint arrays, using the ts field. */ -export const timeSeriesBisector = bisector( - (d) => d.ts, -); +export const timeSeriesBisector = bisector((d) => d.ts); /** * Get the time grain label from V1TimeGrain. @@ -69,7 +67,10 @@ export function bisectDimensionData( dimensionData: DimensionSeriesData[], date: Date, timeGrain: V1TimeGrain, -): Map { +): Map< + string | null, + { value: number | null; color: string; point: TimeSeriesPoint | null } +> { const results = new Map< string | null, { value: number | null; color: string; point: TimeSeriesPoint | null } @@ -165,7 +166,9 @@ export function computeLineSegments( * Find singleton points (segments with only one point). * These need to be rendered as circles instead of lines. */ -export function findSingletonPoints(data: TimeSeriesPoint[]): TimeSeriesPoint[] { +export function findSingletonPoints( + data: TimeSeriesPoint[], +): TimeSeriesPoint[] { const segments = computeLineSegments(data); return segments .filter((segment) => segment.length === 1) diff --git a/web-common/src/features/dashboards/time-series/measure-chart/interactions.ts b/web-common/src/features/dashboards/time-series/measure-chart/interactions.ts index bd29e396066..c779e841fae 100644 --- a/web-common/src/features/dashboards/time-series/measure-chart/interactions.ts +++ b/web-common/src/features/dashboards/time-series/measure-chart/interactions.ts @@ -5,9 +5,7 @@ import type { HoverState } from "./types"; * Create an IntersectionObserver-based visibility store. * Used for lazy-loading chart data when the chart scrolls into view. */ -export function createVisibilityObserver( - rootMargin = "120px", -): { +export function createVisibilityObserver(rootMargin = "120px"): { visible: Writable; observe: (element: HTMLElement, root?: HTMLElement | null) => () => void; } { @@ -57,5 +55,7 @@ export function getOrderedDates( end: Date | null, ): { start: Date | null; end: Date | null } { if (!start || !end) return { start, end }; - return start.getTime() > end.getTime() ? { start: end, end: start } : { start, end }; + return start.getTime() > end.getTime() + ? { start: end, end: start } + : { start, end }; } diff --git a/web-common/src/features/dashboards/time-series/measure-chart/use-dimension-data.ts b/web-common/src/features/dashboards/time-series/measure-chart/use-dimension-data.ts index 5cc9f842f42..9733d3d36b3 100644 --- a/web-common/src/features/dashboards/time-series/measure-chart/use-dimension-data.ts +++ b/web-common/src/features/dashboards/time-series/measure-chart/use-dimension-data.ts @@ -16,11 +16,8 @@ import { keepPreviousData, type CreateQueryResult, } from "@tanstack/svelte-query"; -import { - transformAggregateDimensionData, - prepareTimeSeries, -} from "../utils"; -import { COMPARIONS_COLORS } from "@rilldata/web-common/features/dashboards/config"; +import { transformAggregateDimensionData, prepareTimeSeries } from "../utils"; +import { COMPARISON_COLORS } from "@rilldata/web-common/features/dashboards/config"; import { TIME_GRAIN } from "@rilldata/web-common/lib/time/config"; import { DateTime } from "luxon"; import type { DimensionSeriesData, TimeSeriesPoint } from "./types"; @@ -159,7 +156,7 @@ export function buildDimensionSeriesData( results.push({ dimensionValue: dimensionValues[i], - color: COMPARIONS_COLORS[i] || "", + color: COMPARISON_COLORS[i] || "", data, isFetching, }); diff --git a/web-common/src/features/dashboards/time-series/measure-chart/use-measure-totals.ts b/web-common/src/features/dashboards/time-series/measure-chart/use-measure-totals.ts index f9475dd809c..8fc4146327c 100644 --- a/web-common/src/features/dashboards/time-series/measure-chart/use-measure-totals.ts +++ b/web-common/src/features/dashboards/time-series/measure-chart/use-measure-totals.ts @@ -4,7 +4,10 @@ import { type V1MetricsViewAggregationResponse, } from "@rilldata/web-common/runtime-client"; import type { HTTPError } from "@rilldata/web-common/runtime-client/fetchWrapper"; -import { keepPreviousData, type CreateQueryResult } from "@tanstack/svelte-query"; +import { + keepPreviousData, + type CreateQueryResult, +} from "@tanstack/svelte-query"; import { mergeDimensionAndMeasureFilters } from "@rilldata/web-common/features/dashboards/filters/measure-filters/measure-filter-utils"; import { sanitiseExpression } from "@rilldata/web-common/features/dashboards/stores/filter-utils"; import { useTimeControlStore } from "@rilldata/web-common/features/dashboards/time-controls/time-control-store"; diff --git a/web-common/src/features/dashboards/time-series/multiple-dimension-queries.ts b/web-common/src/features/dashboards/time-series/multiple-dimension-queries.ts index b3a534e9d96..f18161e9235 100644 --- a/web-common/src/features/dashboards/time-series/multiple-dimension-queries.ts +++ b/web-common/src/features/dashboards/time-series/multiple-dimension-queries.ts @@ -8,7 +8,7 @@ import { } from "@rilldata/web-common/features/dashboards/stores/filter-utils"; import { createBatches } from "@rilldata/web-common/lib/arrayUtils"; import { type Readable, derived } from "svelte/store"; -import { COMPARIONS_COLORS } from "@rilldata/web-common/features/dashboards/config"; +import { COMPARISON_COLORS } from "@rilldata/web-common/features/dashboards/config"; import { getDimensionFilterWithSearch } from "@rilldata/web-common/features/dashboards/dimension-table/dimension-table-utils"; import { SortDirection, @@ -413,7 +413,7 @@ export function getDimensionValueTimeSeries( results.push({ value, total, - color: COMPARIONS_COLORS[i] ? COMPARIONS_COLORS[i] : "", + color: COMPARISON_COLORS[i] ? COMPARISON_COLORS[i] : "", data: prepData, isFetching, }); From 37b8c77e6865277ccfc74bfb698c8afab8b42504 Mon Sep 17 00:00:00 2001 From: Brian Holmes Date: Tue, 3 Feb 2026 15:09:27 -0500 Subject: [PATCH 05/36] remove dead code --- .../WithGraphicContexts.svelte | 6 - .../WithRoundToTimegrain.svelte | 33 -- .../functional-components/index.ts | 1 - .../data-graphic/guides/PointLabel.svelte | 223 ------------ .../guides/TimeSeriesMouseover.svelte | 60 ---- .../components/data-graphic/guides/index.ts | 8 - .../marks/AnnotationGroupPopover.svelte | 118 ------ .../data-graphic/marks/Annotations.svelte | 90 ----- .../marks/ClippedChunkedLine.svelte | 46 --- .../data-graphic/marks/DelayedLabel.svelte | 41 --- .../marks/MultiMetricMouseoverLabel.svelte | 336 ------------------ .../components/data-graphic/marks/index.ts | 1 - .../TimeSeriesChartContainer.svelte | 42 --- .../time-series/measure-chart/bisect.ts | 176 --------- .../time-series/measure-chart/index.ts | 13 +- .../measure-chart/use-measure-time-series.ts | 132 +------ .../measure-chart/use-measure-totals.ts | 166 --------- 17 files changed, 2 insertions(+), 1490 deletions(-) delete mode 100644 web-common/src/components/data-graphic/functional-components/WithGraphicContexts.svelte delete mode 100644 web-common/src/components/data-graphic/functional-components/WithRoundToTimegrain.svelte delete mode 100644 web-common/src/components/data-graphic/guides/PointLabel.svelte delete mode 100644 web-common/src/components/data-graphic/guides/TimeSeriesMouseover.svelte delete mode 100644 web-common/src/components/data-graphic/marks/AnnotationGroupPopover.svelte delete mode 100644 web-common/src/components/data-graphic/marks/Annotations.svelte delete mode 100644 web-common/src/components/data-graphic/marks/ClippedChunkedLine.svelte delete mode 100644 web-common/src/components/data-graphic/marks/DelayedLabel.svelte delete mode 100644 web-common/src/components/data-graphic/marks/MultiMetricMouseoverLabel.svelte delete mode 100644 web-common/src/features/dashboards/time-series/TimeSeriesChartContainer.svelte delete mode 100644 web-common/src/features/dashboards/time-series/measure-chart/bisect.ts delete mode 100644 web-common/src/features/dashboards/time-series/measure-chart/use-measure-totals.ts diff --git a/web-common/src/components/data-graphic/functional-components/WithGraphicContexts.svelte b/web-common/src/components/data-graphic/functional-components/WithGraphicContexts.svelte deleted file mode 100644 index db8d63be910..00000000000 --- a/web-common/src/components/data-graphic/functional-components/WithGraphicContexts.svelte +++ /dev/null @@ -1,6 +0,0 @@ - - - diff --git a/web-common/src/components/data-graphic/functional-components/WithRoundToTimegrain.svelte b/web-common/src/components/data-graphic/functional-components/WithRoundToTimegrain.svelte deleted file mode 100644 index c6a0b140204..00000000000 --- a/web-common/src/components/data-graphic/functional-components/WithRoundToTimegrain.svelte +++ /dev/null @@ -1,33 +0,0 @@ - - - - diff --git a/web-common/src/components/data-graphic/functional-components/index.ts b/web-common/src/components/data-graphic/functional-components/index.ts index da379431bb6..7d1a09d0d80 100644 --- a/web-common/src/components/data-graphic/functional-components/index.ts +++ b/web-common/src/components/data-graphic/functional-components/index.ts @@ -1,6 +1,5 @@ export { default as WithBisector } from "./WithBisector.svelte"; export { default as WithDelayedValue } from "./WithDelayedValue.svelte"; -export { default as WithGraphicContexts } from "./WithGraphicContexts.svelte"; export { default as WithParentClientRect } from "./WithParentClientRect.svelte"; export { default as WithSimpleLinearScale } from "./WithSimpleLinearScale.svelte"; export { default as WithTween } from "./WithTween.svelte"; diff --git a/web-common/src/components/data-graphic/guides/PointLabel.svelte b/web-common/src/components/data-graphic/guides/PointLabel.svelte deleted file mode 100644 index 8806ba81c4c..00000000000 --- a/web-common/src/components/data-graphic/guides/PointLabel.svelte +++ /dev/null @@ -1,223 +0,0 @@ - - - - {@const isNull = point[yAccessor] == null} - {@const comparisonIsNull = - yComparisonAccessor === undefined || - point[yComparisonAccessor] === null || - point[yComparisonAccessor] === undefined} - {@const x = xScale(point[xAccessor])} - {@const y = !isNull - ? yScale(point[yAccessor]) - : lastAvailablePoint - ? yScale(lastAvailablePoint[yAccessor]) - : (config.plotBottom - config.plotTop) / 2} - - {@const comparisonY = yScale(point?.[`comparison.${yAccessor}`] || 0)} - - {@const text = isNull - ? "no data" - : format - ? format(point[yAccessor]) - : point[yAccessor]} - {@const comparisonText = - isNull || yComparisonAccessor === undefined - ? "no data" - : format - ? format(point[yAccessor] - point[yComparisonAccessor]) - : point[yAccessor] - point[yComparisonAccessor]} - {@const percentageDifference = - (isNull && comparisonIsNull) || yComparisonAccessor === undefined - ? undefined - : (point[yAccessor] - point[yComparisonAccessor]) / - point[yComparisonAccessor]} - {@const comparisonIsPositive = percentageDifference - ? percentageDifference >= 0 - : undefined} - {#if showReferenceLine} - - {/if} - {#if showText} - - {text} - - {/if} - {#if !isNull && showDistanceLine} - - {#if showComparisonText} - {@const signedDist = !comparisonIsPositive - ? -1 * COMPARISON_DIST - : 1 * COMPARISON_DIST} - {@const yLoc = output.y + signedDist} - {@const show = Math.abs(output.y - output.cdy) > 24} - {#if show} - - - {/if} - {/if} - {/if} - {#if !isNull && showPoint} - - {/if} - {#if !isNull && showPoint && showComparisonText} - - {/if} - {#if showComparisonText && percentageDifference} - {@const diffParts = - formatMeasurePercentageDifference(percentageDifference)} - - {comparisonText} - {" "} - ({diffParts?.neg || ""}{diffParts?.int || ""}{diffParts?.percent || ""}) - - - {/if} - - diff --git a/web-common/src/components/data-graphic/guides/TimeSeriesMouseover.svelte b/web-common/src/components/data-graphic/guides/TimeSeriesMouseover.svelte deleted file mode 100644 index 19e52f90146..00000000000 --- a/web-common/src/components/data-graphic/guides/TimeSeriesMouseover.svelte +++ /dev/null @@ -1,60 +0,0 @@ - - - { - showRawValue = false; - }} -/> - - d[xAccessor]} - let:point -> - - {#key showRawValue} - - v.toString() : format} - /> - - {/key} - diff --git a/web-common/src/components/data-graphic/guides/index.ts b/web-common/src/components/data-graphic/guides/index.ts index a69391976d3..0f918effb95 100644 --- a/web-common/src/components/data-graphic/guides/index.ts +++ b/web-common/src/components/data-graphic/guides/index.ts @@ -1,9 +1 @@ -/** - * These guide components are used to provide some context - * for the data being displayed in the chart. - * Currently, they're a pastiche of components, without much - * thought to how they should be organized or generalized. - */ export { default as DynamicallyPlacedLabel } from "./DynamicallyPlacedLabel.svelte"; -export { default as PointLabel } from "./PointLabel.svelte"; -export { default as TimeSeriesMouseover } from "./TimeSeriesMouseover.svelte"; diff --git a/web-common/src/components/data-graphic/marks/AnnotationGroupPopover.svelte b/web-common/src/components/data-graphic/marks/AnnotationGroupPopover.svelte deleted file mode 100644 index 0773026cb47..00000000000 --- a/web-common/src/components/data-graphic/marks/AnnotationGroupPopover.svelte +++ /dev/null @@ -1,118 +0,0 @@ - - -{#if $hoveredAnnotationGroup} -
- (showingMore = false)} - > - - - - -
annotationPopoverHovered.set(true)} - on:mouseleave={() => annotationPopoverHovered.set(false)} - role="menu" - tabindex="-1" - > - {#each annotationsToShow as annotation, i (i)} -
-
- {annotation.description} -
-
- {annotation.formattedTimeOrRange} -
-
- {/each} - {#if hasMoreAnnotations && !showingMore} - - {/if} -
-
-
-
-{/if} diff --git a/web-common/src/components/data-graphic/marks/Annotations.svelte b/web-common/src/components/data-graphic/marks/Annotations.svelte deleted file mode 100644 index 172d9a24dc7..00000000000 --- a/web-common/src/components/data-graphic/marks/Annotations.svelte +++ /dev/null @@ -1,90 +0,0 @@ - - -{#each $annotationGroups as annotationGroup, i (i)} - {@const hovered = $hoveredAnnotationGroup === annotationGroup} - -{/each} - -{#if hasRange} - - - - - - - - -{/if} diff --git a/web-common/src/components/data-graphic/marks/ClippedChunkedLine.svelte b/web-common/src/components/data-graphic/marks/ClippedChunkedLine.svelte deleted file mode 100644 index be63bd01e57..00000000000 --- a/web-common/src/components/data-graphic/marks/ClippedChunkedLine.svelte +++ /dev/null @@ -1,46 +0,0 @@ - - - - - - - - - - - - - diff --git a/web-common/src/components/data-graphic/marks/DelayedLabel.svelte b/web-common/src/components/data-graphic/marks/DelayedLabel.svelte deleted file mode 100644 index c31c4b6fab3..00000000000 --- a/web-common/src/components/data-graphic/marks/DelayedLabel.svelte +++ /dev/null @@ -1,41 +0,0 @@ - - - diff --git a/web-common/src/components/data-graphic/marks/MultiMetricMouseoverLabel.svelte b/web-common/src/components/data-graphic/marks/MultiMetricMouseoverLabel.svelte deleted file mode 100644 index 6b7c0ece55a..00000000000 --- a/web-common/src/components/data-graphic/marks/MultiMetricMouseoverLabel.svelte +++ /dev/null @@ -1,336 +0,0 @@ - - - - - {#if showLabels} - {#each locations as location (location.key || location.label)} - {#if (location.y || location.yRange) && (location.x || location.xRange)} - - {@const xText = - internalDirection === "right" - ? location.xRange + (xBuffer + xOffset + labelWidth) - : location.xRange - xBuffer - xOffset} - - - - {#if internalDirection === "right"} - - {#if !location?.yOverride} - {location.value - ? location.value - : formatValue(location.y)} - {/if} - - - - {#if location?.yOverride} - {location.yOverrideLabel} - {:else} - {location.label} - {/if} - - {:else} - - {#if location?.yOverride} - {location.yOverrideLabel} - {:else} - {location.label} - {/if} - - - {#if !location?.yOverride} - {location.value - ? location.value - : formatValue(location.y)} - {/if} - - {/if} - - - {#if location.yRange} - - {/if} - - - {/if} - {/each} - {/if} - - - diff --git a/web-common/src/components/data-graphic/marks/index.ts b/web-common/src/components/data-graphic/marks/index.ts index ed351e974ab..15abe6b5b4c 100644 --- a/web-common/src/components/data-graphic/marks/index.ts +++ b/web-common/src/components/data-graphic/marks/index.ts @@ -1,6 +1,5 @@ export { default as Area } from "./Area.svelte"; export { default as ChunkedLine } from "./ChunkedLine.svelte"; -export { default as ClippedChunkedLine } from "./ClippedChunkedLine.svelte"; export { default as HistogramPrimitive } from "./HistogramPrimitive.svelte"; export { default as Line } from "./Line.svelte"; export { default as Rug } from "./Rug.svelte"; diff --git a/web-common/src/features/dashboards/time-series/TimeSeriesChartContainer.svelte b/web-common/src/features/dashboards/time-series/TimeSeriesChartContainer.svelte deleted file mode 100644 index 074ac075651..00000000000 --- a/web-common/src/features/dashboards/time-series/TimeSeriesChartContainer.svelte +++ /dev/null @@ -1,42 +0,0 @@ - - - - - - diff --git a/web-common/src/features/dashboards/time-series/measure-chart/bisect.ts b/web-common/src/features/dashboards/time-series/measure-chart/bisect.ts deleted file mode 100644 index 5d59efbd7ec..00000000000 --- a/web-common/src/features/dashboards/time-series/measure-chart/bisect.ts +++ /dev/null @@ -1,176 +0,0 @@ -import { bisector } from "d3-array"; -import type { DateTimeUnit } from "luxon"; -import type { - TimeSeriesPoint, - DimensionSeriesData, - BisectedPoint, -} from "./types"; -import { - roundDownToTimeUnit, - roundToNearestTimeUnit, -} from "../round-to-nearest-time-unit"; -import { TIME_GRAIN } from "@rilldata/web-common/lib/time/config"; -import type { V1TimeGrain } from "@rilldata/web-common/runtime-client"; - -/** - * D3 bisector for TimeSeriesPoint arrays, using the ts field. - */ -export const timeSeriesBisector = bisector((d) => d.ts); - -/** - * Get the time grain label from V1TimeGrain. - */ -export function getTimeGrainLabel(timeGrain: V1TimeGrain): DateTimeUnit { - return TIME_GRAIN[timeGrain]?.label as DateTimeUnit; -} - -/** - * Find the nearest point in the data array to the given date. - * Uses optional time grain rounding for stable tooltip behavior. - */ -export function bisectTimeSeriesPoint( - data: TimeSeriesPoint[], - date: Date, - timeGrain: V1TimeGrain, - roundStrategy: "nearest" | "down" = "down", -): BisectedPoint { - if (!data.length || !date) { - return { point: null, index: -1, roundedTs: null }; - } - - const grainLabel = getTimeGrainLabel(timeGrain); - if (!grainLabel) { - return { point: null, index: -1, roundedTs: null }; - } - - // Round to time grain for stable tooltip - const roundedTs = - roundStrategy === "down" - ? roundDownToTimeUnit(date, grainLabel) - : roundToNearestTimeUnit(date, grainLabel); - - // Find nearest point using bisector center - const index = timeSeriesBisector.center(data, roundedTs); - - // Clamp index to valid range - const clampedIndex = Math.max(0, Math.min(data.length - 1, index)); - const point = data[clampedIndex] ?? null; - - return { point, index: clampedIndex, roundedTs }; -} - -/** - * Find values for all dimension series at a given time. - * Returns a map from dimension value to the data point. - */ -export function bisectDimensionData( - dimensionData: DimensionSeriesData[], - date: Date, - timeGrain: V1TimeGrain, -): Map< - string | null, - { value: number | null; color: string; point: TimeSeriesPoint | null } -> { - const results = new Map< - string | null, - { value: number | null; color: string; point: TimeSeriesPoint | null } - >(); - - for (const dim of dimensionData) { - const { point } = bisectTimeSeriesPoint(dim.data, date, timeGrain); - results.set(dim.dimensionValue, { - value: point?.value ?? null, - color: dim.color, - point, - }); - } - - return results; -} - -/** - * Check if a date is within the data's time range. - */ -export function isDateInDataRange( - date: Date, - data: TimeSeriesPoint[], -): boolean { - if (!data.length) return false; - - const firstTs = data[0].ts; - const lastTs = data[data.length - 1].ts; - - return date >= firstTs && date <= lastTs; -} - -/** - * Find the closest data point to the given screen X coordinate. - * Useful for mouse interactions. - */ -export function bisectByScreenX( - screenX: number, - data: TimeSeriesPoint[], - xScale: (date: Date) => number, - timeGrain: V1TimeGrain, -): BisectedPoint { - if (!data.length) { - return { point: null, index: -1, roundedTs: null }; - } - - // Get the time range - const firstTs = data[0].ts; - const lastTs = data[data.length - 1].ts; - - // Convert screen X to date via inverse of scale - // We need to approximate this since we don't have the inverse scale here - const firstX = xScale(firstTs); - const lastX = xScale(lastTs); - - // Linear interpolation to get approximate date - const ratio = (screenX - firstX) / (lastX - firstX); - const timeRange = lastTs.getTime() - firstTs.getTime(); - const approximateTime = new Date(firstTs.getTime() + ratio * timeRange); - - return bisectTimeSeriesPoint(data, approximateTime, timeGrain); -} - -/** - * Compute line segments from data, handling gaps (null values). - * Returns an array of segments, where each segment is a contiguous - * run of non-null data points. - */ -export function computeLineSegments( - data: TimeSeriesPoint[], -): TimeSeriesPoint[][] { - const segments: TimeSeriesPoint[][] = []; - let currentSegment: TimeSeriesPoint[] = []; - - for (const point of data) { - if (point.value !== null) { - currentSegment.push(point); - } else if (currentSegment.length > 0) { - segments.push(currentSegment); - currentSegment = []; - } - } - - // Don't forget the last segment - if (currentSegment.length > 0) { - segments.push(currentSegment); - } - - return segments; -} - -/** - * Find singleton points (segments with only one point). - * These need to be rendered as circles instead of lines. - */ -export function findSingletonPoints( - data: TimeSeriesPoint[], -): TimeSeriesPoint[] { - const segments = computeLineSegments(data); - return segments - .filter((segment) => segment.length === 1) - .map((segment) => segment[0]); -} diff --git a/web-common/src/features/dashboards/time-series/measure-chart/index.ts b/web-common/src/features/dashboards/time-series/measure-chart/index.ts index a17fbf845fd..003afc568ff 100644 --- a/web-common/src/features/dashboards/time-series/measure-chart/index.ts +++ b/web-common/src/features/dashboards/time-series/measure-chart/index.ts @@ -39,15 +39,4 @@ export { export { ScrubController } from "./ScrubController"; -// Data fetching hooks -export { - useMeasureTimeSeries, - useMeasureTimeSeriesData, - transformTimeSeriesData, -} from "./use-measure-time-series"; - -export { - useMeasureTotals, - useMeasureTotalsData, - computeComparisonMetrics, -} from "./use-measure-totals"; +export { transformTimeSeriesData } from "./use-measure-time-series"; diff --git a/web-common/src/features/dashboards/time-series/measure-chart/use-measure-time-series.ts b/web-common/src/features/dashboards/time-series/measure-chart/use-measure-time-series.ts index 83e78c12c06..75f5e97cacc 100644 --- a/web-common/src/features/dashboards/time-series/measure-chart/use-measure-time-series.ts +++ b/web-common/src/features/dashboards/time-series/measure-chart/use-measure-time-series.ts @@ -1,92 +1,7 @@ -import { derived, type Readable } from "svelte/store"; -import { - createQueryServiceMetricsViewTimeSeries, - type V1MetricsViewTimeSeriesResponse, -} from "@rilldata/web-common/runtime-client"; -import type { HTTPError } from "@rilldata/web-common/runtime-client/fetchWrapper"; -import { - keepPreviousData, - type CreateQueryResult, -} from "@tanstack/svelte-query"; -import { mergeDimensionAndMeasureFilters } from "@rilldata/web-common/features/dashboards/filters/measure-filters/measure-filter-utils"; -import { sanitiseExpression } from "@rilldata/web-common/features/dashboards/stores/filter-utils"; -import { useTimeControlStore } from "@rilldata/web-common/features/dashboards/time-controls/time-control-store"; -import type { StateManagers } from "@rilldata/web-common/features/dashboards/state-managers/state-managers"; -import { isGrainAllowed } from "@rilldata/web-common/lib/time/new-grains"; +import type { V1MetricsViewTimeSeriesResponse } from "@rilldata/web-common/runtime-client"; import type { TimeSeriesPoint } from "./types"; import { DateTime } from "luxon"; -/** - * Create a time series query for a single measure. - * Each MeasureChart component creates its own query, enabling: - * - Lazy loading (only fetch when visible) - * - Per-measure error handling - * - Independent loading states - */ -export function useMeasureTimeSeries( - ctx: StateManagers, - measureName: string, - visible: Readable, - isComparison = false, -): CreateQueryResult { - return derived( - [ - ctx.runtime, - ctx.metricsViewName, - ctx.dashboardStore, - useTimeControlStore(ctx), - visible, - ], - ( - [runtime, metricsViewName, dashboardStore, timeControls, isVisible], - set, - ) => { - const timeGrain = isGrainAllowed( - timeControls.selectedTimeRange?.interval, - timeControls.minTimeGrain, - ) - ? timeControls.selectedTimeRange?.interval - : timeControls.minTimeGrain; - - return createQueryServiceMetricsViewTimeSeries( - runtime.instanceId, - metricsViewName, - { - measureNames: [measureName], // Single measure! - where: sanitiseExpression( - mergeDimensionAndMeasureFilters( - dashboardStore.whereFilter, - dashboardStore.dimensionThresholdFilters, - ), - undefined, - ), - timeStart: isComparison - ? timeControls.comparisonAdjustedStart - : timeControls.adjustedStart, - timeEnd: isComparison - ? timeControls.comparisonAdjustedEnd - : timeControls.adjustedEnd, - timeGranularity: timeGrain, - timeZone: dashboardStore.selectedTimezone, - }, - { - query: { - // Only fetch when visible AND time controls are ready - enabled: - isVisible && - !!timeControls.ready && - !!ctx.dashboardStore && - (!isComparison || !!timeControls.comparisonAdjustedStart), - placeholderData: keepPreviousData, - refetchOnMount: false, - }, - }, - ctx.queryClient, - ).subscribe(set); - }, - ); -} - /** * Transform raw API time series data to typed TimeSeriesPoint[]. * Minimal processing: just extract ts, value, and comparison fields. @@ -127,48 +42,3 @@ export function transformTimeSeriesData( return { ts, value, comparisonValue, comparisonTs }; }); } - -/** - * Create a derived store that transforms raw query data to TimeSeriesPoint[]. - */ -export function useMeasureTimeSeriesData( - ctx: StateManagers, - measureName: string, - visible: Readable, - showComparison: Readable, -): Readable<{ - data: TimeSeriesPoint[]; - isFetching: boolean; - isError: boolean; - error: string | undefined; -}> { - const primaryQuery = useMeasureTimeSeries(ctx, measureName, visible, false); - - return derived( - [primaryQuery, showComparison, ctx.dashboardStore], - ([$primary, _$showComparison, $dashboard]) => { - if ($primary.isFetching || !$primary.data?.data) { - return { - data: [], - isFetching: $primary.isFetching, - isError: $primary.isError, - error: ($primary.error as HTTPError)?.response?.data?.message, - }; - } - - const data = transformTimeSeriesData( - $primary.data.data, - undefined, - measureName, - $dashboard.selectedTimezone, - ); - - return { - data, - isFetching: false, - isError: $primary.isError, - error: ($primary.error as HTTPError)?.response?.data?.message, - }; - }, - ); -} diff --git a/web-common/src/features/dashboards/time-series/measure-chart/use-measure-totals.ts b/web-common/src/features/dashboards/time-series/measure-chart/use-measure-totals.ts deleted file mode 100644 index 8fc4146327c..00000000000 --- a/web-common/src/features/dashboards/time-series/measure-chart/use-measure-totals.ts +++ /dev/null @@ -1,166 +0,0 @@ -import { derived, type Readable } from "svelte/store"; -import { - createQueryServiceMetricsViewAggregation, - type V1MetricsViewAggregationResponse, -} from "@rilldata/web-common/runtime-client"; -import type { HTTPError } from "@rilldata/web-common/runtime-client/fetchWrapper"; -import { - keepPreviousData, - type CreateQueryResult, -} from "@tanstack/svelte-query"; -import { mergeDimensionAndMeasureFilters } from "@rilldata/web-common/features/dashboards/filters/measure-filters/measure-filter-utils"; -import { sanitiseExpression } from "@rilldata/web-common/features/dashboards/stores/filter-utils"; -import { useTimeControlStore } from "@rilldata/web-common/features/dashboards/time-controls/time-control-store"; -import type { StateManagers } from "@rilldata/web-common/features/dashboards/state-managers/state-managers"; - -/** - * Totals data for a single measure. - */ -export interface MeasureTotalsData { - value: number | null; - comparisonValue: number | null; - isFetching: boolean; - isError: boolean; - error: string | undefined; -} - -/** - * Create a totals query for a single measure. - * Used for the big number display above each chart. - */ -export function useMeasureTotals( - ctx: StateManagers, - measureName: string, - visible: Readable, - isComparison = false, -): CreateQueryResult { - return derived( - [ - ctx.runtime, - ctx.metricsViewName, - useTimeControlStore(ctx), - ctx.dashboardStore, - visible, - ], - ([runtime, metricsViewName, timeControls, dashboard, isVisible], set) => - createQueryServiceMetricsViewAggregation( - runtime.instanceId, - metricsViewName, - { - measures: [{ name: measureName }], - where: sanitiseExpression( - mergeDimensionAndMeasureFilters( - dashboard.whereFilter, - dashboard.dimensionThresholdFilters, - ), - undefined, - ), - timeRange: { - start: isComparison - ? timeControls?.comparisonTimeStart - : timeControls.timeStart, - end: isComparison - ? timeControls?.comparisonTimeEnd - : timeControls.timeEnd, - }, - }, - { - query: { - enabled: - isVisible && - !!timeControls.ready && - !!ctx.dashboardStore && - (!isComparison || !!timeControls.comparisonTimeStart), - placeholderData: keepPreviousData, - refetchOnMount: false, - }, - }, - ctx.queryClient, - ).subscribe(set), - ); -} - -/** - * Create a derived store that provides totals data for a measure. - * Includes both primary and comparison values. - */ -export function useMeasureTotalsData( - ctx: StateManagers, - measureName: string, - visible: Readable, - showComparison: Readable, -): Readable { - const primaryQuery = useMeasureTotals(ctx, measureName, visible, false); - - // Create comparison query only when comparison is enabled - const comparisonQuery = derived( - [showComparison, visible], - ([$showComparison, $visible]) => { - if (!$showComparison) { - return null; - } - return useMeasureTotals(ctx, measureName, visible, true); - }, - ); - - return derived( - [primaryQuery, comparisonQuery, showComparison], - ([$primary, $comparisonQueryStore, $showComparison]) => { - // Get primary value - const primaryValue = - ($primary.data?.data?.[0]?.[measureName] as number | null) ?? null; - - // Get comparison value if applicable - let comparisonValue: number | null = null; - let comparisonIsFetching = false; - let comparisonIsError = false; - let comparisonError: string | undefined; - - if ($showComparison && $comparisonQueryStore) { - // Note: This is a simplified version. In full implementation, - // we'd need to properly subscribe to the comparison query. - // For now, comparison is handled at the parent level. - } - - return { - value: primaryValue, - comparisonValue, - isFetching: $primary.isFetching || comparisonIsFetching, - isError: $primary.isError || comparisonIsError, - error: - ($primary.error as HTTPError)?.response?.data?.message ?? - comparisonError, - }; - }, - ); -} - -/** - * Compute comparison metrics from primary and comparison values. - */ -export function computeComparisonMetrics( - value: number | null, - comparisonValue: number | null, -): { - delta: number | null; - deltaPercent: number | null; - isPositive: boolean | null; -} { - if (value === null || comparisonValue === null || comparisonValue === 0) { - return { - delta: null, - deltaPercent: null, - isPositive: null, - }; - } - - const delta = value - comparisonValue; - const deltaPercent = (delta / Math.abs(comparisonValue)) * 100; - const isPositive = delta >= 0; - - return { - delta, - deltaPercent, - isPositive, - }; -} From 2521ec3837c722a61518cf60d4347051562501e9 Mon Sep 17 00:00:00 2001 From: Brian Holmes Date: Tue, 3 Feb 2026 15:11:54 -0500 Subject: [PATCH 06/36] cleanup --- .../src/features/dashboards/big-number/MeasureBigNumber.svelte | 2 +- .../src/features/dashboards/big-number/MeasuresContainer.svelte | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/web-common/src/features/dashboards/big-number/MeasureBigNumber.svelte b/web-common/src/features/dashboards/big-number/MeasureBigNumber.svelte index 2e22036b657..e6ff34d10a7 100644 --- a/web-common/src/features/dashboards/big-number/MeasureBigNumber.svelte +++ b/web-common/src/features/dashboards/big-number/MeasureBigNumber.svelte @@ -24,7 +24,7 @@ type FlyParams, } from "svelte/transition"; import BigNumberTooltipContent from "./BigNumberTooltipContent.svelte"; - import { keepPreviousData, type QueryClient } from "@tanstack/svelte-query"; + import { keepPreviousData } from "@tanstack/svelte-query"; export let measure: MetricsViewSpecMeasure; export let withTimeseries = true; diff --git a/web-common/src/features/dashboards/big-number/MeasuresContainer.svelte b/web-common/src/features/dashboards/big-number/MeasuresContainer.svelte index 9f4331d52f5..e04fc07de81 100644 --- a/web-common/src/features/dashboards/big-number/MeasuresContainer.svelte +++ b/web-common/src/features/dashboards/big-number/MeasuresContainer.svelte @@ -188,7 +188,6 @@ {metricsViewName} where={chartWhere} ready={chartReady} - queryClient={ctx.queryClient} />
{/each} From 17ed12069abfa3a26f09c7ff3447e3724ac18ffe Mon Sep 17 00:00:00 2001 From: Brian Holmes Date: Tue, 3 Feb 2026 15:29:42 -0500 Subject: [PATCH 07/36] cleanup --- web-common/src/features/dashboards/workspace/Dashboard.svelte | 2 -- 1 file changed, 2 deletions(-) diff --git a/web-common/src/features/dashboards/workspace/Dashboard.svelte b/web-common/src/features/dashboards/workspace/Dashboard.svelte index 32bde3d3b31..ff9b721f691 100644 --- a/web-common/src/features/dashboards/workspace/Dashboard.svelte +++ b/web-common/src/features/dashboards/workspace/Dashboard.svelte @@ -190,8 +190,6 @@ {#if hasTimeSeries} {:else} From e2a107e9b352a28925945d847ce52ebf7fe474f8 Mon Sep 17 00:00:00 2001 From: Brian Holmes Date: Tue, 3 Feb 2026 15:36:17 -0500 Subject: [PATCH 08/36] test fix --- .../time-series/measure-chart/MeasureChart.svelte | 4 +++- web-local/tests/explores/timeseries.spec.ts | 12 ++++++------ 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/web-common/src/features/dashboards/time-series/measure-chart/MeasureChart.svelte b/web-common/src/features/dashboards/time-series/measure-chart/MeasureChart.svelte index e16da67fddd..9194f0de3e3 100644 --- a/web-common/src/features/dashboards/time-series/measure-chart/MeasureChart.svelte +++ b/web-common/src/features/dashboards/time-series/measure-chart/MeasureChart.svelte @@ -758,8 +758,10 @@ />
{:else if data.length > 0} + { diff --git a/web-local/tests/explores/timeseries.spec.ts b/web-local/tests/explores/timeseries.spec.ts index 7b32d8f23ad..d1af1a9f689 100644 --- a/web-local/tests/explores/timeseries.spec.ts +++ b/web-local/tests/explores/timeseries.spec.ts @@ -23,12 +23,12 @@ interface TimeRangeTestCase { const TIME_RANGE_TEST_CASES: TimeRangeTestCase[] = [ { menuItem: "Last 7 days", - expectedDataPoints: 9, + expectedDataPoints: 7, grain: V1TimeGrain.TIME_GRAIN_DAY, }, { menuItem: "Last 24 hours", - expectedDataPoints: 26, + expectedDataPoints: 24, grain: V1TimeGrain.TIME_GRAIN_HOUR, }, ]; @@ -50,8 +50,8 @@ async function verifyChartTooltipData( const centerY = box.y + box.height / 2; let verifiedPoints = 0; let lastDateText: string | undefined; - // Exclude first and last data points as they're not rendered - const expectedPoints = apiData.data.length - 2; + + const expectedPoints = apiData.data.length; for (let x = box.x; x < box.x + box.width; x += HOVER_STEP_PX) { await page.mouse.move(x, centerY); @@ -63,8 +63,8 @@ async function verifyChartTooltipData( if (!dateText || dateText === lastDateText) continue; lastDateText = dateText; - // Skip the first data point (index 0) as chart starts from second point - const point = apiData.data[verifiedPoints + 1]; + + const point = apiData.data[verifiedPoints]; const dateTime = DateTime.fromISO(point.ts, { zone: "UTC" }); const pattern = formatDateTimeByGrain(dateTime, grain); From 8f82d00cbd6c778ad7d5716239acdb182b190b89 Mon Sep 17 00:00:00 2001 From: Brian Holmes Date: Tue, 3 Feb 2026 18:03:59 -0500 Subject: [PATCH 09/36] huge cleanup --- scripts/tsc-with-whitelist.sh | 2 - scripts/web-test-code-quality.sh | 2 +- web-common/src/app.css | 6 + ...mouse-position-to-domain-action-factory.ts | 83 -- .../data-graphic/actions/outline.ts | 91 --- .../actions/scrub-action-factory.ts | 241 ------ .../timestamp-profile/TimestampDetail.svelte | 768 +++++++++--------- .../TimestampMouseoverAnnotation.svelte | 83 -- .../timestamp-profile/TimestampPaths.svelte | 148 ---- .../timestamp-profile/TimestampSpark.svelte | 103 +-- .../timestamp-profile/ZoomWindow.svelte | 31 - .../elements/GraphicContext.svelte | 219 ----- .../elements/SimpleDataGraphic.svelte | 94 --- .../elements/SimpleSVGContainer.svelte | 79 -- .../components/data-graphic/elements/index.ts | 3 - .../functional-components/WithBisector.svelte | 28 - .../WithDelayedValue.svelte | 21 - .../WithSimpleLinearScale.svelte | 12 - .../get-graphic-contexts.ts | 14 - .../functional-components/index.ts | 4 - .../guides/DynamicallyPlacedLabel.svelte | 84 -- .../components/data-graphic/guides/index.ts | 1 - .../components/data-graphic/guides/types.d.ts | 2 - .../components/data-graphic/marks/Area.svelte | 78 -- .../data-graphic/marks/ChunkedLine.svelte | 186 ----- .../marks/HistogramPrimitive.svelte | 89 -- .../components/data-graphic/marks/Line.svelte | 124 --- .../components/data-graphic/marks/Rug.svelte | 67 -- .../components/data-graphic/marks/index.ts | 5 - .../state/cascading-context-store.ts | 80 -- .../state/extremum-resolution-store.spec.ts | 65 -- .../state/extremum-resolution-store.ts | 124 --- .../components/data-graphic/state/index.ts | 7 - .../data-graphic/state/scale-stores.ts | 70 -- .../components/data-graphic/state/types.d.ts | 14 +- .../src/components/data-graphic/utils.ts | 215 +---- .../time-series-chart/BarChart.svelte | 30 +- .../column-types/TimestampProfile.svelte | 65 +- .../column-types/details/NumericPlot.svelte | 537 +++++++----- .../details/SummaryNumberPlot.svelte | 122 ++- .../column-types/sparks/NumericSpark.svelte | 92 ++- .../column-types/sparks/TimestampSpark.svelte | 57 -- .../MetricsTimeSeriesCharts.svelte | 3 +- .../time-series/TimeSeriesAxis.svelte | 43 - .../measure-chart/MeasureChart.svelte | 169 ++-- .../measure-chart/MeasureChartTooltip.svelte | 57 +- .../measure-chart/annotation-utils.ts | 23 +- .../time-series/measure-chart/index.ts | 13 +- .../time-series/measure-chart/interactions.ts | 24 +- .../time-series/measure-chart/scales.ts | 68 +- .../time-series/measure-chart/types.ts | 49 -- .../time-series/measure-chart/utils.ts | 71 ++ .../round-to-nearest-time-unit.spec.ts | 28 - .../time-series/round-to-nearest-time-unit.ts | 71 -- .../features/dashboards/time-series/utils.ts | 28 - 55 files changed, 1253 insertions(+), 3540 deletions(-) delete mode 100644 web-common/src/components/data-graphic/actions/mouse-position-to-domain-action-factory.ts delete mode 100644 web-common/src/components/data-graphic/actions/outline.ts delete mode 100644 web-common/src/components/data-graphic/actions/scrub-action-factory.ts delete mode 100644 web-common/src/components/data-graphic/compositions/timestamp-profile/TimestampMouseoverAnnotation.svelte delete mode 100644 web-common/src/components/data-graphic/compositions/timestamp-profile/TimestampPaths.svelte delete mode 100644 web-common/src/components/data-graphic/compositions/timestamp-profile/ZoomWindow.svelte delete mode 100644 web-common/src/components/data-graphic/elements/GraphicContext.svelte delete mode 100644 web-common/src/components/data-graphic/elements/SimpleDataGraphic.svelte delete mode 100644 web-common/src/components/data-graphic/elements/SimpleSVGContainer.svelte delete mode 100644 web-common/src/components/data-graphic/elements/index.ts delete mode 100644 web-common/src/components/data-graphic/functional-components/WithBisector.svelte delete mode 100644 web-common/src/components/data-graphic/functional-components/WithDelayedValue.svelte delete mode 100644 web-common/src/components/data-graphic/functional-components/WithSimpleLinearScale.svelte delete mode 100644 web-common/src/components/data-graphic/functional-components/get-graphic-contexts.ts delete mode 100644 web-common/src/components/data-graphic/guides/DynamicallyPlacedLabel.svelte delete mode 100644 web-common/src/components/data-graphic/guides/index.ts delete mode 100644 web-common/src/components/data-graphic/guides/types.d.ts delete mode 100644 web-common/src/components/data-graphic/marks/Area.svelte delete mode 100644 web-common/src/components/data-graphic/marks/ChunkedLine.svelte delete mode 100644 web-common/src/components/data-graphic/marks/HistogramPrimitive.svelte delete mode 100644 web-common/src/components/data-graphic/marks/Line.svelte delete mode 100644 web-common/src/components/data-graphic/marks/Rug.svelte delete mode 100644 web-common/src/components/data-graphic/marks/index.ts delete mode 100644 web-common/src/components/data-graphic/state/cascading-context-store.ts delete mode 100644 web-common/src/components/data-graphic/state/extremum-resolution-store.spec.ts delete mode 100644 web-common/src/components/data-graphic/state/extremum-resolution-store.ts delete mode 100644 web-common/src/components/data-graphic/state/index.ts delete mode 100644 web-common/src/components/data-graphic/state/scale-stores.ts delete mode 100644 web-common/src/features/column-profile/column-types/sparks/TimestampSpark.svelte delete mode 100644 web-common/src/features/dashboards/time-series/TimeSeriesAxis.svelte create mode 100644 web-common/src/features/dashboards/time-series/measure-chart/utils.ts delete mode 100644 web-common/src/features/dashboards/time-series/round-to-nearest-time-unit.spec.ts delete mode 100644 web-common/src/features/dashboards/time-series/round-to-nearest-time-unit.ts diff --git a/scripts/tsc-with-whitelist.sh b/scripts/tsc-with-whitelist.sh index b198a0ac01a..2bc3a608df5 100755 --- a/scripts/tsc-with-whitelist.sh +++ b/scripts/tsc-with-whitelist.sh @@ -23,8 +23,6 @@ web-admin/src/routes/[organization]/-/upgrade-callback/+page.ts: error TS2307 web-admin/src/routes/[organization]/[project]/-/open-query/+page.ts: error TS2307 web-admin/src/routes/[organization]/[project]/-/share/[token]/+page.ts: error TS2345 web-common/src/components/data-graphic/actions/mouse-position-to-domain-action-factory.ts: error TS2322 -web-common/src/components/data-graphic/actions/outline.ts: error TS18047 -web-common/src/components/data-graphic/actions/outline.ts: error TS2345 web-common/src/components/data-graphic/marks/segment.ts: error TS2345 web-common/src/components/data-graphic/utils.ts: error TS2362 web-common/src/components/data-graphic/utils.ts: error TS2363 diff --git a/scripts/web-test-code-quality.sh b/scripts/web-test-code-quality.sh index 1a32b3b81a9..3ed053d0f81 100755 --- a/scripts/web-test-code-quality.sh +++ b/scripts/web-test-code-quality.sh @@ -79,7 +79,7 @@ if [[ "$COMMON" == "true" ]]; then npx svelte-kit sync cd .. npx eslint web-common --quiet || exit_code=$? - npx svelte-check --workspace web-common --no-tsconfig --ignore "src/features/dashboards/time-series/MetricsTimeSeriesCharts.svelte,src/features/dashboards/time-series/MeasureChart.svelte,src/features/dashboards/time-controls/TimeControls.svelte,src/components/data-graphic/elements/GraphicContext.svelte,src/components/data-graphic/guides/Axis.svelte,src/components/data-graphic/guides/DynamicallyPlacedLabel.svelte,src/components/data-graphic/guides/Grid.svelte,src/components/data-graphic/compositions/timestamp-profile/TimestampDetail.svelte,src/components/data-graphic/marks/Area.svelte,src/components/data-graphic/marks/ChunkedLine.svelte,src/components/data-graphic/marks/HistogramPrimitive.svelte,src/components/data-graphic/marks/Line.svelte,src/components/data-graphic/marks/MultiMetricMouseoverLabel.svelte,src/features/column-profile/column-types/details/SummaryNumberPlot.svelte,src/stories/Tooltip.stories.svelte,src/lib/number-formatting/__stories__/NumberFormatting.stories.svelte" || exit_code=$? + npx svelte-check --workspace web-common --no-tsconfig --ignore "src/stories/Tooltip.stories.svelte,src/lib/number-formatting/__stories__/NumberFormatting.stories.svelte" || exit_code=$? fi if [[ "$LOCAL" == "true" ]]; then diff --git a/web-common/src/app.css b/web-common/src/app.css index 6e1d3f5540a..aea70f2b68e 100644 --- a/web-common/src/app.css +++ b/web-common/src/app.css @@ -48,6 +48,12 @@ .ui-measure-bar-excluded { @apply bg-surface-overlay; } + + .text-outline { + stroke: var(--surface-background); + stroke-width: 4px; + paint-order: stroke; + } } @layer base { diff --git a/web-common/src/components/data-graphic/actions/mouse-position-to-domain-action-factory.ts b/web-common/src/components/data-graphic/actions/mouse-position-to-domain-action-factory.ts deleted file mode 100644 index 3e349ec5dc5..00000000000 --- a/web-common/src/components/data-graphic/actions/mouse-position-to-domain-action-factory.ts +++ /dev/null @@ -1,83 +0,0 @@ -/** - * @module mousePositionToDomainActionFactory - * This action factory creates - * 1. a readable store that contains the domain coordinates - * 2. an action that updates the readable store when the mouse moves over the attached DOM element - */ -import { getContext } from "svelte"; -import { get, type Readable, writable } from "svelte/store"; -import { DEFAULT_COORDINATES } from "../constants"; -import type { Action, ActionReturn } from "svelte/action"; -import { contexts } from "../constants"; -import type { DomainCoordinates } from "../constants/types"; -import type { ScaleStore } from "../state/types"; - -export interface MousePositionToDomainActionSet { - coordinates: Readable; - mousePositionToDomain: Action; - mouseover: Readable; -} - -export function mousePositionToDomainActionFactory(): MousePositionToDomainActionSet { - const coordinateStore = writable({ - ...DEFAULT_COORDINATES, - }); - const xScale = getContext(contexts.scale("x")); - const yScale = getContext(contexts.scale("y")); - - let offsetX: number; - let offsetY: number; - const mouseover = writable(false); - - const unsubscribeFromXScale = xScale.subscribe((xs) => { - if (get(mouseover)) { - coordinateStore.update((coords) => { - return { ...coords, x: xs(offsetX) }; - }); - } - }); - const unsubscribeFromYScale = yScale.subscribe((ys) => { - if (get(mouseover)) { - coordinateStore.update((coords) => { - return { ...coords, y: ys(offsetY) }; - }); - } - }); - - function onMouseMove(event) { - offsetX = event.offsetX; - offsetY = event.offsetY; - - coordinateStore.set({ - x: get(xScale).invert(offsetX), - y: get(yScale).invert(offsetY), - xActual: offsetX, - yActual: offsetY, - }); - mouseover.set(true); - } - - function onMouseLeave() { - coordinateStore.set({ ...DEFAULT_COORDINATES }); - mouseover.set(false); - } - const coordinates = { - subscribe: coordinateStore.subscribe, - } as Readable; - return { - coordinates, - mouseover, - mousePositionToDomain(node: HTMLElement | SVGElement): ActionReturn { - node.addEventListener("mousemove", onMouseMove); - node.addEventListener("mouseleave", onMouseLeave); - return { - destroy(): void { - unsubscribeFromXScale(); - unsubscribeFromYScale(); - node.removeEventListener("mousemove", onMouseMove); - node.removeEventListener("mouseleave", onMouseLeave); - }, - }; - }, - }; -} diff --git a/web-common/src/components/data-graphic/actions/outline.ts b/web-common/src/components/data-graphic/actions/outline.ts deleted file mode 100644 index c7e9accb8fc..00000000000 --- a/web-common/src/components/data-graphic/actions/outline.ts +++ /dev/null @@ -1,91 +0,0 @@ -/** this action appends another text DOM element - * that gives an outlined / punched-out look to whatever - * svg text node it is applied to. It will then listen to - * any of the relevant attributes / the content itself - * and update accordingly via a basic MutationObserver. - */ - -interface OutlineAction { - destroy: () => void; -} - -export function outline( - node: SVGElement, - args = { color: "white" }, -): OutlineAction { - const enclosingSVG = node.ownerSVGElement; - - // create a clone of the element. - const clonedElement = node.cloneNode(true) as SVGElement; - node.parentElement.insertBefore(clonedElement, node); - clonedElement.setAttribute("fill", args.color); - clonedElement.style.fill = args.color; - clonedElement.setAttribute("filter", "url(#outline-filter)"); - // apply the filter to this svg element. - let outlineFilter = enclosingSVG.querySelector("#outline-filter"); - if (outlineFilter === null) { - outlineFilter = document.createElementNS( - "http://www.w3.org/2000/svg", - "filter", - ); - - outlineFilter.id = "outline-filter"; - - const morph = document.createElementNS( - "http://www.w3.org/2000/svg", - "feMorphology", - ); - morph.setAttribute("operator", "dilate"); - morph.setAttribute("radius", "2"); - morph.setAttribute("in", "SourceGraphic"); - morph.setAttribute("result", "THICKNESS"); - - const composite = document.createElementNS( - "http://www.w3.org/2000/svg", - "feComposite", - ); - composite.setAttribute("operator", "out"); - composite.setAttribute("in", "THICKNESS"); - composite.setAttribute("in2", "SourceGraphic"); - - outlineFilter.appendChild(morph); - outlineFilter.appendChild(composite); - enclosingSVG.prepend(outlineFilter); - } - - const config = { - attributes: true, - childList: true, - subtree: true, - characterData: true, - }; - const observer = new MutationObserver(() => { - clonedElement.setAttribute("x", node.getAttribute("x")); - clonedElement.setAttribute("y", node.getAttribute("y")); - if (node.getAttribute("text-anchor")) { - clonedElement.setAttribute( - "text-anchor", - node.getAttribute("text-anchor"), - ); - } - - if (node.getAttribute("dx")) { - clonedElement.setAttribute("dx", node.getAttribute("dx")); - } - if (node.getAttribute("dy")) { - clonedElement.setAttribute("dy", node.getAttribute("dy")); - } - - // clone any animations that may be applied via svelte transitions. - clonedElement.style.animation = node.style.animation; - // copy the contents of the node. - clonedElement.innerHTML = node.innerHTML; - }); - observer.observe(node, config); - - return { - destroy() { - clonedElement.remove(); - }, - }; -} diff --git a/web-common/src/components/data-graphic/actions/scrub-action-factory.ts b/web-common/src/components/data-graphic/actions/scrub-action-factory.ts deleted file mode 100644 index 0d4ff77bd72..00000000000 --- a/web-common/src/components/data-graphic/actions/scrub-action-factory.ts +++ /dev/null @@ -1,241 +0,0 @@ -/** - * scrub-action-factory - * -------------------- - * This action factory produces an object that contains - * - a coordinates store, which has the x and y start and stop values - * of the in-progress scrub. - * - an isScrubbing store, which the user can exploit to see if scrubbing is - * currently happening - * - a movement store, which captures the momentum of the scrub. - * - a customized action - * - * Why is this an action factory and not an action? Because we actually want to initialize a bunch - * of stores that are used throughout the app, which respond to the action's logic automatically, - * and can thus be consumed within the application without any other explicit call point. - * This action factory pattern is quite useful in a variety of settings. - * - */ - -import { get, writable } from "svelte/store"; -import { DEFAULT_NUMBER_COORDINATES } from "../constants"; - -/** converts an event to a simplified object - * with only the needed properties - */ -function mouseEvents(event: MouseEvent) { - return { - movementX: event.movementX, - movementY: event.movementY, - clientX: event.clientX, - clientY: event.clientY, - ctrlKey: event.ctrlKey, - altKey: event.altKey, - shiftKey: event.shiftKey, - metaKey: event.metaKey, - }; -} - -interface ScrubActionFactoryArguments { - /** the bounds where the scrub is active. */ - plotLeft: number; - plotRight: number; - plotTop: number; - plotBottom: number; - /** the name of the events we declare for start, move, end. - * Typically mousedown, mousemove, and mouseup. - */ - - startEvent?: string; - endEvent?: string; - moveEvent?: string; - startEventName?: string; - /** the dispatched move event name for the scrub move effect, to be - * passed up to the parent element when the scrub move has happened. - * e.g. - */ - moveEventName?: string; - /** the dispatched move event name for the scrub completion effect, to be - * passed up to the parent element when the scrub is completed. - * e.g. when moveEventName = "scrubbing", we have
- */ - endEventName?: string; - /** These predicates will gate whether we continue with - * the startEvent, moveEvent, and endEvents. - * If they're not passed in as arguments, the action - * will always assume they're true. - * This is used e.g. when a user wants to hold the shift or alt key, or - * check for some other condition to to be true. - * e.g when completedEventName = "scrub", we have
- */ - startPredicate?: (event: Event) => boolean; - movePredicate?: (event: Event) => boolean; - endPredicate?: (event: Event) => boolean; -} - -export interface PlotBounds { - plotLeft?: number; - plotRight?: number; - plotTop?: number; - plotBottom?: number; -} - -interface ScrubAction { - destroy: () => void; -} - -function clamp(v: number, min: number, max: number) { - if (v < min) return min; - if (v > max) return max; - return v; -} - -/** - * - * NOTE: types for these scrub action are added to the - * `interface SVGAttributes` in the svelteHTML namespace in - * `web-common/app.d.ts` - */ -export function createScrubAction({ - plotLeft, - plotRight, - plotTop, - plotBottom, - startEvent = "mousedown", - startEventName = undefined, - startPredicate = undefined, - endEvent = "mouseup", - endPredicate = undefined, - moveEvent = "mousemove", - movePredicate = undefined, - endEventName = undefined, - moveEventName = undefined, -}: ScrubActionFactoryArguments) { - const coordinates = writable({ - start: DEFAULT_NUMBER_COORDINATES, - stop: DEFAULT_NUMBER_COORDINATES, - }); - - /** local plot bound state */ - let _plotLeft = plotLeft; - let _plotRight = plotRight; - let _plotTop = plotTop; - let _plotBottom = plotBottom; - - const movement = writable({ - xMovement: 0, - yMovement: 0, - }); - - const isScrubbing = writable(false); - - function setCoordinateBounds(event: MouseEvent) { - return { - x: clamp(event.offsetX, _plotLeft, _plotRight), - y: clamp(event.offsetY, _plotTop, _plotBottom), - }; - } - - return { - coordinates, - isScrubbing, - movement, - updatePlotBounds(bounds: PlotBounds) { - if (bounds.plotLeft) _plotLeft = bounds.plotLeft; - if (bounds.plotRight) _plotRight = bounds.plotRight; - if (bounds.plotTop) _plotTop = bounds.plotTop; - if (bounds.plotBottom) _plotBottom = bounds.plotBottom; - }, - scrubAction(node: Node): ScrubAction { - function reset() { - coordinates.set({ - start: DEFAULT_NUMBER_COORDINATES, - stop: DEFAULT_NUMBER_COORDINATES, - }); - isScrubbing.set(false); - } - - function onScrubStart(event: MouseEvent) { - // Check for the main button press - if (event.button !== 0) return; - if (!(startPredicate === undefined || startPredicate(event))) { - return; - } - node.addEventListener(moveEvent, onScrub); - coordinates.set({ - start: setCoordinateBounds(event), - stop: DEFAULT_NUMBER_COORDINATES, - }); - isScrubbing.set(true); - if (startEventName) { - node.dispatchEvent( - new CustomEvent(startEventName, { - detail: { - ...get(coordinates), - ...mouseEvents(event), - }, - }), - ); - } - } - - function onScrub(event: MouseEvent) { - event.preventDefault(); - - if (!(movePredicate === undefined || movePredicate(event))) { - reset(); - return; - } - coordinates.update((coords) => { - const newCoords = { ...coords }; - newCoords.stop = setCoordinateBounds(event); - return newCoords; - }); - const coords = get(coordinates); - // fire the moveEventName event. - // e.g. on:scrubbing={(event) => { ... }} - if (moveEventName) { - node.dispatchEvent( - new CustomEvent(moveEventName, { - detail: { - ...coords, - ...mouseEvents(event), - }, - }), - ); - } - } - - function onScrubEnd(event: MouseEvent) { - node.removeEventListener(moveEvent, onScrub); - if (!(endPredicate === undefined || endPredicate(event))) { - reset(); - return; - } - const coords = get(coordinates); - if (coords.start.x && coords.stop.x && endEventName) { - node.dispatchEvent( - new CustomEvent(endEventName, { - detail: { - ...coords, - ...mouseEvents(event), - }, - }), - ); - } - reset(); - } - - node.addEventListener(startEvent, onScrubStart); - window.addEventListener(endEvent, onScrubEnd); - window.addEventListener(endEvent, reset); - return { - destroy() { - node.removeEventListener(startEvent, onScrubStart); - node.removeEventListener(moveEvent, onScrub); - window.removeEventListener(endEvent, onScrubEnd); - window.removeEventListener(endEvent, reset); - }, - }; - }, - }; -} diff --git a/web-common/src/components/data-graphic/compositions/timestamp-profile/TimestampDetail.svelte b/web-common/src/components/data-graphic/compositions/timestamp-profile/TimestampDetail.svelte index d6decf004cb..af6a07e62b2 100644 --- a/web-common/src/components/data-graphic/compositions/timestamp-profile/TimestampDetail.svelte +++ b/web-common/src/components/data-graphic/compositions/timestamp-profile/TimestampDetail.svelte @@ -1,297 +1,312 @@ + - - - - {#each [[yAccessor, "rgb(100,100,100)"]] as [accessor, color]} - {@const cx = $X(point[xAccessor])} - {@const cy = $Y(point[accessor])} - {#if cx && cy} - - {/if} - {/each} - - - {datePortion(xLabel)} - - - {timePortion(xLabel)} - - - {formatInteger(Math.trunc(point[yAccessor]))} row{#if point[yAccessor] !== 1}s{/if} - - - diff --git a/web-common/src/components/data-graphic/compositions/timestamp-profile/TimestampPaths.svelte b/web-common/src/components/data-graphic/compositions/timestamp-profile/TimestampPaths.svelte deleted file mode 100644 index d0bf55df120..00000000000 --- a/web-common/src/components/data-graphic/compositions/timestamp-profile/TimestampPaths.svelte +++ /dev/null @@ -1,148 +0,0 @@ - - - - - - - $plotConfig.width * $plotConfig.devicePixelRatio - ? 1 - : 0} -> - - - diff --git a/web-common/src/components/data-graphic/compositions/timestamp-profile/TimestampSpark.svelte b/web-common/src/components/data-graphic/compositions/timestamp-profile/TimestampSpark.svelte index faa3855b2b7..62303e10038 100644 --- a/web-common/src/components/data-graphic/compositions/timestamp-profile/TimestampSpark.svelte +++ b/web-common/src/components/data-graphic/compositions/timestamp-profile/TimestampSpark.svelte @@ -1,27 +1,20 @@ {#if data.length} - + + + + + + + - - + {#if linePath} + + {/if} + {#if areaPath} + + {/if} - {#if zoomWindowXMin && zoomWindowXMax} {/if} - + {/if} diff --git a/web-common/src/components/data-graphic/compositions/timestamp-profile/ZoomWindow.svelte b/web-common/src/components/data-graphic/compositions/timestamp-profile/ZoomWindow.svelte deleted file mode 100644 index 602494f9764..00000000000 --- a/web-common/src/components/data-graphic/compositions/timestamp-profile/ZoomWindow.svelte +++ /dev/null @@ -1,31 +0,0 @@ - - - - diff --git a/web-common/src/components/data-graphic/elements/GraphicContext.svelte b/web-common/src/components/data-graphic/elements/GraphicContext.svelte deleted file mode 100644 index 432e5f0c7ae..00000000000 --- a/web-common/src/components/data-graphic/elements/GraphicContext.svelte +++ /dev/null @@ -1,219 +0,0 @@ - - - - diff --git a/web-common/src/components/data-graphic/elements/SimpleDataGraphic.svelte b/web-common/src/components/data-graphic/elements/SimpleDataGraphic.svelte deleted file mode 100644 index faeab4e2cf6..00000000000 --- a/web-common/src/components/data-graphic/elements/SimpleDataGraphic.svelte +++ /dev/null @@ -1,94 +0,0 @@ - - - - - - - - diff --git a/web-common/src/components/data-graphic/elements/SimpleSVGContainer.svelte b/web-common/src/components/data-graphic/elements/SimpleSVGContainer.svelte deleted file mode 100644 index 12284f3b3de..00000000000 --- a/web-common/src/components/data-graphic/elements/SimpleSVGContainer.svelte +++ /dev/null @@ -1,79 +0,0 @@ - - - - - - - - diff --git a/web-common/src/components/data-graphic/elements/index.ts b/web-common/src/components/data-graphic/elements/index.ts deleted file mode 100644 index ba37f42c62b..00000000000 --- a/web-common/src/components/data-graphic/elements/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { default as GraphicContext } from "./GraphicContext.svelte"; -export { default as SimpleDataGraphic } from "./SimpleDataGraphic.svelte"; -export { default as SimpleSVGContainer } from "./SimpleSVGContainer.svelte"; diff --git a/web-common/src/components/data-graphic/functional-components/WithBisector.svelte b/web-common/src/components/data-graphic/functional-components/WithBisector.svelte deleted file mode 100644 index 2c86b384711..00000000000 --- a/web-common/src/components/data-graphic/functional-components/WithBisector.svelte +++ /dev/null @@ -1,28 +0,0 @@ - - - - diff --git a/web-common/src/components/data-graphic/functional-components/WithDelayedValue.svelte b/web-common/src/components/data-graphic/functional-components/WithDelayedValue.svelte deleted file mode 100644 index c959fbbc655..00000000000 --- a/web-common/src/components/data-graphic/functional-components/WithDelayedValue.svelte +++ /dev/null @@ -1,21 +0,0 @@ - - - - diff --git a/web-common/src/components/data-graphic/functional-components/WithSimpleLinearScale.svelte b/web-common/src/components/data-graphic/functional-components/WithSimpleLinearScale.svelte deleted file mode 100644 index d12139765cd..00000000000 --- a/web-common/src/components/data-graphic/functional-components/WithSimpleLinearScale.svelte +++ /dev/null @@ -1,12 +0,0 @@ - - - - diff --git a/web-common/src/components/data-graphic/functional-components/get-graphic-contexts.ts b/web-common/src/components/data-graphic/functional-components/get-graphic-contexts.ts deleted file mode 100644 index 6d91f92a58c..00000000000 --- a/web-common/src/components/data-graphic/functional-components/get-graphic-contexts.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { contexts } from "@rilldata/web-common/components/data-graphic/constants"; -import type { - ScaleStore, - SimpleConfigurationStore, -} from "@rilldata/web-common/components/data-graphic/state/types"; -import { getContext } from "svelte"; - -export function getGraphicContexts() { - return { - xScale: getContext(contexts.scale("x")) as ScaleStore, - yScale: getContext(contexts.scale("y")) as ScaleStore, - config: getContext(contexts.config) as SimpleConfigurationStore, - }; -} diff --git a/web-common/src/components/data-graphic/functional-components/index.ts b/web-common/src/components/data-graphic/functional-components/index.ts index 7d1a09d0d80..2fe863efacf 100644 --- a/web-common/src/components/data-graphic/functional-components/index.ts +++ b/web-common/src/components/data-graphic/functional-components/index.ts @@ -1,5 +1 @@ -export { default as WithBisector } from "./WithBisector.svelte"; -export { default as WithDelayedValue } from "./WithDelayedValue.svelte"; -export { default as WithParentClientRect } from "./WithParentClientRect.svelte"; -export { default as WithSimpleLinearScale } from "./WithSimpleLinearScale.svelte"; export { default as WithTween } from "./WithTween.svelte"; diff --git a/web-common/src/components/data-graphic/guides/DynamicallyPlacedLabel.svelte b/web-common/src/components/data-graphic/guides/DynamicallyPlacedLabel.svelte deleted file mode 100644 index 7a24e811a6d..00000000000 --- a/web-common/src/components/data-graphic/guides/DynamicallyPlacedLabel.svelte +++ /dev/null @@ -1,84 +0,0 @@ - - - - - - - diff --git a/web-common/src/components/data-graphic/guides/index.ts b/web-common/src/components/data-graphic/guides/index.ts deleted file mode 100644 index 0f918effb95..00000000000 --- a/web-common/src/components/data-graphic/guides/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default as DynamicallyPlacedLabel } from "./DynamicallyPlacedLabel.svelte"; diff --git a/web-common/src/components/data-graphic/guides/types.d.ts b/web-common/src/components/data-graphic/guides/types.d.ts deleted file mode 100644 index d4fd7fb6b37..00000000000 --- a/web-common/src/components/data-graphic/guides/types.d.ts +++ /dev/null @@ -1,2 +0,0 @@ -export type PointLabelVariant = "moving" | "fixed"; -export type AxisSide = "left" | "right" | "top" | "bottom"; diff --git a/web-common/src/components/data-graphic/marks/Area.svelte b/web-common/src/components/data-graphic/marks/Area.svelte deleted file mode 100644 index 57851dae0ed..00000000000 --- a/web-common/src/components/data-graphic/marks/Area.svelte +++ /dev/null @@ -1,78 +0,0 @@ - - - -{#if areaFcn} - - - - - - - -{/if} diff --git a/web-common/src/components/data-graphic/marks/ChunkedLine.svelte b/web-common/src/components/data-graphic/marks/ChunkedLine.svelte deleted file mode 100644 index 1579408d8cd..00000000000 --- a/web-common/src/components/data-graphic/marks/ChunkedLine.svelte +++ /dev/null @@ -1,186 +0,0 @@ - - - - - {@const delayedFilteredData = delayedValues[0]} - {@const delayedSegments = delayedValues[1]} - {@const delayedSingletons = delayedValues[2]} - {#each delayedSingletons as [singleton]} - - - {/each} - - - - - - {#if areaGradientColors !== null} - - - - - - - - - - {/if} - - - - {#each delayedSegments as segment (segment[0][xAccessor])} - {@const x = $xScale(segment[0][xAccessor])} - {@const width = - $xScale(segment.at(-1)[xAccessor]) - $xScale(segment[0][xAccessor])} - - {/each} - - - - diff --git a/web-common/src/components/data-graphic/marks/HistogramPrimitive.svelte b/web-common/src/components/data-graphic/marks/HistogramPrimitive.svelte deleted file mode 100644 index 4c3ff1ca814..00000000000 --- a/web-common/src/components/data-graphic/marks/HistogramPrimitive.svelte +++ /dev/null @@ -1,89 +0,0 @@ - - - -{#if d?.length} - - - - - - - - -{/if} diff --git a/web-common/src/components/data-graphic/marks/Line.svelte b/web-common/src/components/data-graphic/marks/Line.svelte deleted file mode 100644 index 78752e88afe..00000000000 --- a/web-common/src/components/data-graphic/marks/Line.svelte +++ /dev/null @@ -1,124 +0,0 @@ - - - -{#if lineFcn} - -{/if} diff --git a/web-common/src/components/data-graphic/marks/Rug.svelte b/web-common/src/components/data-graphic/marks/Rug.svelte deleted file mode 100644 index 6d797cc5586..00000000000 --- a/web-common/src/components/data-graphic/marks/Rug.svelte +++ /dev/null @@ -1,67 +0,0 @@ - - -{#if xScale && data} - - - {#each counts as countSet, i} - - - - {/each} - - -{/if} diff --git a/web-common/src/components/data-graphic/marks/index.ts b/web-common/src/components/data-graphic/marks/index.ts deleted file mode 100644 index 15abe6b5b4c..00000000000 --- a/web-common/src/components/data-graphic/marks/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export { default as Area } from "./Area.svelte"; -export { default as ChunkedLine } from "./ChunkedLine.svelte"; -export { default as HistogramPrimitive } from "./HistogramPrimitive.svelte"; -export { default as Line } from "./Line.svelte"; -export { default as Rug } from "./Rug.svelte"; diff --git a/web-common/src/components/data-graphic/state/cascading-context-store.ts b/web-common/src/components/data-graphic/state/cascading-context-store.ts deleted file mode 100644 index daafb4d9e9a..00000000000 --- a/web-common/src/components/data-graphic/state/cascading-context-store.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { getContext, hasContext, setContext } from "svelte"; -import { get, writable } from "svelte/store"; - -export function pruneProps(props: T): T { - return Object.keys(props).reduce((next, prop) => { - if (props[prop] !== undefined) next[prop] = props[prop]; - return next; - }, {}) as T; -} - -function addDerivations(store, derivations) { - store.update((state) => { - Object.keys(derivations).forEach((key) => { - state[key] = derivations[key](state); - }); - return state; - }); -} - -/** Creates a store that passes itself down as a context. - * If any children of the parent that created the store create a cascadingContextStore, - * the store value will look like {...parentProps, ...childProps}. - * In this case, the child component calling the new cascadingContextStore will pass the - * new store down to its children, reconciling any differences downstream. - * - * this may seem complicated, but it does enable a lot of important - * reactive data viz component compositions. - * Most consumers of the data graphic components won't need to worry about this store. - */ -export function cascadingContextStore( - namespace: string, - props: Props, - derivations = {}, -) { - // check to see if namespace exists. - const hasParentCascade = hasContext(namespace); - - const prunedProps = pruneProps(props); - - let lastProps = props; - let lastParentState = {}; - - const store = writable(prunedProps); - let parentStore; - - if (hasParentCascade) { - parentStore = getContext(namespace); - store.set({ - ...get(parentStore), - ...prunedProps, - }); - - /** When the parent updates, we need to take care - * to reconcile parent and child + any changed props. - */ - parentStore.subscribe((parentState) => { - lastParentState = { ...parentState }; - store.set({ - ...parentState, // the parent state - ...pruneProps(lastProps), // last props to be reconciled overrides clashing keys with current state - }); - // add the derived values into the final store. - addDerivations(store, derivations); - }); - } - addDerivations(store, derivations); - // always reset the context for all children. - setContext(namespace, store); - return { - hasParentCascade, - subscribe: store.subscribe, - reconcileProps(props: Props) { - lastProps = { ...props }; - - /** let's update the store with the latest props. */ - store.set({ ...lastParentState, ...pruneProps(lastProps) }); - addDerivations(store, derivations); - }, - }; -} diff --git a/web-common/src/components/data-graphic/state/extremum-resolution-store.spec.ts b/web-common/src/components/data-graphic/state/extremum-resolution-store.spec.ts deleted file mode 100644 index 524531adb22..00000000000 --- a/web-common/src/components/data-graphic/state/extremum-resolution-store.spec.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { createExtremumResolutionStore } from "./extremum-resolution-store"; -import { get } from "svelte/store"; - -describe("createExtremumResolutionStore", () => { - it("instantiates either with undefined or with a concrete value", () => { - const undefinedStore = createExtremumResolutionStore(); - expect(get(undefinedStore)).toBe(undefined); - const storeWithPassedValue = createExtremumResolutionStore(10); - expect(get(storeWithPassedValue)).toBe(10); - }); - it("instantiates with an empty value if you pass in a single instance value", () => { - const store = createExtremumResolutionStore(10); - expect(get(store)).toBe(10); - }); - it("picks the min value by default if direction not specified. Order should not matter", () => { - const store1 = createExtremumResolutionStore(10); - store1.setWithKey("first", 10); - expect(get(store1)).toBe(10); - store1.setWithKey("second", 5); - expect(get(store1)).toBe(5); - - // order should not matter - const store2 = createExtremumResolutionStore(10); - store2.setWithKey("second", 5); - expect(get(store2)).toBe(5); - store2.setWithKey("first", 10); - expect(get(store2)).toBe(5); - }); - it("picks the max value by default if not specified. Order should not matter.", () => { - const store = createExtremumResolutionStore(10, { direction: "max" }); - store.setWithKey("first", 10); - store.setWithKey("second", 5); - expect(get(store)).toBe(10); - - // order should not matter - store.setWithKey("first", 10); - store.setWithKey("second", 5); - expect(get(store)).toBe(10); - }); - - it("respects an override no matter the extremum values passed in", () => { - const minStore = createExtremumResolutionStore(10, { direction: "min" }); - minStore.setWithKey("overriding", 10, true); - expect(get(minStore)).toBe(10); - minStore.setWithKey("will not work", 5); - expect(get(minStore)).toBe(10); - }); - - it("defaults to the next most extreme value when a key is removed", () => { - const minStore = createExtremumResolutionStore(10, { direction: "min" }); - minStore.setWithKey("first", 3); - expect(get(minStore)).toBe(3); - minStore.setWithKey("second", 2); - expect(get(minStore)).toBe(2); - minStore.setWithKey("third", 1); - expect(get(minStore)).toBe(1); - minStore.removeKey("third"); - expect(get(minStore)).toBe(2); - minStore.removeKey("second"); - expect(get(minStore)).toBe(3); - minStore.removeKey("first"); - expect(get(minStore)).toBe(10); - }); -}); diff --git a/web-common/src/components/data-graphic/state/extremum-resolution-store.ts b/web-common/src/components/data-graphic/state/extremum-resolution-store.ts deleted file mode 100644 index 6666fa7eb20..00000000000 --- a/web-common/src/components/data-graphic/state/extremum-resolution-store.ts +++ /dev/null @@ -1,124 +0,0 @@ -/** - * @module extremum-resolution-store - * This specialized store handles the resolution of plot bounds based - * on multiple extrema. If multiple components set a maximum value within - * the same namespace, the store will automatically pick the largest - * for the store value. This enables us to determine, for instance, if - * multiple lines are on the same chart, which ones determine the bounds. - */ -import { max, min } from "d3-array"; -import { cubicOut } from "svelte/easing"; -import { tweened } from "svelte/motion"; -import { derived, get, writable, type Writable } from "svelte/store"; -import type { EasingFunction } from "svelte/transition"; - -const LINEAR_SCALE_STORE_DEFAULTS = { - duration: 0, - easing: cubicOut, - direction: "min", - namespace: undefined, - alwaysOverrideInitialValue: false, -}; - -interface extremumArgs { - duration?: number; - easing?: EasingFunction; - direction?: string; - alwaysOverrideInitialValue?: boolean; -} - -interface Extremum { - value: number | Date | undefined; - override?: boolean; -} - -interface ExtremaStoreValue { - [key: string]: Extremum; -} - -const extremaFunctions = { min, max }; - -export function createExtremumResolutionStore( - initialValue: number | Date | undefined = undefined, - passedArgs: extremumArgs = {}, -) { - const args = { ...LINEAR_SCALE_STORE_DEFAULTS, ...passedArgs }; - const storedValues: Writable = writable({}); - let tweenProps = { - duration: args.duration, - easing: args.easing, - }; - const valueTween = tweened(initialValue, tweenProps); - function _update( - key: string, - value: number | Date | undefined, - override = false, - ) { - // FIXME: there's an odd bug where if I don't check for equality first, I tend - // to get an infinite loop with dates and the downstream scale. - // This is easily fixed by only updating if the value has in fact changed. - const extremum = get(storedValues)[key]; - if (extremum?.value === value && extremum?.override === override) return; - storedValues.update((storeValue) => { - if (!(key in storeValue)) - storeValue[key] = { value: undefined, override: false }; - storeValue[key].value = value; - storeValue[key].override = override; - return storeValue; - }); - } - /** add the initial value as its own key, if set by user. */ - if (initialValue && args.alwaysOverrideInitialValue === false) { - _update("__initial_value__", initialValue); - } - - function _remove(key: string) { - storedValues.update((storeValue) => { - delete storeValue[key]; - return storeValue; - }); - } - - const domainExtremum = derived( - storedValues, - ($storedValues) => { - let extremum; - const extrema: Extremum[] = [...Object.values($storedValues)]; - for (const entry of extrema) { - if (entry.override) { - extremum = entry.value; - break; - } else { - extremum = extremaFunctions[args.direction]([entry.value, extremum]); - } - } - return extremum; - }, - initialValue, - ); - - // set the final tween with the value. - domainExtremum.subscribe((value) => { - if (value !== undefined) { - valueTween.set(value, tweenProps); - } - }); - - const returnedStore = { - subscribe: valueTween.subscribe, - setWithKey( - key, - value: number | Date | undefined = undefined, - override: boolean | undefined = undefined, - ) { - _update(key, value, override); - }, - removeKey(key: string) { - _remove(key); - }, - setTweenProps(tweenPropsArgs) { - tweenProps = tweenPropsArgs; - }, - }; - return returnedStore; -} diff --git a/web-common/src/components/data-graphic/state/index.ts b/web-common/src/components/data-graphic/state/index.ts deleted file mode 100644 index 2fae0151c89..00000000000 --- a/web-common/src/components/data-graphic/state/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -export { initializeMaxMinStores, initializeScale } from "./scale-stores"; -export { cascadingContextStore, pruneProps } from "./cascading-context-store"; - -export enum ScaleType { - NUMBER = "number", - DATE = "date", -} diff --git a/web-common/src/components/data-graphic/state/scale-stores.ts b/web-common/src/components/data-graphic/state/scale-stores.ts deleted file mode 100644 index 60491973ae7..00000000000 --- a/web-common/src/components/data-graphic/state/scale-stores.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { scaleLinear, scaleTime } from "d3-scale"; -import { getContext, setContext } from "svelte"; -import { derived, type Writable } from "svelte/store"; -import { contexts } from "../constants"; -import { createExtremumResolutionStore } from "./extremum-resolution-store"; -import type { ScaleStore, SimpleConfigurationStore } from "./types"; - -const SCALES = { - number: scaleLinear, - date: scaleTime, -}; - -/** We operate on the domain through these stores. */ -export function initializeMaxMinStores({ - namespace, - domainMin = undefined, - domainMax = undefined, - domainMinTweenProps = { duration: 0 }, - domainMaxTweenProps = { duration: 0 }, -}: { - namespace: string; - domainMin?: number | Date; - domainMax?: number | Date; - domainMinTweenProps?: { duration: number }; - domainMaxTweenProps?: { duration: number }; -}) { - // initialize - const minStore = createExtremumResolutionStore(domainMin, { - direction: "min", - ...domainMinTweenProps, - }); - const maxStore = createExtremumResolutionStore(domainMax, { - direction: "max", - ...domainMaxTweenProps, - }); - if (domainMin !== undefined) minStore.setWithKey("global", domainMin, true); - if (domainMax !== undefined) maxStore.setWithKey("global", domainMax, true); - // set the contexts. - setContext(contexts.min(namespace), minStore); - setContext(contexts.max(namespace), maxStore); - return { minStore, maxStore }; -} - -export function initializeScale(args): ScaleStore { - const minStore = getContext(contexts.min(args.namespace)) as Writable< - number | Date - >; - const maxStore = getContext(contexts.max(args.namespace)) as Writable< - number | Date - >; - const config = getContext(contexts.config) as SimpleConfigurationStore; - const scaleStore = derived( - [minStore, maxStore, config], - ([$min, $max, $config]) => { - const scale = SCALES[args.scaleType]; - const minRangeValue: number | Date = - typeof args.rangeMin === "function" - ? args.rangeMin($config) - : args.rangeMin; - const maxRangeValue: number | Date = - typeof args.rangeMax === "function" - ? args.rangeMax($config) - : args.rangeMax; - return scale().domain([$min, $max]).range([minRangeValue, maxRangeValue]); - }, - ) as ScaleStore; - scaleStore.type = args.scaleType; - setContext(contexts.scale(args.namespace), scaleStore); - return scaleStore; -} diff --git a/web-common/src/components/data-graphic/state/types.d.ts b/web-common/src/components/data-graphic/state/types.d.ts index 0165ae79f61..ca070a34c21 100644 --- a/web-common/src/components/data-graphic/state/types.d.ts +++ b/web-common/src/components/data-graphic/state/types.d.ts @@ -1,22 +1,10 @@ import type { ScaleLinear, ScaleTime } from "d3-scale"; import type { Readable } from "svelte/store"; -export interface ExtremumResolutionTweenProps { - delay?: number; - duration?: number | ((from: T, to: T) => number); - easing?: (t: number) => number; - interpolate?: (a: T, b: T) => (t: number) => T; -} - -export interface ExtremumResolutionStore extends Readable { - setWithKey: (arg0: string, arg1: number | Date, arg2?: boolean) => void; - removeKey: (arg0: string) => void; - setTweenProps: (arg0: ExtremumResolutionTweenProps) => void; -} - export type GraphicScale = | ScaleLinear | ScaleTime; + export interface ScaleStore extends Readable { type: string; } diff --git a/web-common/src/components/data-graphic/utils.ts b/web-common/src/components/data-graphic/utils.ts index eaf6a81c4e4..5f0a2874a24 100644 --- a/web-common/src/components/data-graphic/utils.ts +++ b/web-common/src/components/data-graphic/utils.ts @@ -2,16 +2,7 @@ import { bisector } from "d3-array"; import type { ScaleLinear, ScaleTime } from "d3-scale"; import { area, curveLinear, curveStep, line } from "d3-shape"; import { timeFormat } from "d3-time-format"; -import { getContext } from "svelte"; -import { derived, writable } from "svelte/store"; -import { contexts } from "./constants"; import { curveStepExtended } from "./marks/curveStepExtended"; -import { ScaleType } from "./state"; -import type { - GraphicScale, - ScaleStore, - SimpleConfigurationStore, -} from "./state/types"; /** * Creates a string to be fed into the d attribute of a path, @@ -116,188 +107,72 @@ export function areaFactory(args: LineGeneratorArguments) { } /** - * Return a list of ticks to be represented on the - * axis or grid depending on axis-side, it's length and - * the data type of field + * Generates an SVG path string for a histogram / bar plot. + * Each bin is defined by a low/high x range and a y count value. + * The path traces the outline of all non-zero bins, suitable for + * both fill and stroke rendering. */ -export function getTicks( - xOrY: string, - scale: GraphicScale, - axisLength: number, - scaleType: ScaleType, -) { - const isDate = scaleType === ScaleType.DATE; - const tickCount = Math.trunc(axisLength / (xOrY === "x" ? 100 : 50)); - - let ticks = scale.ticks(tickCount); - - // Prevent overlapping ticks on X axis - if (xOrY === "x" && axisLength / ticks.length < 60) { - ticks = scale.ticks(tickCount - 1); - } - - if (ticks.length <= 1) { - if (isDate) ticks = scale.domain(); - else ticks = scale.nice().domain(); - } - - return ticks; -} - export function barplotPolyline( - data, - xLow, - xHigh, - yAccessor, - X, - Y, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + data: Record[], + xLow: string, + xHigh: string, + yAccessor: string, + xScale: (v: number) => number, + yScale: (v: number) => number, separator = 1, closeBottom = false, inflator = 1, ): string { if (!data?.length) return ""; - const path = data.reduce((pointsPathString, datum, i) => { - const low = datum[xLow]; - const high = datum[xHigh]; - const count = datum[yAccessor]; - const x = X(low) + separator; + const baseline = yScale(0); - const width = Math.max(0.5, X(high) - X(low) - separator * 2); - const y = Y(0) * (1 - inflator) + Y(count) * inflator; + const path = data.reduce((acc: string, datum, i) => { + const count = datum[yAccessor] ?? 0; + if (count === 0) return acc; - const computedHeight = Math.min( - Y(0), - Y(0) * inflator - Y(count) * inflator, + const low = datum[xLow] ?? 0; + const high = datum[xHigh] ?? 0; + const x = xScale(low) + separator; + const width = Math.max(0.5, xScale(high) - xScale(low) - separator * 2); + const y = baseline * (1 - inflator) + yScale(count) * inflator; + const barHeight = Math.min( + baseline, + baseline * inflator - yScale(count) * inflator, ); - const height = separator > 0 ? computedHeight : 0; - - // do not add zero values here - if (count === 0) { - return pointsPathString; - } + const dropHeight = separator > 0 ? barHeight : 0; - let p1 = ""; + const prevIsZero = i > 0 && !data[i - 1][yAccessor]; + const nextIsZero = i < data.length - 1 && !data[i + 1][yAccessor]; - const nextPointIsZero = i < data.length - 1 && data[i + 1][yAccessor] === 0; - - const lastPointWasZero = i > 0 && data[i - 1][yAccessor] === 0; - - if (separator === 0 && lastPointWasZero) { - // we will need to start this thing at 0? - p1 = `M${x},${y + computedHeight}`; + // Move to the bottom-left of this bar + let move: string; + if (separator === 0 && prevIsZero) { + move = `M${x},${y + barHeight}`; } else if (separator > 0 || i === 0) { - // standard case. - p1 = `${i !== 0 ? "M" : ""}${x},${y + height}`; + move = `${i !== 0 ? "M" : ""}${x},${y + dropHeight}`; + } else { + move = ""; } - const p2 = `${x},${y}`; - const p3 = `${x + width},${y}`; - - const p4 = - separator > 0 || nextPointIsZero - ? `${x + width},${y + (separator > 0 ? height : computedHeight)}` + const topLeft = `${x},${y}`; + const topRight = `${x + width},${y}`; + const bottomRight = + separator > 0 || nextIsZero + ? `${x + width},${y + (separator > 0 ? dropHeight : barHeight)}` : ""; - const closedBottom = closeBottom ? `${x},${y + height}` : ""; + const close = closeBottom ? `${x},${y + dropHeight}` : ""; - return pointsPathString + `${p1} ${p2} ${p3} ${p4} ${closedBottom} `; + return acc + `${move} ${topLeft} ${topRight} ${bottomRight} ${close} `; }, " "); - const lastElement = data.findLast((d) => d[yAccessor]); - if (!lastElement) return ""; - return ( - `M${X(data[0][xLow]) + separator},${Y(0)} ` + - path + - ` ${X(lastElement[xHigh]) - separator},${Y(0)} ` - ); -} - -/** utilizes the provided scales to calculate the line thinness in a way - * that enables higher-density "overplotted lines". - */ - -export function createAdaptiveLineThicknessStore(yAccessor) { - let data; - - // get xScale, yScale, and config from contexts - const xScale = getContext(contexts.scale("x")); - const yScale = getContext(contexts.scale("y")); - const config = getContext(contexts.config); - - // capture data state. - const dataStore = writable(data); - const store = derived( - [xScale, yScale, config, dataStore], - ([$xScale, $yScale, $config, $data]) => { - if (!$data) { - return 1; - } - const totalTravelDistance = $data - .filter((di) => di[yAccessor] !== null) - .map((di, i) => { - if (i === $data.length - 1) { - return 0; - } - const max = Math.max( - $yScale($data[i + 1][yAccessor]), - $yScale($data[i][yAccessor]), - ); - const min = Math.min( - $yScale($data[i + 1][yAccessor]), - $yScale($data[i][yAccessor]), - ); - if (isNaN(min) || isNaN(max)) return 1 / $data.length; - return Math.abs(max - min); - }) - .reduce((acc, v) => acc + v, 0); + const lastNonZero = data.findLast((d) => d[yAccessor]); + if (!lastNonZero) return ""; - const yIshDistanceTravelled = - 2 / - (totalTravelDistance / - (($xScale.range()[1] - $xScale.range()[0]) * - ($config.devicePixelRatio || 3))); - - const xIshDistanceTravellled = - (($xScale.range()[1] - $xScale.range()[0]) * - ($config.devicePixelRatio || 3) * - 0.7) / - $data.length / - 1.5; - - const value = Math.min( - 1, - /** to determine the stroke width of the path, let's look at - * the bigger of two values: - * 1. the "y-ish" distance travelled - * the inverse of "total travel distance", which is the Y - * gap size b/t successive points divided by the zoom window size; - * 2. time series length / available X pixels - * the time series divided by the total number of pixels in the existing - * zoom window. - * - * These heuristics could be refined, but this seems to provide a reasonable approximation for - * the stroke width. (1) excels when lots of successive points are close together in the Y direction, - * whereas (2) excels` when a line is very, very noisy (and thus the X direction is the main constraint). - */ - Math.max( - // the y-ish distance travelled - yIshDistanceTravelled, - // the time series length / available X pixels - xIshDistanceTravellled, - ), - ); - - return value; - }, - ); - - return { - subscribe: store.subscribe, - /** trigger an update when the data changes */ - setData(d) { - dataStore.set(d); - }, - }; + const startX = xScale(data[0][xLow] ?? 0) + separator; + const endX = xScale(lastNonZero[xHigh] ?? 0) - separator; + return `M${startX},${baseline} ${path} ${endX},${baseline} `; } // This is function equivalent of WithBisector diff --git a/web-common/src/components/time-series-chart/BarChart.svelte b/web-common/src/components/time-series-chart/BarChart.svelte index bd2b6623682..a3f3013f20d 100644 --- a/web-common/src/components/time-series-chart/BarChart.svelte +++ b/web-common/src/components/time-series-chart/BarChart.svelte @@ -1,5 +1,6 @@ {#if stacked} {#each { length: visibleCount } as _, slot (slot)} {@const ptIdx = visibleStart + slot} - {@const cx = plotLeft + (slot + 0.5) * slotWidth} - {@const bx = cx - bandWidth / 2} + {@const cx = plotLeft + (slot + 0.5) * geo.slotWidth} + {@const bx = cx - geo.bandWidth / 2} {@const stackValues = series.map((s) => ({ value: s.values[ptIdx] ?? 0, color: s.color, @@ -59,7 +58,7 @@ 1 ? 2 : 0} - {@const totalGaps = barGap * (barCount - 1)} - {@const singleBarWidth = (bandWidth - totalGaps) / barCount} {@const radius = 4} {#each { length: visibleCount } as _, slot (slot)} {@const ptIdx = visibleStart + slot} - {@const cx = plotLeft + (slot + 0.5) * slotWidth} + {@const cx = plotLeft + (slot + 0.5) * geo.slotWidth} {#each series as s, sIdx (s.id)} {@const v = s.values[ptIdx] ?? null} {#if v !== null} - {@const bx = cx - bandWidth / 2 + sIdx * (singleBarWidth + barGap)} + {@const bx = + cx - geo.bandWidth / 2 + sIdx * (geo.singleBarWidth + geo.barGap)} {@const by = Math.min(zeroY, yScale(v))} {@const bh = Math.abs(zeroY - yScale(v))} - {@const r = Math.min(radius, singleBarWidth / 2, bh / 2)} + {@const r = Math.min(radius, geo.singleBarWidth / 2, bh / 2)} {@const isPositive = v >= 0} import TimestampDetail from "@rilldata/web-common/components/data-graphic/compositions/timestamp-profile/TimestampDetail.svelte"; import TimestampSpark from "@rilldata/web-common/components/data-graphic/compositions/timestamp-profile/TimestampSpark.svelte"; - import WithParentClientRect from "@rilldata/web-common/components/data-graphic/functional-components/WithParentClientRect.svelte"; import { copyToClipboard } from "@rilldata/web-common/lib/actions/copy-to-clipboard"; import { TIMESTAMP_TOKENS } from "@rilldata/web-common/lib/duckdb-data-types"; import { httpRequestQueue } from "../../../runtime-client/http-client"; @@ -32,6 +31,8 @@ let timestampDetailHeight = 160; let active = false; + let clientWidth: number; + let secondWidth: number; /** queries used to power the different plots */ $: nullPercentage = getNullPercentage( @@ -55,12 +56,12 @@ active, ); + $: fetchingSummaries = isFetching($timeSeries, $nullPercentage); + function toggleColumnProfile() { active = !active; httpRequestQueue.prioritiseColumn(objectName, columnName, active); } - - $: fetchingSummaries = isFetching($timeSeries, $nullPercentage); {columnName}
-
- - - +
+
-
- - {#if $timeSeries?.data?.length && $timeSeries?.estimatedRollupInterval?.interval && $timeSeries?.smallestTimegrain} - - {/if} - +
+ {#if $timeSeries?.data?.length && $timeSeries?.estimatedRollupInterval?.interval && $timeSeries?.smallestTimegrain} + + {/if}
diff --git a/web-common/src/features/column-profile/column-types/details/NumericPlot.svelte b/web-common/src/features/column-profile/column-types/details/NumericPlot.svelte index 6769be5ee48..39a0fb9aa92 100644 --- a/web-common/src/features/column-profile/column-types/details/NumericPlot.svelte +++ b/web-common/src/features/column-profile/column-types/details/NumericPlot.svelte @@ -1,4 +1,4 @@ -
- - + + + { + mouseX = e.offsetX; + }} + on:mouseleave={() => { + mouseX = undefined; + }} > - - (dt.high + dt.low) / 2} - value={mouseoverValue?.x} - let:point - > - {#if data} - - - - + + + + + + + {#if data} + + + + + {#if histPath?.length} + + + {/if} + + + + + + {#if hoveredBin} + + + - - - {#if point} - - - - - - - - - {/if} - - - {#if point?.low !== undefined} - - ({justEnoughPrecision(point?.low)}, {justEnoughPrecision( - point?.high, - )}{point?.high === data.at(-1)?.high ? ")" : "]"} - - {formatInteger(Math.trunc(point.count))} row{#if point.count !== 1}s{/if} - ({((point.count / totalRows) * 100).toFixed(2)}%) - - - {/if} + + + + + {/if} - - {#if focusPoint?.count !== undefined && focusPoint?.value && topK && summaryMode === "topk"} - - - - - - - {/if} - {/if} - - - - {#if rug} - + + {#if hoveredBin?.low !== undefined} + + ({justEnoughPrecision(hoveredBin?.low ?? 0)}, {justEnoughPrecision( + hoveredBin?.high ?? 0, + )}{hoveredBin?.high === data.at(-1)?.high ? ")" : "]"} + + {formatInteger(Math.trunc(hoveredBin.count ?? 0))} row{#if (hoveredBin.count ?? 0) !== 1}s{/if} + ({(((hoveredBin.count ?? 0) / totalRows) * 100).toFixed(2)}%) + + {/if} - - -
- {#if summaryMode === "summary" && summary} - {@const rowHeight = 24} -
- -
- {:else if topK && summaryMode === "topk"} -
- { - focusPoint = value; - }} - k={topKLimit} - {topK} - {totalRows} - colorClass="bg-red-200" - {type} + + + {#if focusPoint?.count !== undefined && focusPoint?.value && topK && summaryMode === "topk"} + + -
+ + {/if} -
-
-
+ {/if} + + + + {#if rug} + + + {#each rugBuckets as bucket, i (i)} + {#if bucket.length > 0} + + {/if} + {/each} + + + {/if} + + +
+ {#if summaryMode === "summary" && summary} + {@const rowHeight = 24} +
+ +
+ {:else if topK && summaryMode === "topk"} +
+ { + focusPoint = value; + }} + k={topKLimit} + {topK} + {totalRows} + colorClass="bg-red-200" + {type} + /> +
+ {/if} +
+
diff --git a/web-common/src/features/column-profile/column-types/details/SummaryNumberPlot.svelte b/web-common/src/features/column-profile/column-types/details/SummaryNumberPlot.svelte index 04ef60d3707..1a8758de814 100644 --- a/web-common/src/features/column-profile/column-types/details/SummaryNumberPlot.svelte +++ b/web-common/src/features/column-profile/column-types/details/SummaryNumberPlot.svelte @@ -1,18 +1,16 @@ {#if values} - - - {#each values as { label, value, format = undefined }, i} - - - - - + {#each values as { label, value, format = undefined }, i (i)} + {@const px = xScale(value ?? 0)} + {@const rowY = (values.length - i - 1) * rowHeight} + + + + + - - {label} - {format ? format(value) : value} - - - + {label} + {format && value !== undefined + ? format(value) + : (value ?? "—")} + + {/each} - + {/if} diff --git a/web-common/src/features/column-profile/column-types/sparks/NumericSpark.svelte b/web-common/src/features/column-profile/column-types/sparks/NumericSpark.svelte index 23a9d47e33f..374364aa65f 100644 --- a/web-common/src/features/column-profile/column-types/sparks/NumericSpark.svelte +++ b/web-common/src/features/column-profile/column-types/sparks/NumericSpark.svelte @@ -1,60 +1,80 @@ {#if data} - - + + + + + + + + + {#if d?.length} + + + {/if} - - - - + the distribution of the values of this column diff --git a/web-common/src/features/column-profile/column-types/sparks/TimestampSpark.svelte b/web-common/src/features/column-profile/column-types/sparks/TimestampSpark.svelte deleted file mode 100644 index b6fafac16ee..00000000000 --- a/web-common/src/features/column-profile/column-types/sparks/TimestampSpark.svelte +++ /dev/null @@ -1,57 +0,0 @@ - - -{#if data?.length} - - - - - - - -{/if} diff --git a/web-common/src/features/dashboards/time-series/MetricsTimeSeriesCharts.svelte b/web-common/src/features/dashboards/time-series/MetricsTimeSeriesCharts.svelte index 1988d597a2c..8ab1a3ecb45 100644 --- a/web-common/src/features/dashboards/time-series/MetricsTimeSeriesCharts.svelte +++ b/web-common/src/features/dashboards/time-series/MetricsTimeSeriesCharts.svelte @@ -340,7 +340,7 @@ class:pb-4={!showTimeDimensionDetail} class="flex flex-col gap-y-2 overflow-y-scroll h-full max-h-fit" > - {#each renderedMeasures as measure (measure.name)} + {#each renderedMeasures as measure, i (measure.name)}
- import { scaleTime } from "d3-scale"; - - export let start: Date; - export let end: Date; - export let right: number = 25; - export let height: number = 26; - - // Compute width from parent (will be set via bind:clientWidth) - let containerWidth = 300; - - $: plotRight = containerWidth - right; - $: xScale = scaleTime().domain([start, end]).range([0, plotRight]); - $: ticks = xScale.ticks(Math.max(2, Math.floor(containerWidth / 100))); - - function formatTick(date: Date): string { - const month = date.toLocaleString(undefined, { month: "short" }); - const day = date.getDate(); - const year = date.getFullYear(); - // Show year if ticks span multiple years - const startYear = start.getFullYear(); - const endYear = end.getFullYear(); - if (startYear !== endYear) { - return `${month} ${day}, ${year}`; - } - return `${month} ${day}`; - } - - -
- - {#each ticks as tick} - - {formatTick(tick)} - - {/each} - -
diff --git a/web-common/src/features/dashboards/time-series/measure-chart/MeasureChart.svelte b/web-common/src/features/dashboards/time-series/measure-chart/MeasureChart.svelte index 9194f0de3e3..6e218aba32f 100644 --- a/web-common/src/features/dashboards/time-series/measure-chart/MeasureChart.svelte +++ b/web-common/src/features/dashboards/time-series/measure-chart/MeasureChart.svelte @@ -27,7 +27,6 @@ import { TDDChart } from "@rilldata/web-common/features/dashboards/time-dimension-details/types"; import { adjustTimeInterval } from "../utils"; import { createMeasureValueFormatter } from "@rilldata/web-common/lib/number-formatting/format-measure-value"; - import { TIME_GRAIN } from "@rilldata/web-common/lib/time/config"; import type { MetricsViewSpecMeasure } from "@rilldata/web-common/runtime-client"; import { createQueryServiceMetricsViewTimeSeries, @@ -45,8 +44,7 @@ TimeComparisonLineColor, } from "../chart-colors"; import { COMPARISON_COLORS } from "@rilldata/web-common/features/dashboards/config"; - import type { V1TimeGrain } from "@rilldata/web-common/runtime-client"; - import type { AvailableTimeGrain } from "@rilldata/web-common/lib/time/types"; + import { V1TimeGrain } from "@rilldata/web-common/runtime-client"; import { DateTime } from "luxon"; import { transformTimeSeriesData } from "./use-measure-time-series"; import { @@ -63,10 +61,13 @@ import MeasureChartAnnotationPopover from "./MeasureChartAnnotationPopover.svelte"; import { measureSelection } from "../measure-selection/measure-selection"; import { formatDateTimeByGrain } from "@rilldata/web-common/lib/time/ranges/formatter"; + import { V1TimeGrainToOrder } from "@rilldata/web-common/lib/time/new-grains"; import { formatMeasurePercentageDifference } from "@rilldata/web-common/lib/number-formatting/percentage-formatter"; import { numberPartsToString } from "@rilldata/web-common/lib/number-formatting/utils/number-parts-utils"; + import { snapIndex, dateToIndex } from "./utils"; const Y_DASH_ARRAY = "1,1"; + const DAY_GRAIN_ORDER = V1TimeGrainToOrder[V1TimeGrain.TIME_GRAIN_DAY]; const X_PAD = 8; const CLICK_THRESHOLD_PX = 4; // Max mouse movement to still count as a click const LINE_MODE_MIN_POINTS = 6; // Minimum data points to show line instead of bar @@ -118,6 +119,7 @@ export let onPanLeft: (() => void) | undefined = undefined; export let onPanRight: (() => void) | undefined = undefined; export let scrubController: ScrubController; + export let showAxis: boolean = false; let container: HTMLDivElement; let clientWidth = 425; @@ -310,6 +312,72 @@ ]); $: scales = { x: xScale, y: yScale }; $: yTicks = yScale.ticks(3); + $: xTickIndices = + dataLastIndex >= 2 + ? [0, Math.floor(dataLastIndex / 2), dataLastIndex] + : dataLastIndex >= 1 + ? [0, dataLastIndex] + : [0]; + // Sub-day grains (hour, minute, etc.) show time + date on separate lines + $: isSubDayGrain = timeGranularity + ? V1TimeGrainToOrder[timeGranularity] < DAY_GRAIN_ORDER + : false; + $: axisHeight = isSubDayGrain ? 26 : 16; + $: axisTicks = buildAxisTicks(xTickIndices, data, mode, isSubDayGrain); + + function buildAxisTicks( + indices: number[], + d: TimeSeriesPoint[], + m: ChartMode, + subDay: boolean, + ) { + return indices.map((idx, i) => { + const dt = d[idx]?.ts; + const anchor = + m === "bar" + ? "middle" + : i === 0 + ? "start" + : i === indices.length - 1 + ? "end" + : "middle"; + + if (!dt) return { x: xScale(idx), anchor, timeLine: "", dateLine: "" }; + + if (subDay) { + const grainOrder = V1TimeGrainToOrder[timeGranularity!]; + const fmt: Intl.DateTimeFormatOptions = { + hour: "numeric", + hour12: true, + }; + if (grainOrder < 1) fmt.minute = "2-digit"; + const timeLine = dt.toLocaleString(fmt); + + const prevDt = i > 0 ? d[indices[i - 1]]?.ts : undefined; + const showDate = + !prevDt || + dt.day !== prevDt.day || + dt.month !== prevDt.month || + dt.year !== prevDt.year; + const dateFmt: Intl.DateTimeFormatOptions = { + month: "short", + day: "numeric", + }; + if (d[0]?.ts.year !== d[d.length - 1]?.ts.year) + dateFmt.year = "numeric"; + const dateLine = showDate ? dt.toLocaleString(dateFmt) : ""; + + return { x: xScale(idx), anchor, timeLine, dateLine }; + } + + return { + x: xScale(idx), + anchor, + timeLine: formatDateTimeByGrain(dt, timeGranularity), + dateLine: "", + }; + }); + } // Keep scrub controller in sync with data length $: scrubController.setDataLength(data.length); @@ -321,10 +389,10 @@ // Scrub indices: use local (active) state while scrubbing, external (URL) state otherwise $: externalScrubStartIndex = scrubRange - ? dateToIndex(data, scrubRange.start) + ? dateToIndex(data, scrubRange.start.toMillis()) : null; $: externalScrubEndIndex = scrubRange - ? dateToIndex(data, scrubRange.end) + ? dateToIndex(data, scrubRange.end.toMillis()) : null; $: scrubStartIndex = currentScrubState.startIndex ?? externalScrubStartIndex; $: scrubEndIndex = currentScrubState.endIndex ?? externalScrubEndIndex; @@ -332,9 +400,7 @@ // Hover state - snap to nearest data index $: localHoveredIndex = - $hoverState.index !== null - ? Math.max(0, Math.min(data.length - 1, Math.round($hoverState.index))) - : -1; + $hoverState.index !== null ? snapIndex($hoverState.index, data.length) : -1; $: isLocallyHovered = $hoverState.isHovered && localHoveredIndex >= 0; $: cursorStyle = scrubController.getCursorStyle($hoverState.screenX, xScale); $: sharedHoverIndex.set(isLocallyHovered ? localHoveredIndex : undefined); @@ -344,10 +410,7 @@ : undefined, ); $: tableHoverIndex = $tableHoverTime - ? dateToIndex( - data, - DateTime.fromJSDate($tableHoverTime, { zone: timeZone }), - ) + ? dateToIndex(data, $tableHoverTime.getTime()) : null; $: hoveredIndex = isLocallyHovered ? localHoveredIndex @@ -407,7 +470,7 @@ $: isThisMeasureSelected = $selMeasure === measureName; $: singleSelectIdx = isThisMeasureSelected && $selStart && !$selEnd - ? dateToIndex(data, DateTime.fromJSDate($selStart, { zone: timeZone })) + ? dateToIndex(data, $selStart.getTime()) : null; $: singleSelectX = singleSelectIdx !== null ? scales.x(singleSelectIdx) : null; @@ -472,46 +535,22 @@ function determineMode( d: TimeSeriesPoint[], - dimData: DimensionSeriesData[], + _dimData: DimensionSeriesData[], ): ChartMode { - // Use line chart when we have enough data points and no dimension comparison - if (d.length >= LINE_MODE_MIN_POINTS && dimData.length === 0) return "line"; + if (d.length >= LINE_MODE_MIN_POINTS) return "line"; return "bar"; } function indexToDateTime(idx: number | null): DateTime | null { if (idx === null || data.length === 0) return null; - const snapped = Math.max(0, Math.min(data.length - 1, Math.round(idx))); - const dt = data[snapped]?.ts; + const dt = data[snapIndex(idx, data.length)]?.ts; return dt?.isValid ? dt : null; } - function dateToIndex(d: TimeSeriesPoint[], dt: DateTime): number | null { - if (d.length === 0 || !dt.isValid) return null; - const ms = dt.toMillis(); - let best = 0; - let bestDist = Infinity; - for (let i = 0; i < d.length; i++) { - const dist = Math.abs(d[i].ts.toMillis() - ms); - if (dist < bestDist) { - bestDist = dist; - best = i; - } - } - return best; - } - - function formatTime(dt: DateTime): string { - if (!timeGranularity) return dt.toLocaleString(DateTime.DATE_SHORT); - const grainConfig = TIME_GRAIN[timeGranularity as AvailableTimeGrain]; - if (!grainConfig?.formatDate) return dt.toLocaleString(DateTime.DATE_SHORT); - return dt.toJSDate().toLocaleDateString(undefined, grainConfig.formatDate); - } - function formatScrubLabel(idx: number): string { const dt = indexToDateTime(idx); if (!dt) return ""; - return formatTime(dt); + return formatDateTimeByGrain(dt, timeGranularity); } function handleReset() { @@ -758,6 +797,30 @@ />
{:else if data.length > 0} + {#if showAxis} + + {#each axisTicks as tick (tick.x)} + + {tick.timeLine} + + {#if tick.dateLine} + + {tick.dateLine} + + {/if} + {/each} + + {/if} + + + {#each xTickIndices as idx (idx)} + + {/each} + + @@ -898,16 +974,13 @@ {#if showComparison} {valueFormatter(tooltipCurrentValue)} - vs {valueFormatter(tooltipComparisonValue)} diff --git a/web-common/src/features/dashboards/time-series/measure-chart/MeasureChartTooltip.svelte b/web-common/src/features/dashboards/time-series/measure-chart/MeasureChartTooltip.svelte index a8081051c63..8318f3a7b87 100644 --- a/web-common/src/features/dashboards/time-series/measure-chart/MeasureChartTooltip.svelte +++ b/web-common/src/features/dashboards/time-series/measure-chart/MeasureChartTooltip.svelte @@ -8,6 +8,7 @@ ChartConfig, } from "./types"; import MeasureChartPointIndicator from "./MeasureChartPointIndicator.svelte"; + import { computeBarSlotGeometry, barCenterX } from "./utils"; export let scales: ChartScales; export let config: ChartConfig; @@ -21,24 +22,22 @@ export let visibleEnd: number = 0; export let formatter: (value: number | null) => string; - $: visibleCount = Math.max(1, visibleEnd - visibleStart + 1); - $: slotWidth = config.plotBounds.width / visibleCount; - $: gap = slotWidth * 0.2; - $: bandWidth = slotWidth - gap; - // Bar count: dimension comparison uses dimensionData.length, time comparison uses 2 $: barCount = isComparingDimension ? dimensionData.length : showComparison ? 2 : 1; - $: barGap = barCount > 1 ? 2 : 0; - $: totalGaps = barGap * (barCount - 1); - $: singleBarWidth = (bandWidth - totalGaps) / barCount; + $: visibleCount = Math.max(1, visibleEnd - visibleStart + 1); + $: geo = computeBarSlotGeometry( + config.plotBounds.width, + visibleCount, + barCount, + ); // Slot center for bar positioning $: slot = hoveredIndex - visibleStart; - $: slotCenterX = config.plotBounds.left + (slot + 0.5) * slotWidth; + $: slotCenterX = config.plotBounds.left + (slot + 0.5) * geo.slotWidth; $: y = hoveredPoint?.value ?? null; $: comparisonY = hoveredPoint?.comparisonValue ?? null; @@ -60,22 +59,25 @@ {#if hoveredPoint} - {#if !currentPointIsNull && !isComparingDimension && !showComparison} + {#if !isComparingDimension && !showComparison} {/if} {#if !isComparingDimension && showComparison && !currentPointIsNull} {@const primaryBarX = isBarMode - ? slotCenterX - - bandWidth / 2 + - (singleBarWidth + barGap) + - singleBarWidth / 2 + ? barCenterX( + slotCenterX, + geo.bandWidth, + geo.singleBarWidth, + geo.barGap, + 1, + ) : $tweenedX} {#each dimensionData as dim, i (i)} {@const pt = dim.data[hoveredIndex]} - {@const barX = isBarMode - ? slotCenterX - - bandWidth / 2 + - i * (singleBarWidth + barGap) + - singleBarWidth / 2 + {@const bx = isBarMode + ? barCenterX( + slotCenterX, + geo.bandWidth, + geo.singleBarWidth, + geo.barGap, + i, + ) : $tweenedX} {#if pt?.value !== null && pt?.value !== undefined} {#if !isComparingDimension && showComparison && hasValidComparisonPoint} {@const compBarX = isBarMode - ? slotCenterX - bandWidth / 2 + singleBarWidth / 2 + ? barCenterX( + slotCenterX, + geo.bandWidth, + geo.singleBarWidth, + geo.barGap, + 0, + ) : $tweenedX} { - return writable(EMPTY_HOVER); -} - -export { EMPTY_HOVER }; - -/** - * Helper to get ordered start/end dates. - */ -export function getOrderedDates( - start: Date | null, - end: Date | null, -): { start: Date | null; end: Date | null } { - if (!start || !end) return { start, end }; - return start.getTime() > end.getTime() - ? { start: end, end: start } - : { start, end }; -} diff --git a/web-common/src/features/dashboards/time-series/measure-chart/scales.ts b/web-common/src/features/dashboards/time-series/measure-chart/scales.ts index 41a3ad20f06..858c22d6781 100644 --- a/web-common/src/features/dashboards/time-series/measure-chart/scales.ts +++ b/web-common/src/features/dashboards/time-series/measure-chart/scales.ts @@ -1,11 +1,8 @@ -import { scaleLinear } from "d3-scale"; -import { max, min } from "d3-array"; +import { min, max } from "d3-array"; import type { TimeSeriesPoint, DimensionSeriesData, ChartConfig, - ChartScales, - PlotBounds, } from "./types"; interface ExtentConfig { @@ -112,55 +109,6 @@ export function computeYExtent( return [min(values) ?? 0, max(values) ?? 1]; } -/** - * Compute X extent as index range [0, N-1]. - */ -export function computeXExtent(data: TimeSeriesPoint[]): [number, number] { - return [0, Math.max(0, data.length - 1)]; -} - -/** - * Create index-based X scale. - */ -export function createXScale( - data: TimeSeriesPoint[], - plotBounds: PlotBounds, -): ChartScales["x"] { - return scaleLinear() - .domain(computeXExtent(data)) - .range([plotBounds.left, plotBounds.left + plotBounds.width]); -} - -/** - * Create Y scale from value extent. - */ -export function createYScale( - yExtent: [number, number], - plotBounds: PlotBounds, -): ChartScales["y"] { - return scaleLinear() - .domain(yExtent) - .range([plotBounds.top + plotBounds.height, plotBounds.top]); // Inverted for SVG -} - -/** - * Create both X and Y scales. - */ -export function createScales( - data: TimeSeriesPoint[], - dimensionData: DimensionSeriesData[], - showComparison: boolean, - plotBounds: PlotBounds, -): ChartScales { - const yRawExtent = computeYExtent(data, dimensionData, showComparison); - const yExtent = computeNiceYExtent(yRawExtent[0], yRawExtent[1]); - - return { - x: createXScale(data, plotBounds), - y: createYScale(yExtent, plotBounds), - }; -} - /** * Compute chart configuration from dimensions. */ @@ -193,17 +141,3 @@ export function computeChartConfig( }, }; } - -/** - * Update scales with new data while preserving animation continuity. - */ -export function updateScalesWithData( - existingScales: ChartScales | null, - data: TimeSeriesPoint[], - dimensionData: DimensionSeriesData[], - showComparison: boolean, - plotBounds: PlotBounds, -): ChartScales { - // Always recompute scales - animation is handled by tweening the domain values - return createScales(data, dimensionData, showComparison, plotBounds); -} diff --git a/web-common/src/features/dashboards/time-series/measure-chart/types.ts b/web-common/src/features/dashboards/time-series/measure-chart/types.ts index ff980274c4d..ccb97251142 100644 --- a/web-common/src/features/dashboards/time-series/measure-chart/types.ts +++ b/web-common/src/features/dashboards/time-series/measure-chart/types.ts @@ -1,5 +1,4 @@ import type { ScaleLinear } from "d3-scale"; -import type { MetricsViewSpecMeasure } from "@rilldata/web-common/runtime-client"; import type { DateTime } from "luxon"; /** @@ -98,54 +97,6 @@ export interface HoverState { isHovered: boolean; } -/** - * Bisected point — just the snapped index. - */ -export interface BisectedPoint { - /** Nearest data index */ - index: number; -} - -/** - * Combined interaction state. - */ -export interface InteractionState { - hover: HoverState; - scrub: ScrubState; - bisectedPoint: BisectedPoint; - cursorStyle: string; -} - -/** - * Event handlers for chart interactions. - */ -export interface InteractionHandlers { - onMouseMove: (event: MouseEvent) => void; - onMouseLeave: () => void; - onMouseDown: (event: MouseEvent) => void; - onMouseUp: (event: MouseEvent) => void; - onClick: (event: MouseEvent) => void; -} - -/** - * Props for the new MeasureChart component. - * Reduced from 41 to ~15 essential props. - */ -export interface MeasureChartProps { - /** The measure specification */ - measure: MetricsViewSpecMeasure; - /** Explorer name for state management */ - exploreName: string; - /** Whether to show time comparison overlay */ - showComparison?: boolean; - /** Whether showing expanded TDD view */ - showTimeDimensionDetail?: boolean; - /** Chart width (auto-calculated if not provided) */ - width?: number; - /** Chart height (auto-calculated if not provided) */ - height?: number; -} - /** * A generic series descriptor for the pure TimeSeriesChart renderer. * Decoupled from measure/dimension semantics. diff --git a/web-common/src/features/dashboards/time-series/measure-chart/utils.ts b/web-common/src/features/dashboards/time-series/measure-chart/utils.ts new file mode 100644 index 00000000000..6f072d7bb9f --- /dev/null +++ b/web-common/src/features/dashboards/time-series/measure-chart/utils.ts @@ -0,0 +1,71 @@ +import type { TimeSeriesPoint } from "./types"; + +/** + * Clamp a fractional index to the nearest valid array index. + */ +export function snapIndex(idx: number, length: number): number { + return Math.max(0, Math.min(length - 1, Math.round(idx))); +} + +/** + * Find the data index whose timestamp is closest to the given millisecond timestamp. + */ +export function dateToIndex( + data: TimeSeriesPoint[], + ms: number, +): number | null { + if (data.length === 0) return null; + let best = 0; + let bestDist = Infinity; + for (let i = 0; i < data.length; i++) { + const dist = Math.abs(data[i].ts.toMillis() - ms); + if (dist < bestDist) { + bestDist = dist; + best = i; + } + } + return best; +} + +export interface BarSlotGeometry { + slotWidth: number; + gap: number; + bandWidth: number; + barGap: number; + singleBarWidth: number; +} + +/** + * Compute the bar slot geometry for grouped bar charts. + */ +export function computeBarSlotGeometry( + plotWidth: number, + visibleCount: number, + barCount: number, +): BarSlotGeometry { + const slotWidth = plotWidth / Math.max(1, visibleCount); + const gap = slotWidth * 0.2; + const bandWidth = slotWidth - gap; + const barGap = barCount > 1 ? 2 : 0; + const totalGaps = barGap * (barCount - 1); + const singleBarWidth = (bandWidth - totalGaps) / barCount; + return { slotWidth, gap, bandWidth, barGap, singleBarWidth }; +} + +/** + * Compute the x position of a bar center within a slot. + */ +export function barCenterX( + slotCenterX: number, + bandWidth: number, + singleBarWidth: number, + barGap: number, + barIndex: number, +): number { + return ( + slotCenterX - + bandWidth / 2 + + barIndex * (singleBarWidth + barGap) + + singleBarWidth / 2 + ); +} diff --git a/web-common/src/features/dashboards/time-series/round-to-nearest-time-unit.spec.ts b/web-common/src/features/dashboards/time-series/round-to-nearest-time-unit.spec.ts deleted file mode 100644 index a264c82fbd0..00000000000 --- a/web-common/src/features/dashboards/time-series/round-to-nearest-time-unit.spec.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { roundToNearestTimeUnit } from "./round-to-nearest-time-unit"; -import { describe, it, expect } from "vitest"; - -describe("roundToNearestTimeUnit", () => { - it("rounds to nearest minute", () => { - const date = new Date("2023-03-29T12:34:56"); - const expectedResult = new Date("2023-03-29T12:35:00"); - expect(roundToNearestTimeUnit(date, "minute")).toEqual(expectedResult); - }); - - it("rounds to nearest hour", () => { - const date = new Date("2023-03-29T12:34:56"); - const expectedResult = new Date("2023-03-29T13:00:00"); - expect(roundToNearestTimeUnit(date, "hour")).toEqual(expectedResult); - }); - - it("rounds to nearest day", () => { - const date = new Date("2023-03-29T12:34:56"); - const expectedResult = new Date("2023-03-30T00:00:00"); - expect(roundToNearestTimeUnit(date, "day")).toEqual(expectedResult); - }); - - it("rounds to nearest month", () => { - const date = new Date("2023-03-14T12:34:56"); - const expectedResult = new Date("2023-03-01T00:00:00"); - expect(roundToNearestTimeUnit(date, "month")).toEqual(expectedResult); - }); -}); diff --git a/web-common/src/features/dashboards/time-series/round-to-nearest-time-unit.ts b/web-common/src/features/dashboards/time-series/round-to-nearest-time-unit.ts deleted file mode 100644 index 6fb88013543..00000000000 --- a/web-common/src/features/dashboards/time-series/round-to-nearest-time-unit.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { DateTime, type DateTimeUnit } from "luxon"; - -export function roundToNearestTimeUnit(date: Date, unit: DateTimeUnit): Date { - const dateTime = DateTime.fromJSDate(date); - if (!DateTime.isDateTime(dateTime)) { - throw new Error("Invalid Luxon DateTime object"); - } - - const unitMap: Record = { - year: "month", - quarter: "month", - month: "day", - week: "weekday", - day: "hour", - hour: "minute", - minute: "second", - second: "millisecond", - millisecond: "millisecond", - }; - // get smallest unit - const smallerUnit = unitMap[unit]; - - const smallestValue = dateTime.get(smallerUnit); - let roundUp = false; - if (smallerUnit === "millisecond") { - roundUp = smallestValue >= 500; - } else if (smallerUnit === "second") { - roundUp = smallestValue >= 30; - } else if (smallerUnit === "minute") { - roundUp = smallestValue >= 30; - } else if (smallerUnit === "hour") { - roundUp = smallestValue >= 12; - } else if (smallerUnit === "day") { - roundUp = smallestValue >= 15; - } else if (smallerUnit === "weekday") { - roundUp = smallestValue >= 3; - } else if (smallerUnit === "month") { - roundUp = smallestValue >= 6; - } - - const unitValue = dateTime.get(unit as keyof DateTime); - const roundedValue = roundUp ? unitValue + 1 : unitValue; - - let roundedDateTime: DateTime; - - if (unit === "week") { - roundedDateTime = dateTime.startOf("day")[roundUp ? "plus" : "minus"]({ - day: roundUp ? 7 - smallestValue : smallestValue, - }); - } else { - roundedDateTime = dateTime - .startOf(unit as DateTimeUnit, { useLocaleWeeks: true }) - .set({ [unit]: roundedValue }); - } - - return roundedDateTime.toJSDate(); -} - -export function roundDownToTimeUnit( - date: Date, - unit: DateTimeUnit | keyof DateTime, -) { - const dateTime = DateTime.fromJSDate(date); - if (!DateTime.isDateTime(dateTime)) { - throw new Error("Invalid Luxon DateTime object"); - } - - return dateTime - .startOf(unit as DateTimeUnit, { useLocaleWeeks: true }) - .toJSDate(); -} diff --git a/web-common/src/features/dashboards/time-series/utils.ts b/web-common/src/features/dashboards/time-series/utils.ts index f6bb1b9fccd..a27c9ae8218 100644 --- a/web-common/src/features/dashboards/time-series/utils.ts +++ b/web-common/src/features/dashboards/time-series/utils.ts @@ -1,5 +1,3 @@ -import type { GraphicScale } from "@rilldata/web-common/components/data-graphic/state/types"; -import { bisectData } from "@rilldata/web-common/components/data-graphic/utils"; import { createIndexMap } from "@rilldata/web-common/features/dashboards/pivot/pivot-utils"; import { createAndExpression, @@ -11,14 +9,12 @@ import type { V1MetricsViewAggregationResponseDataItem, V1TimeSeriesValue, } from "@rilldata/web-common/runtime-client"; -import type { DateTimeUnit } from "luxon"; import { convertISOStringToJSDateWithSameTimeAsSelectedTimeZone, removeZoneOffset, } from "../../../lib/time/timezone"; import { getDurationMultiple, getOffset } from "../../../lib/time/transforms"; import { TimeOffsetType } from "../../../lib/time/types"; -import { roundToNearestTimeUnit } from "./round-to-nearest-time-unit"; import type { TimeSeriesDatum } from "./timeseries-data-store"; /** sets extents to 0 if it makes sense; otherwise, inflates each extent component */ @@ -96,30 +92,6 @@ export function prepareTimeSeries( }); } -export function getBisectedTimeFromCordinates( - value: number, - scaleStore: GraphicScale, - accessor: string, - data: TimeSeriesDatum[], - grainLabel: DateTimeUnit, -): Date | null { - const roundedValue = roundToNearestTimeUnit( - new Date(scaleStore.invert(value)), - grainLabel, - ); - const { entry: bisector } = bisectData( - roundedValue, - "center", - accessor, - data, - ); - if (!bisector || typeof bisector === "number") return null; - const bisected = bisector[accessor]; - if (!bisected) return null; - - return new Date(bisected); -} - /** * The dates in the charts are in the local timezone, this util method * removes the selected timezone offset and adds the local offset From fe3301c1338ecdc1954f8f2114ad3b3673da2e55 Mon Sep 17 00:00:00 2001 From: Brian Holmes Date: Tue, 3 Feb 2026 18:05:40 -0500 Subject: [PATCH 10/36] remove dead code --- .../WithParentClientRect.svelte | 36 ------------------- 1 file changed, 36 deletions(-) delete mode 100644 web-common/src/components/data-graphic/functional-components/WithParentClientRect.svelte diff --git a/web-common/src/components/data-graphic/functional-components/WithParentClientRect.svelte b/web-common/src/components/data-graphic/functional-components/WithParentClientRect.svelte deleted file mode 100644 index d6872b21a07..00000000000 --- a/web-common/src/components/data-graphic/functional-components/WithParentClientRect.svelte +++ /dev/null @@ -1,36 +0,0 @@ - - - - - - From 4c5d2b5cd4c2341b2cce0a0503dda4b4a9b3d925 Mon Sep 17 00:00:00 2001 From: Brian Holmes Date: Tue, 3 Feb 2026 18:11:52 -0500 Subject: [PATCH 11/36] cleanup --- .../measure-chart/MeasureChart.svelte | 7 ++--- .../measure-chart/ScrubController.ts | 15 ---------- .../time-series/measure-chart/index.ts | 30 ------------------- 3 files changed, 2 insertions(+), 50 deletions(-) diff --git a/web-common/src/features/dashboards/time-series/measure-chart/MeasureChart.svelte b/web-common/src/features/dashboards/time-series/measure-chart/MeasureChart.svelte index 6e218aba32f..4f915e9b203 100644 --- a/web-common/src/features/dashboards/time-series/measure-chart/MeasureChart.svelte +++ b/web-common/src/features/dashboards/time-series/measure-chart/MeasureChart.svelte @@ -277,7 +277,7 @@ !!dimIsFetching; // Chart series & mode - $: mode = determineMode(data, dimensionData); + $: mode = determineMode(data); $: chartSeries = buildChartSeries(data, dimensionData, showComparison); // For bar mode with time comparison, reverse order so comparison bar is on left @@ -533,10 +533,7 @@ return result; } - function determineMode( - d: TimeSeriesPoint[], - _dimData: DimensionSeriesData[], - ): ChartMode { + function determineMode(d: TimeSeriesPoint[]): ChartMode { if (d.length >= LINE_MODE_MIN_POINTS) return "line"; return "bar"; } diff --git a/web-common/src/features/dashboards/time-series/measure-chart/ScrubController.ts b/web-common/src/features/dashboards/time-series/measure-chart/ScrubController.ts index 03c93c4977a..00351e0f8cd 100644 --- a/web-common/src/features/dashboards/time-series/measure-chart/ScrubController.ts +++ b/web-common/src/features/dashboards/time-series/measure-chart/ScrubController.ts @@ -2,7 +2,6 @@ import { writable, get, type Readable, type Writable } from "svelte/store"; import type { ScrubState } from "./types"; import type { ScaleLinear } from "d3-scale"; -export type { ScrubState }; type ScrubMode = "none" | "create" | "resize-start" | "resize-end" | "move"; @@ -200,20 +199,6 @@ export class ScrubController { return true; } - /** Check if a click is outside the current selection. */ - isClickOutside(screenX: number, xScale: XScale): boolean { - const state = get(this._state); - if (state.startIndex === null || state.endIndex === null) return false; - - const clickIndex = xScale.invert(screenX); - const [min, max] = - state.startIndex < state.endIndex - ? [state.startIndex, state.endIndex] - : [state.endIndex, state.startIndex]; - - return clickIndex < min || clickIndex > max; - } - /** Reset scrub state completely. */ reset(): void { this._state.set(EMPTY_SCRUB); diff --git a/web-common/src/features/dashboards/time-series/measure-chart/index.ts b/web-common/src/features/dashboards/time-series/measure-chart/index.ts index b6e25d0ebef..65f15ef19f8 100644 --- a/web-common/src/features/dashboards/time-series/measure-chart/index.ts +++ b/web-common/src/features/dashboards/time-series/measure-chart/index.ts @@ -1,31 +1 @@ -// Main component export { default as MeasureChart } from "./MeasureChart.svelte"; - -// Sub-components -export { default as MeasureChartTooltip } from "./MeasureChartTooltip.svelte"; -export { default as MeasureChartScrub } from "./MeasureChartScrub.svelte"; - -// Types -export type { - TimeSeriesPoint, - DimensionSeriesData, - ChartConfig, - ChartScales, - ChartSeries, - ChartMode, - ScrubState, - HoverState, -} from "./types"; - -// Utilities -export { - computeChartConfig, - computeNiceYExtent, - computeYExtent, -} from "./scales"; - -export { createVisibilityObserver } from "./interactions"; - -export { ScrubController } from "./ScrubController"; - -export { transformTimeSeriesData } from "./use-measure-time-series"; From 4ce4e5a6ced1a7dbd476efd64e96e52653c9c709 Mon Sep 17 00:00:00 2001 From: Brian Holmes Date: Wed, 4 Feb 2026 09:38:40 -0500 Subject: [PATCH 12/36] improve generics and cleanup --- .../src/components/data-graphic/README.md | 19 -- .../timestamp-profile/TimestampDetail.svelte | 29 +-- .../timestamp-profile/TimestampSpark.svelte | 45 ++-- .../TimestampTooltipContent.svelte | 7 +- .../data-graphic/constants/index.ts | 24 -- .../data-graphic/constants/types.d.ts | 7 - .../functional-components/index.ts | 1 - .../functional-components/types.ts | 6 - .../data-graphic/marks/annotations.ts | 206 ----------------- .../marks/prevent-vertical-overlap.spec.ts | 116 ---------- .../marks/prevent-vertical-overlap.ts | 88 -------- .../components/data-graphic/marks/segment.ts | 69 ------ .../components/data-graphic/marks/types.ts | 23 -- .../components/data-graphic/state/types.d.ts | 59 ----- .../src/components/data-graphic/utils.ts | 210 +++++------------- .../components/time-series-chart/Line.svelte | 43 ++-- .../time-series-chart/TimeSeriesChart.svelte | 29 +-- .../column-types/TimestampProfile.svelte | 14 +- .../column-types/details/NumericPlot.svelte | 56 ++--- .../details/SummaryNumberPlot.svelte | 4 +- .../sparks/NullPercentageSpark.svelte | 3 +- .../column-types/sparks/NumericSpark.svelte | 24 +- .../src/features/column-profile/queries.ts | 40 ++-- .../big-number/MeasureBigNumber.svelte | 2 +- .../measure-chart/MeasureChart.svelte | 7 +- .../MeasureChartAnnotationMarkers.svelte | 10 +- .../MeasureChartAnnotationPopover.svelte | 8 +- .../measure-chart/annotation-utils.ts | 1 + .../measure-chart/use-dimension-data.ts | 2 +- .../measure-selection/measure-selection.ts | 37 --- web-common/src/lib/convertTimestampPreview.ts | 17 -- web-common/src/lib/duckdb-data-types.ts | 2 +- 32 files changed, 205 insertions(+), 1003 deletions(-) delete mode 100644 web-common/src/components/data-graphic/README.md delete mode 100644 web-common/src/components/data-graphic/constants/index.ts delete mode 100644 web-common/src/components/data-graphic/constants/types.d.ts delete mode 100644 web-common/src/components/data-graphic/functional-components/index.ts delete mode 100644 web-common/src/components/data-graphic/functional-components/types.ts delete mode 100644 web-common/src/components/data-graphic/marks/prevent-vertical-overlap.spec.ts delete mode 100644 web-common/src/components/data-graphic/marks/prevent-vertical-overlap.ts delete mode 100644 web-common/src/components/data-graphic/marks/segment.ts delete mode 100644 web-common/src/components/data-graphic/marks/types.ts delete mode 100644 web-common/src/components/data-graphic/state/types.d.ts delete mode 100644 web-common/src/lib/convertTimestampPreview.ts diff --git a/web-common/src/components/data-graphic/README.md b/web-common/src/components/data-graphic/README.md deleted file mode 100644 index c3764f29e4c..00000000000 --- a/web-common/src/components/data-graphic/README.md +++ /dev/null @@ -1,19 +0,0 @@ -# `data-graphic` - -This directory contains components for building flexible data graphics. A solid component system should be: - -- _composable_ - the system of domain and range cascading should ensure that we can do lots of complex things easily, such as nesting graphic contexts within each other. -- _mostly-declarative_ - by taking care of a great deal of complexity for the user – and exposing common primitives like scales in the right places – someone shouldn't have to write too much code to make an interactive data graphic. -- _reactive_ - the domain-space (e.g. the scale domains) should be able to reactively respond to changes in the data. The range-space should be able to respond to changes in configuration parameters. And this should be efficient enough that all values should be tweenable! -- _elegant_ - the system should have sensible defaults and high-quality, human design. -- _cohesive_ - ideally, all our charts and plots should fit nicely within Rill's larger design system. One of the benefits of building our own system is that we can achieve this thread without paying for loss of functionality. - -This component set is organized as such: - -- `elements` - contains the main containers for data graphics. -- `actions` - contains various actions & action factories used for data graphics. -- `constants` - contains the constants used throughout the component set. -- `guides` - guides are components that orient the data graphic, such as axes, grids, and mouseover labels. -- `marks` - contains the main components used to map data to geometric shapes. -- `functional-components` - components that perform some small function and then expose the output in a slot. These convenience components enable users to add a bit of custom functionality when needed without having to resort to reaching into the `script` tag. -- `state` - contains various store factories used throughout the component set. diff --git a/web-common/src/components/data-graphic/compositions/timestamp-profile/TimestampDetail.svelte b/web-common/src/components/data-graphic/compositions/timestamp-profile/TimestampDetail.svelte index af6a07e62b2..28d746a2e5e 100644 --- a/web-common/src/components/data-graphic/compositions/timestamp-profile/TimestampDetail.svelte +++ b/web-common/src/components/data-graphic/compositions/timestamp-profile/TimestampDetail.svelte @@ -22,20 +22,15 @@ Uses index-based scales and TimeSeriesChart for rendering. import { timeGrainToDuration } from "@rilldata/web-common/lib/time/grains"; import { removeLocalTimezoneOffset } from "@rilldata/web-common/lib/time/timezone"; import type { V1TimeGrain } from "@rilldata/web-common/runtime-client"; + import { createLineGenerator } from "@rilldata/web-common/components/data-graphic/utils"; import { max } from "d3-array"; import { scaleLinear } from "d3-scale"; - import { curveLinear, line } from "d3-shape"; import { onMount } from "svelte"; import { fade, fly } from "svelte/transition"; import TimestampBound from "./TimestampBound.svelte"; import TimestampProfileSummary from "./TimestampProfileSummary.svelte"; import TimestampTooltipContent from "./TimestampTooltipContent.svelte"; - - interface TimestampDataPoint { - ts: Date; - count: number; - [key: string]: unknown; - } + import type { TimestampDataPoint } from "@rilldata/web-common/features/column-profile/queries"; const id = guidGenerator(); const tooltipSparkWidth = 84; @@ -47,8 +42,6 @@ Uses index-based scales and TimeSeriesChart for rendering. export let height = 120; export let mouseover = false; export let smooth = true; - export let xAccessor: string; - export let yAccessor: string; export let left = 1; export let right = 1; export let top = 12; @@ -160,10 +153,10 @@ Uses index-based scales and TimeSeriesChart for rendering. windowWithoutZeros.length > 0 && windowWithoutZeros.length > width * devicePixelRatio; - $: smoothedLineGen = line() - .x((_d, i) => xScale(i)) - .y((d) => yScale(d ?? 0)) - .curve(curveLinear); + $: smoothedLineGen = createLineGenerator({ + x: (_d, i) => xScale(i), + y: (d) => yScale(d ?? 0), + }); $: smoothedPath = smoothedLineGen(smoothedValues) ?? ""; @@ -199,15 +192,15 @@ Uses index-based scales and TimeSeriesChart for rendering. ); function val(d: TimestampDataPoint): number { - return d[yAccessor] as number; + return d.count; } - function dateAt(d: TimestampDataPoint | undefined): Date { - return d?.[xAccessor] as Date; + function dateAt(d: TimestampDataPoint) { + return d.ts; } function indexToDate(idx: number): Date { - return dateAt(data[snapIndex(idx, data.length)]); + return dateAt(data[snapIndex(idx, data.length)]) ?? new Date(); } function computeZoomedRows( @@ -523,8 +516,6 @@ Uses index-based scales and TimeSeriesChart for rendering. > d[xAcc] as Date); - $: [, yMaxVal] = extent(data, (d) => d[yAcc] as number); + $: [xMinVal, xMaxVal] = extent(data, (d) => d.ts); + $: [, yMaxVal] = extent(data, (d) => d.count); $: xScale = scaleTime() .domain([xMinVal ?? new Date(), xMaxVal ?? new Date()]) @@ -48,13 +43,23 @@ .domain([0, yMaxVal ?? 1]) .range([plotBottom, plotTop]); - $: lineFn = lineFactory({ xScale, yScale, xAccessor: xAcc }); - $: areaFn = areaFactory({ xScale, yScale, xAccessor: xAcc }); + $: lineGen = createLineGenerator({ + x: (d) => xScale(d.ts ?? 0), + y: (d) => yScale(d.count), + defined: pathDoesNotDropToZero("count"), + }); + + $: areaGen = createAreaGenerator({ + x: (d) => xScale(d.ts ?? 0), + y0: yScale(0), + y1: (d) => yScale(d.count), + defined: pathDoesNotDropToZero("count"), + }); - $: linePath = lineFn(yAcc)(data); - $: areaPath = areaFn(yAcc)(data); + $: linePath = lineGen(data); + $: areaPath = areaGen(data); - export function scaleVertical( + function scaleVertical( node: Element, { delay = 0, diff --git a/web-common/src/components/data-graphic/compositions/timestamp-profile/TimestampTooltipContent.svelte b/web-common/src/components/data-graphic/compositions/timestamp-profile/TimestampTooltipContent.svelte index 75101726e96..f657ccc6832 100644 --- a/web-common/src/components/data-graphic/compositions/timestamp-profile/TimestampTooltipContent.svelte +++ b/web-common/src/components/data-graphic/compositions/timestamp-profile/TimestampTooltipContent.svelte @@ -14,10 +14,9 @@ } from "@rilldata/web-common/lib/formatters"; import { isClipboardApiSupported } from "../../../../lib/actions/copy-to-clipboard"; import TimestampSpark from "./TimestampSpark.svelte"; + import type { TimestampDataPoint } from "@rilldata/web-common/features/column-profile/queries"; - export let xAccessor: string; - export let yAccessor: string; - export let data; + export let data: TimestampDataPoint[]; // FIXME: document meaning of these special looking numbers // e.g. something like width = y* CHAR_HEIGHT, height = CHAR_HEIGHT? export let width = 84; @@ -78,8 +77,6 @@
= { - x: undefined, - y: undefined, -}; - -export const contexts = { - config: "rill:data-graphic:plot-config", - scale(namespace: string) { - return `rill:data-graphic:${namespace}-scale`; - }, - min(namespace: string) { - return `rill:data-graphic:${namespace}-min`; - }, - max(namespace: string) { - return `rill:data-graphic:${namespace}-max`; - }, -}; diff --git a/web-common/src/components/data-graphic/constants/types.d.ts b/web-common/src/components/data-graphic/constants/types.d.ts deleted file mode 100644 index 1f476832a37..00000000000 --- a/web-common/src/components/data-graphic/constants/types.d.ts +++ /dev/null @@ -1,7 +0,0 @@ -export interface DomainCoordinates { - x?: T; - y?: number; - // For annotations we need the actual x/y. So save it directly for easy access. - xActual?: number; - yActual?: number; -} diff --git a/web-common/src/components/data-graphic/functional-components/index.ts b/web-common/src/components/data-graphic/functional-components/index.ts deleted file mode 100644 index 2fe863efacf..00000000000 --- a/web-common/src/components/data-graphic/functional-components/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default as WithTween } from "./WithTween.svelte"; diff --git a/web-common/src/components/data-graphic/functional-components/types.ts b/web-common/src/components/data-graphic/functional-components/types.ts deleted file mode 100644 index 48c581f0ad3..00000000000 --- a/web-common/src/components/data-graphic/functional-components/types.ts +++ /dev/null @@ -1,6 +0,0 @@ -export type NumericPlotPoint = { - low: number; - high: number; - count: number; - value: number; -}; diff --git a/web-common/src/components/data-graphic/marks/annotations.ts b/web-common/src/components/data-graphic/marks/annotations.ts index c8947c3930b..3bcd994a3d5 100644 --- a/web-common/src/components/data-graphic/marks/annotations.ts +++ b/web-common/src/components/data-graphic/marks/annotations.ts @@ -1,213 +1,7 @@ -import type { DomainCoordinates } from "@rilldata/web-common/components/data-graphic/constants/types"; -import type { - GraphicScale, - SimpleDataGraphicConfiguration, -} from "@rilldata/web-common/components/data-graphic/state/types"; -import { Throttler } from "@rilldata/web-common/lib/throttler.ts"; import type { V1MetricsViewAnnotationsResponseAnnotation } from "@rilldata/web-common/runtime-client"; -import type { ActionReturn } from "svelte/action"; -import { get, writable } from "svelte/store"; export type Annotation = V1MetricsViewAnnotationsResponseAnnotation & { startTime: Date; endTime?: Date; formattedTimeOrRange: string; }; - -export type AnnotationGroup = { - items: Annotation[]; - top: number; - left: number; - bottom: number; - right: number; - hasRange: boolean; -}; - -export const AnnotationWidth = 10; -const AnnotationOverlapWidth = AnnotationWidth * (1 - 0.4); // Width where 40% overlap -export const AnnotationHeight = 10; - -export class AnnotationsStore { - public lookupTable = writable<(AnnotationGroup | undefined)[]>([]); - public annotationGroups = writable([]); - public hoveredAnnotationGroup = writable( - undefined, - ); - - public annotationPopoverOpened = writable(false); - public annotationPopoverHovered = writable(false); - public annotationPopoverTextHiddenCount = writable(0); - - private hoverCheckThrottler = new Throttler(100, 100); - - public updateData( - annotations: Annotation[], - scaler: GraphicScale, - config: SimpleDataGraphicConfiguration, - ) { - const groups = this.createAnnotationGroups(annotations, scaler, config); - this.annotationGroups.set(groups); - const lookupTable = this.buildLookupTable(groups); - this.lookupTable.set(lookupTable); - } - - public triggerHoverCheck( - mouseoverValue: DomainCoordinates | undefined, - mouseOverThisChart: boolean, - annotationPopoverHovered: boolean, - ) { - // Check the hover at a slight delay. - // When the popover is hovered, there will be a small window where both mouseOverThisChart and annotationPopoverHovered will be false. - this.hoverCheckThrottler.throttle(() => - this.checkHover( - mouseoverValue, - mouseOverThisChart, - annotationPopoverHovered, - ), - ); - } - - public textHiddenActions = (node: HTMLElement): ActionReturn => { - let hidden = false; - const checkTextHidden = () => { - const currentlyHidden = - node.scrollWidth > node.clientWidth || - node.scrollHeight > node.clientHeight; - if (currentlyHidden === hidden) return; - - this.annotationPopoverTextHiddenCount.set( - get(this.annotationPopoverTextHiddenCount) + (currentlyHidden ? 1 : -1), - ); - hidden = currentlyHidden; - }; - checkTextHidden(); - - node.addEventListener("resize", checkTextHidden); - return { - destroy: () => { - this.annotationPopoverTextHiddenCount.set( - get(this.annotationPopoverTextHiddenCount) + (hidden ? -1 : 0), - ); - node.removeEventListener("resize", checkTextHidden); - }, - }; - }; - - private checkHover( - mouseoverValue: DomainCoordinates | undefined, - mouseOverThisChart: boolean, - annotationPopoverHovered: boolean, - ) { - const annotationGroups = get(this.annotationGroups); - const lookupTable = get(this.lookupTable); - const top = annotationGroups[0]?.top; - let hoveredAnnotationGroup = get(this.hoveredAnnotationGroup); - - const mouseX = mouseoverValue?.xActual; - const mouseY = mouseoverValue?.yActual; - - const yNearAnnotations = mouseY !== undefined && mouseY > top; - const checkXCoord = yNearAnnotations && mouseX !== undefined; - - if (!mouseOverThisChart && !annotationPopoverHovered) { - // If the mouse is no longer hovering, the current chart or an annotation popover unset the group. - hoveredAnnotationGroup = undefined; - } else { - const tempHoverGroup = checkXCoord ? lookupTable[mouseX] : undefined; - const hoverGroupChanged = - tempHoverGroup && tempHoverGroup !== hoveredAnnotationGroup; - const cursorToLeftOfCurrentGroup = - hoveredAnnotationGroup && - mouseX !== undefined && - mouseX < hoveredAnnotationGroup.left; - - if (hoverGroupChanged) { - // To keep the popover opened for interaction, only update the hovered group when it changes but not when it goes undefined. - hoveredAnnotationGroup = tempHoverGroup; - } else if (cursorToLeftOfCurrentGroup) { - // Else to have better UX, if cursor is to the left of the currently hovered group then unset it. - hoveredAnnotationGroup = undefined; - } - } - - this.hoveredAnnotationGroup.set(hoveredAnnotationGroup); - } - - private createAnnotationGroups( - annotations: Annotation[], - scaler: GraphicScale, - config: SimpleDataGraphicConfiguration, - ): AnnotationGroup[] { - if (annotations.length === 0 || !scaler || !config) return []; - - let currentGroup: AnnotationGroup = this.getSingletonAnnotationGroup( - annotations[0], - scaler, - config, - ); - const groups: AnnotationGroup[] = [currentGroup]; - - for (let i = 1; i < annotations.length; i++) { - const annotation = annotations[i]; - const group = this.getSingletonAnnotationGroup( - annotation, - scaler, - config, - ); - - const leftDiff = group.left - currentGroup.left; - - if (leftDiff < AnnotationOverlapWidth) { - currentGroup.right = Math.max(currentGroup.right, group.right); - currentGroup.items.push(annotation); - } else { - currentGroup = group; - groups.push(currentGroup); - } - } - - // Filter out-of-bounds items. - return groups.filter( - (g) => g.left > config.plotLeft && g.left < config.plotRight, - ); - } - - private buildLookupTable(annotationGroups: AnnotationGroup[]) { - if (annotationGroups.length === 0) return []; - const lastGroup = annotationGroups[annotationGroups.length - 1]; - - const lookupTable = new Array( - Math.ceil(lastGroup.right) + 1, - ).fill(undefined); - - annotationGroups.forEach((group) => { - const left = Math.floor(group.left); - for (let x = 0; x <= AnnotationWidth; x++) { - lookupTable[left + x] = group; - } - }); - - return lookupTable; - } - - private getSingletonAnnotationGroup( - annotation: Annotation, - scaler: GraphicScale, - config: SimpleDataGraphicConfiguration, - ): AnnotationGroup { - const left = config.bodyLeft / 2 + scaler(annotation.startTime); - const right = - config.bodyLeft / 2 + - (annotation.endTime - ? scaler(annotation.endTime) - : left + AnnotationWidth); - return { - items: [annotation], - top: config.plotBottom - AnnotationHeight, - left, - bottom: config.plotBottom, - right, - hasRange: !!annotation.endTime, - }; - } -} diff --git a/web-common/src/components/data-graphic/marks/prevent-vertical-overlap.spec.ts b/web-common/src/components/data-graphic/marks/prevent-vertical-overlap.spec.ts deleted file mode 100644 index 91fdb68e306..00000000000 --- a/web-common/src/components/data-graphic/marks/prevent-vertical-overlap.spec.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { preventVerticalOverlap } from "./prevent-vertical-overlap"; - -describe("preventVerticalOverlap", () => { - it("returns an empty array if input is empty", () => { - const result = preventVerticalOverlap([], 0, 100, 10, 2); - expect(result).toEqual([]); - }); - - it("returns the input array if only one point is provided", () => { - const input = [{ key: 1, value: 50 }]; - const expectedOutput = [{ key: 1, value: 50 }]; - const result = preventVerticalOverlap(input, 0, 100, 10, 2); - expect(result).toEqual(expectedOutput); - }); - - it("prevents overlap for points close together", () => { - const input = [ - { key: 1, value: 50 }, - { key: 2, value: 55 }, - ]; - const expectedOutput = [ - { key: 1, value: 43 }, - { key: 2, value: 55 }, - ]; - const result = preventVerticalOverlap(input, 0, 100, 10, 2); - expect(result).toEqual(expectedOutput); - }); - - it("prevents overlap for points and respects boundaries", () => { - const input = [ - { key: 1, value: 10 }, - { key: 2, value: 25 }, - { key: 3, value: 60 }, - { key: 4, value: 90 }, - ]; - const expectedOutput = [ - { key: 1, value: 12 }, - { key: 2, value: 25 }, - { key: 3, value: 60 }, - { key: 4, value: 88 }, - ]; - const result = preventVerticalOverlap(input, 10, 90, 10, 2); - expect(result).toEqual(expectedOutput); - }); - - it("handles case when all points are close to the top boundary", () => { - const input = [ - { key: 1, value: 15 }, - { key: 2, value: 20 }, - { key: 3, value: 25 }, - ]; - const expectedOutput = [ - { key: 1, value: 12 }, - { key: 2, value: 24 }, - { key: 3, value: 36 }, - ]; - const result = preventVerticalOverlap(input, 10, 90, 10, 2); - expect(result).toEqual(expectedOutput); - }); - it("handles case when all points are close to the bottom boundary", () => { - const input = [ - { key: 1, value: 75 }, - { key: 2, value: 80 }, - { key: 3, value: 85 }, - ]; - const expectedOutput = [ - { key: 1, value: 64 }, - { key: 2, value: 76 }, - { key: 3, value: 88 }, - ]; - const result = preventVerticalOverlap(input, 10, 90, 10, 2); - expect(result).toEqual(expectedOutput); - }); - - it("handles points close together in the middle", () => { - const input = [ - { key: 1, value: 45 }, - { key: 2, value: 50 }, - { key: 3, value: 55 }, - ]; - const expectedOutput = [ - { key: 1, value: 38 }, - { key: 2, value: 50 }, - { key: 3, value: 62 }, - ]; - const result = preventVerticalOverlap(input, 10, 90, 10, 2); - expect(result).toEqual(expectedOutput); - }); - - it("handles points near both boundaries", () => { - const input = [ - { key: 1, value: 15 }, - { key: 2, value: 85 }, - ]; - const expectedOutput = [ - { key: 1, value: 15 }, - { key: 2, value: 85 }, - ]; - const result = preventVerticalOverlap(input, 10, 90, 10, 2); - expect(result).toEqual(expectedOutput); - }); - - it("handles a large number of points close together", () => { - const input = Array.from({ length: 10 }, (_, i) => ({ - key: i, - value: i * 5 + 10, - })); - const result = preventVerticalOverlap(input, 0, 100, 10, 2); - - for (let i = 1; i < result.length; i++) { - const difference = result[i].value - result[i - 1].value; - expect(difference).toBeGreaterThanOrEqual(12); - } - }); -}); diff --git a/web-common/src/components/data-graphic/marks/prevent-vertical-overlap.ts b/web-common/src/components/data-graphic/marks/prevent-vertical-overlap.ts deleted file mode 100644 index 1cd15fb2296..00000000000 --- a/web-common/src/components/data-graphic/marks/prevent-vertical-overlap.ts +++ /dev/null @@ -1,88 +0,0 @@ -/** This function implements a point-pushing algorithm that prevents - * y values from overlapping. It is used to prevent labels from - * overlapping in a data graphic. - * The algorithm is as follows: - * 1. Sort the points by y value. - * 2. If there is only one point, return it. - * 3. If there are no points, return an empty array. - * 4. Calculate the middle index. - * 5. Adjust the position of the points above the middle index. - * 6. Adjust the position of the top point and push down overlapping points. - * 7. Adjust the position of the points below the middle index. - * 8. Adjust the position of the bottom point and push up overlapping points. - * We can't claim that this works for every case, but it seems to work - * in most cases we care about. - */ -export function preventVerticalOverlap( - pt: { key: unknown; value: number }[], - topBoundary, - bottomBoundary, - elementHeight, - yBuffer, -): { key: unknown; value: number }[] { - // this is where the boundary condition lives. - - const locations = [...pt.map((p) => ({ ...p }))]; - // sort the locations by y value. - locations.sort((a, b) => a.value - b.value); - - if (locations.length === 1) { - return locations; - } - - if (!locations.length) return locations; - - // calculate the middle index. - const middle = Math.trunc(locations.length / 2); // eslint-disable-line - - // Adjust position of labels above the middle index - let i = middle; - while (i >= 0) { - if (i !== middle) { - const diff = locations[i + 1].value - locations[i].value; - if (diff <= elementHeight + yBuffer) { - locations[i].value -= elementHeight + yBuffer - diff; - } - } - i -= 1; - } - - // Adjust position of top label and push down overlapping labels - if (locations[0].value < topBoundary + yBuffer) { - locations[0].value = topBoundary + yBuffer; - i = 0; - while (i < middle) { - const diff = locations[i + 1].value - locations[i].value; - if (diff <= elementHeight + yBuffer) { - locations[i + 1].value += elementHeight + yBuffer - diff; - } - i += 1; - } - } - - // Adjust position of labels below the middle index - i = middle; - while (i < locations.length) { - if (i !== middle) { - const diff = locations[i].value - locations[i - 1].value; - if (diff < elementHeight + yBuffer) { - locations[i].value += elementHeight + yBuffer - diff; - } - } - i += 1; - } - - // Adjust position of bottom label and push up overlapping labels - if (locations[locations.length - 1].value > bottomBoundary - yBuffer) { - locations[locations.length - 1].value = bottomBoundary - yBuffer; - i = locations.length - 1; - while (i > 0) { - const diff = locations[i].value - locations[i - 1].value; - if (diff <= elementHeight + yBuffer) { - locations[i - 1].value -= elementHeight + yBuffer - diff; - } - i -= 1; - } - } - return locations; -} diff --git a/web-common/src/components/data-graphic/marks/segment.ts b/web-common/src/components/data-graphic/marks/segment.ts deleted file mode 100644 index 05236d14b80..00000000000 --- a/web-common/src/components/data-graphic/marks/segment.ts +++ /dev/null @@ -1,69 +0,0 @@ -function pathIsDefined(yAccessor: string) { - return (d: Record) => { - const val = d[yAccessor]; - return !( - val === undefined || - (typeof val === "number" && isNaN(val)) || - val === null - ); - }; -} - -/** - * Helper function to compute the contiguous segments of the data - * based on https://github.com/pbeshai/d3-line-chunked/blob/master/src/lineChunked.js - */ -export function computeSegments( - lineData: Record[], - yAccessor: string, -): Record[][] { - let startNewSegment = true; - - const defined = pathIsDefined(yAccessor); - // split into segments of continuous data - const segments = lineData.reduce( - (segments: Record[][], d) => { - // skip if this point has no data - if (!defined(d)) { - startNewSegment = true; - return segments; - } - - // if we are starting a new segment, start it with this point - if (startNewSegment) { - segments.push([d]); - startNewSegment = false; - - // otherwise see if we are adding to the last segment - } else { - const lastSegment = segments[segments.length - 1]; - lastSegment.push(d); - // if we expect this point to come next, add it to the segment - } - - return segments; - }, - [], - ); - - return segments; -} - -/** - * Compute the gaps from segments. Takes an array of segments and creates new segments - * based on the edges of adjacent segments. - * - * @param {Array} segments The segments array (e.g. from computeSegments) - * @return {Array} gaps The gaps array (same form as segments, but representing spaces between segments) - */ -export function gapsFromSegments(segments) { - const gaps = []; - for (let i = 0; i < segments.length - 1; i++) { - const currSegment = segments[i]; - const nextSegment = segments[i + 1]; - - gaps.push([currSegment[currSegment.length - 1], nextSegment[0]]); - } - - return gaps; -} diff --git a/web-common/src/components/data-graphic/marks/types.ts b/web-common/src/components/data-graphic/marks/types.ts deleted file mode 100644 index a3c60370901..00000000000 --- a/web-common/src/components/data-graphic/marks/types.ts +++ /dev/null @@ -1,23 +0,0 @@ -export interface Point { - x: number; - y: number; - value?: string; - label: string; - key: string; - valueColorClass?: string; - valueStyleClass?: string; - labelColorClass?: string; - labelStyleClass?: string; - pointColor?: string; - pointOpacity?: number; - yOverride?: boolean; - yOverrideLabel?: string; - yOverrideStyleClass?: string; -} - -export interface YValue { - y: string | number | Date | undefined | null; - name?: string | null; - color?: string; - isTimeComparison?: boolean; -} diff --git a/web-common/src/components/data-graphic/state/types.d.ts b/web-common/src/components/data-graphic/state/types.d.ts deleted file mode 100644 index ca070a34c21..00000000000 --- a/web-common/src/components/data-graphic/state/types.d.ts +++ /dev/null @@ -1,59 +0,0 @@ -import type { ScaleLinear, ScaleTime } from "d3-scale"; -import type { Readable } from "svelte/store"; - -export type GraphicScale = - | ScaleLinear - | ScaleTime; - -export interface ScaleStore extends Readable { - type: string; -} - -export interface SimpleDataGraphicConfigurationArguments { - id: string; - width: number; - height: number; - left: number; - right: number; - top: number; - bottom: number; - fontSize: number; - textGap: number; - xType: ScaleType; - yType: ScaleType; - xMin: number | Date; - xMax: number | Date; - yMin: number | Date; - yMax: number | Date; - bodyBuffer: number; - marginBuffer: number; - devicePixelRatio: number; -} - -export interface SimpleGraphicConfigurationDerivations { - bodyLeft: number; - bodyRight: number; - bodyTop: number; - bodyBottom: number; - plotLeft: number; - plotRight: number; - plotTop: number; - plotBottom: number; - graphicWidth: number; - graphicHeight: number; -} - -export interface SimpleDataGraphicConfiguration - extends SimpleDataGraphicConfigurationArguments, - SimpleGraphicConfigurationDerivations {} - -export interface CascadingContextStore - extends Readable { - hasParentCascade: boolean; - reconcileProps: (props: Arguments) => void; -} - -export type SimpleConfigurationStore = CascadingContextStore< - SimpleDataGraphicConfigurationArguments, - SimpleDataGraphicConfiguration ->; diff --git a/web-common/src/components/data-graphic/utils.ts b/web-common/src/components/data-graphic/utils.ts index 5f0a2874a24..ccd0bb68701 100644 --- a/web-common/src/components/data-graphic/utils.ts +++ b/web-common/src/components/data-graphic/utils.ts @@ -1,36 +1,57 @@ -import { bisector } from "d3-array"; -import type { ScaleLinear, ScaleTime } from "d3-scale"; -import { area, curveLinear, curveStep, line } from "d3-shape"; -import { timeFormat } from "d3-time-format"; -import { curveStepExtended } from "./marks/curveStepExtended"; +import type { NumericHistogramBinsBin } from "@rilldata/web-common/runtime-client"; +import { area, curveLinear, line } from "d3-shape"; +import type { Area, CurveFactory, Line } from "d3-shape"; /** - * Creates a string to be fed into the d attribute of a path, - * producing a single path definition for one circle. - * These completed, segmented arcs will not overlap in a way where - * we can overplot if part of the same path. + * Creates a configured d3 line generator. + * Defaults to curveLinear; pass `defined` to skip null/invalid points. */ -export function circlePath(cx: number, cy: number, r: number): string { - return ` - M ${cx - r}, ${cy} - a ${r},${r} 0 1,0 ${r * 2},0 - a ${r},${r} 0 1,0 ${-r * 2},0 - `; +export function createLineGenerator(args: { + x: (d: T, i: number, data: T[]) => number; + y: (d: T, i: number, data: T[]) => number; + defined?: (d: T, i: number, data: T[]) => boolean; + curve?: CurveFactory; +}): Line { + const gen = line() + .x(args.x) + .y(args.y) + .curve(args.curve ?? curveLinear); + if (args.defined) gen.defined(args.defined); + return gen; } -const curves = { - curveLinear, - curveStep, - curveStepExtended, -}; +/** + * Creates a configured d3 area generator. + * y0/y1 each accept a constant number or an accessor function. + * Defaults to curveLinear; pass `defined` to skip null/invalid points. + */ +export function createAreaGenerator(args: { + x: (d: T, i: number, data: T[]) => number; + y0: number | ((d: T, i: number, data: T[]) => number); + y1: number | ((d: T, i: number, data: T[]) => number); + defined?: (d: T, i: number, data: T[]) => boolean; + curve?: CurveFactory; +}): Area { + const gen = area() + .x(args.x) + .curve(args.curve ?? curveLinear); + if (typeof args.y0 === "number") gen.y0(args.y0); + else gen.y0(args.y0); + if (typeof args.y1 === "number") gen.y1(args.y1); + else gen.y1(args.y1); + if (args.defined) gen.defined(args.defined); + return gen; +} -export function pathDoesNotDropToZero(yAccessor: string) { - return (d, i: number, arr) => { +/** + * Filter predicate that removes consecutive zero values from a path. + * Zeroes are kept if at least one neighbor is non-zero. + */ +export function pathDoesNotDropToZero(yAccessor: keyof T) { + return (d: T, i: number, arr: T[]): boolean => { return ( - !isNaN(d[yAccessor]) && + (typeof d[yAccessor] !== "number" || !isNaN(d[yAccessor])) && d[yAccessor] !== undefined && - // remove all zeroes where the previous or next value is also zero. - // these do not add to our understanding. (!(i !== 0 && d[yAccessor] === 0 && arr[i - 1][yAccessor] === 0) || !( i !== arr.length - 1 && @@ -41,71 +62,6 @@ export function pathDoesNotDropToZero(yAccessor: string) { }; } -export interface PlotConfig { - top: number; - bottom: number; - left: number; - right: number; - buffer: number; - width: number; - height: number; - devicePixelRatio: number; - plotTop: number; - plotBottom: number; - plotLeft: number; - plotRight: number; - fontSize: number; - textGap: number; - id: string; -} - -interface LineGeneratorArguments { - xAccessor: string; - xScale: - | ScaleLinear - | ScaleTime - | ((d) => number); - yScale: - | ScaleLinear - | ScaleTime - | ((d) => number); - curve?: string; - pathDefined?: ( - datum: object, - i?: number, - arr?: ArrayLike, - ) => boolean; -} - -/** - * A convenience function to generate a nice SVG path for a time series. - * FIXME: rename to timeSeriesLineFactory. - * FIXME: once we've gotten the data generics in place and threaded into components, let's make sure to type this. - */ -export function lineFactory(args: LineGeneratorArguments) { - return (yAccessor: string) => - line() - .x((d) => args.xScale(d[args.xAccessor])) - .y((d) => args.yScale(d[yAccessor])) - .curve(args.curve ? curves[args.curve] : curveLinear) - .defined(args.pathDefined || pathDoesNotDropToZero(yAccessor)); -} - -/** - * A convenience function to generate a nice SVG area path for a time series. - * FIXME: rename to timeSeriesAreaFactory. - * FIXME: once we've gotten the data generics in place and threaded into components, let's make sure to type this. - */ -export function areaFactory(args: LineGeneratorArguments) { - return (yAccessor: string) => - area() - .x((d) => args.xScale(d[args.xAccessor])) - .y0(args.yScale(0)) - .y1((d) => args.yScale(d[yAccessor])) - .curve(args.curve ? curves[args.curve] : curveLinear) - .defined(args.pathDefined || pathDoesNotDropToZero(yAccessor)); -} - /** * Generates an SVG path string for a histogram / bar plot. * Each bin is defined by a low/high x range and a y count value. @@ -113,11 +69,7 @@ export function areaFactory(args: LineGeneratorArguments) { * both fill and stroke rendering. */ export function barplotPolyline( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - data: Record[], - xLow: string, - xHigh: string, - yAccessor: string, + data: NumericHistogramBinsBin[], xScale: (v: number) => number, yScale: (v: number) => number, separator = 1, @@ -129,11 +81,11 @@ export function barplotPolyline( const baseline = yScale(0); const path = data.reduce((acc: string, datum, i) => { - const count = datum[yAccessor] ?? 0; + const count = datum.count ?? 0; if (count === 0) return acc; - const low = datum[xLow] ?? 0; - const high = datum[xHigh] ?? 0; + const low = datum.low ?? 0; + const high = datum.high ?? 0; const x = xScale(low) + separator; const width = Math.max(0.5, xScale(high) - xScale(low) - separator * 2); const y = baseline * (1 - inflator) + yScale(count) * inflator; @@ -143,8 +95,8 @@ export function barplotPolyline( ); const dropHeight = separator > 0 ? barHeight : 0; - const prevIsZero = i > 0 && !data[i - 1][yAccessor]; - const nextIsZero = i < data.length - 1 && !data[i + 1][yAccessor]; + const prevIsZero = i > 0 && !data[i - 1].count; + const nextIsZero = i < data.length - 1 && !data[i + 1].count; // Move to the bottom-left of this bar let move: string; @@ -167,62 +119,10 @@ export function barplotPolyline( return acc + `${move} ${topLeft} ${topRight} ${bottomRight} ${close} `; }, " "); - const lastNonZero = data.findLast((d) => d[yAccessor]); + const lastNonZero = data.findLast((d) => d.count); if (!lastNonZero) return ""; - const startX = xScale(data[0][xLow] ?? 0) + separator; - const endX = xScale(lastNonZero[xHigh] ?? 0) - separator; + const startX = xScale(data[0].low ?? 0) + separator; + const endX = xScale(lastNonZero.high ?? 0) - separator; return `M${startX},${baseline} ${path} ${endX},${baseline} `; } - -// This is function equivalent of WithBisector -export function bisectData( - value: Date, - direction: "left" | "right" | "center", - accessor: keyof T, - data: ArrayLike, -): { position: number; entry: T } { - const bisect = bisector((d) => d[accessor])[direction]; - const position = bisect(data, value); - - return { - position, - entry: data[position], - }; -} - -/** For a scale domain returns a formatter for axis label and super label */ -export function createTimeFormat( - scaleDomain: [Date, Date], - numberOfValues: number, -): [(d: Date) => string, ((d: Date) => string) | undefined] { - const diff = - Math.abs(scaleDomain[1]?.getTime() - scaleDomain[0]?.getTime()) / 1000; - if (!diff) return [timeFormat("%d %b"), timeFormat("%Y")]; - const gap = diff / (numberOfValues - 1); // time gap between two consecutive values - - // If the gap is less than a second, format in milliseconds - if (gap < 1) { - return [timeFormat("%M:%S.%L"), timeFormat("%H %d %b %Y")]; - } - // If the gap is less than a minute, format in seconds - else if (gap < 60) { - return [timeFormat("%M:%S"), timeFormat("%H %d %b %Y")]; - } - // If the gap is less than 24 hours, format in hours and minutes - else if (gap < 60 * 60 * 24) { - return [timeFormat("%H:%M"), timeFormat("%d %b %Y")]; - } - // If the gap is less than 30 days, format in days - else if (gap < 60 * 60 * 24 * 30) { - return [timeFormat("%b %d"), timeFormat("%Y")]; - } - // If the gap is less than a year, format in months - else if (gap < 60 * 60 * 24 * 365) { - return [timeFormat("%b"), timeFormat("%Y")]; - } - // Else format in years - else { - return [timeFormat("%Y"), undefined]; - } -} diff --git a/web-common/src/components/time-series-chart/Line.svelte b/web-common/src/components/time-series-chart/Line.svelte index cd2018e3535..00122a09849 100644 --- a/web-common/src/components/time-series-chart/Line.svelte +++ b/web-common/src/components/time-series-chart/Line.svelte @@ -1,11 +1,14 @@ {#if fill} diff --git a/web-common/src/components/time-series-chart/TimeSeriesChart.svelte b/web-common/src/components/time-series-chart/TimeSeriesChart.svelte index 92779f6e392..41028993fbb 100644 --- a/web-common/src/components/time-series-chart/TimeSeriesChart.svelte +++ b/web-common/src/components/time-series-chart/TimeSeriesChart.svelte @@ -1,5 +1,8 @@ {#if data} @@ -55,17 +45,21 @@ - - + + - + diff --git a/web-common/src/features/column-profile/queries.ts b/web-common/src/features/column-profile/queries.ts index c004e29624a..a186b2f66c6 100644 --- a/web-common/src/features/column-profile/queries.ts +++ b/web-common/src/features/column-profile/queries.ts @@ -1,4 +1,3 @@ -import { convertTimestampPreview } from "@rilldata/web-common/lib/convertTimestampPreview"; import { createQueryServiceColumnCardinality, createQueryServiceColumnNullCount, @@ -11,6 +10,7 @@ import { QueryServiceColumnNumericHistogramHistogramMethod, type V1ProfileColumn, type V1TableColumnsResponse, + type V1TimeSeriesValue, } from "@rilldata/web-common/runtime-client"; import type { HTTPError } from "@rilldata/web-common/runtime-client/fetchWrapper"; import { getPriorityForColumn } from "@rilldata/web-common/runtime-client/http-request-queue/priorities"; @@ -228,6 +228,21 @@ export function getTopK( }); } +function convertPoint(point: V1TimeSeriesValue) { + const next = { + ...point, + count: point?.records?.count as number, + ts: point.ts ? new Date(point.ts) : new Date(0), + }; + if (next.count == null || !isFinite(next.count)) { + next.count = 0; + } + + return next; +} + +export type TimestampDataPoint = ReturnType; + export function getTimeSeriesAndSpark( instanceId: string, connector: string, @@ -296,28 +311,15 @@ export function getTimeSeriesAndSpark( return derived( [query, estimatedInterval, smallestTimeGrain], ([$query, $estimatedInterval, $smallestTimeGrain]) => { + const data = $query?.data?.rollup?.results?.map(convertPoint) || []; + + const spark = $query?.data?.rollup?.spark?.map(convertPoint) || []; return { isFetching: $query?.isFetching, estimatedRollupInterval: $estimatedInterval?.data, smallestTimegrain: $smallestTimeGrain?.data?.timeGrain, - data: convertTimestampPreview( - $query?.data?.rollup?.results?.map((di) => { - const next = { ...di, count: di?.records?.count as number }; - if (next.count == null || !isFinite(next.count)) { - next.count = 0; - } - return next; - }) || [], - ), - spark: convertTimestampPreview( - $query?.data?.rollup?.spark?.map((di) => { - const next = { ...di, count: di?.records?.count as number }; - if (next.count == null || !isFinite(next.count)) { - next.count = 0; - } - return next; - }) || [], - ), + data, + spark, }; }, ); diff --git a/web-common/src/features/dashboards/big-number/MeasureBigNumber.svelte b/web-common/src/features/dashboards/big-number/MeasureBigNumber.svelte index e6ff34d10a7..e9678818ce4 100644 --- a/web-common/src/features/dashboards/big-number/MeasureBigNumber.svelte +++ b/web-common/src/features/dashboards/big-number/MeasureBigNumber.svelte @@ -1,5 +1,5 @@ {#if stacked} diff --git a/web-common/src/features/dashboards/time-series/measure-chart/ScrubController.ts b/web-common/src/features/dashboards/time-series/measure-chart/ScrubController.ts index 00351e0f8cd..64e7eb804df 100644 --- a/web-common/src/features/dashboards/time-series/measure-chart/ScrubController.ts +++ b/web-common/src/features/dashboards/time-series/measure-chart/ScrubController.ts @@ -2,7 +2,6 @@ import { writable, get, type Readable, type Writable } from "svelte/store"; import type { ScrubState } from "./types"; import type { ScaleLinear } from "d3-scale"; - type ScrubMode = "none" | "create" | "resize-start" | "resize-end" | "move"; const EMPTY_SCRUB: ScrubState = { From 222cba7eb5e8015bf602c8bf92561133001b57c2 Mon Sep 17 00:00:00 2001 From: Brian Holmes Date: Wed, 4 Feb 2026 12:47:04 -0500 Subject: [PATCH 14/36] clean up, add tests --- .../timestamp-profile/TimestampDetail.svelte | 8 +- .../timestamp-profile/TimestampSpark.svelte | 5 +- .../src/components/data-graphic/utils.ts | 2 + .../components/time-series-chart/Line.svelte | 10 +- .../column-types/details/NumericPlot.svelte | 2 +- .../column-types/sparks/NumericSpark.svelte | 2 +- .../time-series/ChartInteractions.svelte | 4 +- .../MetricsTimeSeriesCharts.svelte | 171 ++++++++-------- .../measure-chart/ComparisonTooltip.svelte | 24 +++ .../measure-chart/MeasureChart.svelte | 167 ++++----------- .../MeasureChartAnnotationMarkers.svelte | 6 +- .../measure-chart/MeasureChartXAxis.svelte | 192 ++++++++++++++++++ .../measure-chart/annotation-utils.ts | 19 +- .../time-series/measure-chart/index.ts | 1 + .../time-series/measure-chart/types.ts | 10 + .../measure-chart/use-dimension-data.ts | 7 +- web-local/tests/explores/scrub.spec.ts | 153 ++++++++++++++ 17 files changed, 555 insertions(+), 228 deletions(-) create mode 100644 web-common/src/features/dashboards/time-series/measure-chart/ComparisonTooltip.svelte create mode 100644 web-common/src/features/dashboards/time-series/measure-chart/MeasureChartXAxis.svelte create mode 100644 web-local/tests/explores/scrub.spec.ts diff --git a/web-common/src/components/data-graphic/compositions/timestamp-profile/TimestampDetail.svelte b/web-common/src/components/data-graphic/compositions/timestamp-profile/TimestampDetail.svelte index 28d746a2e5e..f48756cb4b1 100644 --- a/web-common/src/components/data-graphic/compositions/timestamp-profile/TimestampDetail.svelte +++ b/web-common/src/components/data-graphic/compositions/timestamp-profile/TimestampDetail.svelte @@ -75,7 +75,11 @@ Uses index-based scales and TimeSeriesChart for rendering. $: plotWidth = plotRight - plotLeft; // Zoom resets when data changes - $: zoomEndIdx = data.length - 1; + $: if (data) { + zoomStartIdx = 0; + zoomEndIdx = data.length - 1; + isZoomed = false; + } // Scales $: xScale = scaleLinear() @@ -114,7 +118,7 @@ Uses index-based scales and TimeSeriesChart for rendering. $: opacity = Math.min( 1, - 1 + (plotWidth * devicePixelRatio) / dataWindow.length / 2, + (plotWidth * devicePixelRatio) / dataWindow.length / 2, ); $: chartSeries = [ diff --git a/web-common/src/components/data-graphic/compositions/timestamp-profile/TimestampSpark.svelte b/web-common/src/components/data-graphic/compositions/timestamp-profile/TimestampSpark.svelte index e609f5a3365..a896d6d1f25 100644 --- a/web-common/src/components/data-graphic/compositions/timestamp-profile/TimestampSpark.svelte +++ b/web-common/src/components/data-graphic/compositions/timestamp-profile/TimestampSpark.svelte @@ -81,9 +81,8 @@ duration, easing, css: (_t: number, u: number) => { - const yScaleDown = ` scaleY(${1 - sd * u})`; return ` - transform: ${transform} scaleY(${1 - sd * u}) ${yScaleDown}; + transform: ${transform} scaleY(${1 - sd * u}); transform-origin: 100% calc(100% - ${0}px); opacity: ${target_opacity - od * u} `; @@ -114,7 +113,7 @@ x={xScale(zoomWindowXMin)} y={plotTop} width={xScale(zoomWindowXMax) - xScale(zoomWindowXMin)} - {height} + height={plotBottom - plotTop} fill={zoomWindowColor} opacity=".9" style:mix-blend-mode="lighten" diff --git a/web-common/src/components/data-graphic/utils.ts b/web-common/src/components/data-graphic/utils.ts index ccd0bb68701..c9d0db1655d 100644 --- a/web-common/src/components/data-graphic/utils.ts +++ b/web-common/src/components/data-graphic/utils.ts @@ -35,6 +35,8 @@ export function createAreaGenerator(args: { const gen = area() .x(args.x) .curve(args.curve ?? curveLinear); + // The typeof narrowing is required: d3's .y0()/.y1() have separate overloads + // for number vs function, and TypeScript won't accept the union directly. if (typeof args.y0 === "number") gen.y0(args.y0); else gen.y0(args.y0); if (typeof args.y1 === "number") gen.y1(args.y1); diff --git a/web-common/src/components/time-series-chart/Line.svelte b/web-common/src/components/time-series-chart/Line.svelte index 00122a09849..361238e2bff 100644 --- a/web-common/src/components/time-series-chart/Line.svelte +++ b/web-common/src/components/time-series-chart/Line.svelte @@ -18,6 +18,8 @@ export let strokeWidth = 4; export let fill: boolean | undefined; + const gradientId = `chart-gradient-${Math.random().toString(36).slice(2, 11)}`; + $: lineFunction = createLineGenerator({ x: (d) => xScale(d.index), y: (d) => yScale(d.value as number), @@ -26,8 +28,8 @@ $: areaFunction = createAreaGenerator({ x: (d) => xScale(d.index), - y0: (d) => yScale(d.value as number), - y1: yScale.range()[0], + y0: yScale.range()[0], + y1: (d) => yScale(d.value as number), defined: (d) => d.value !== null && d.value !== undefined, }); @@ -37,10 +39,10 @@ {#if fill} - + - + - + - + button { @apply border rounded-[2px] bg-surface-subtle pointer-events-auto; - @apply absolute left-1/2 -top-8 -translate-x-1/2 z-50; + @apply absolute top-0 -translate-x-1/2 z-50; + /* Center over the plot body, not the full chart (40px right margin for y-axis labels) */ + left: calc(50% - 20px); } .content-wrapper { diff --git a/web-common/src/features/dashboards/time-series/MetricsTimeSeriesCharts.svelte b/web-common/src/features/dashboards/time-series/MetricsTimeSeriesCharts.svelte index 8ab1a3ecb45..f42443b62ac 100644 --- a/web-common/src/features/dashboards/time-series/MetricsTimeSeriesCharts.svelte +++ b/web-common/src/features/dashboards/time-series/MetricsTimeSeriesCharts.svelte @@ -18,7 +18,6 @@ import { TDDChart } from "@rilldata/web-common/features/dashboards/time-dimension-details/types"; import BackToExplore from "@rilldata/web-common/features/dashboards/time-series/BackToExplore.svelte"; import { measureSelection } from "@rilldata/web-common/features/dashboards/time-series/measure-selection/measure-selection.ts"; - import { EntityStatus } from "@rilldata/web-common/features/entity-management/types"; import { V1TimeGrainToDateTimeUnit } from "@rilldata/web-common/lib/time/new-grains"; import { TimeRangePreset, @@ -31,10 +30,12 @@ import Pivot from "../../../components/icons/Pivot.svelte"; import { TIME_GRAIN } from "../../../lib/time/config"; import { DashboardState_ActivePage } from "../../../proto/gen/rill/ui/v1/dashboard_pb"; + import { EntityStatus } from "@rilldata/web-common/features/entity-management/types"; import Spinner from "../../entity-management/Spinner.svelte"; import { featureFlags } from "../../feature-flags"; import MeasureBigNumber from "../big-number/MeasureBigNumber.svelte"; import { MeasureChart } from "./measure-chart"; + import MeasureChartXAxis from "./measure-chart/MeasureChartXAxis.svelte"; import { ScrubController } from "./measure-chart/ScrubController"; import { getAnnotationsForMeasure } from "./annotations-selectors"; import ChartInteractions from "./ChartInteractions.svelte"; @@ -194,6 +195,7 @@ let showReplacePivotModal = false; function startPivotForTimeseries() { const pivot = $exploreState?.pivot; + if (!pivot) return; const pivotColumns = splitPivotChips(pivot.columns); if ( pivot.rows.length || @@ -329,95 +331,104 @@
{#if renderedMeasures} -
- -
- {#each renderedMeasures as measure, i (measure.name)} -
- - - {#if activeTimeGrain} - +
+
+ handlePan("left")} - onPanRight={() => handlePan("right")} + /> + { - if (dt) { - // Convert to JS Date matching table's timezone handling: - // keepLocalTime: true preserves wall clock time when shifting to system zone - const systemTimeZone = - Intl.DateTimeFormat().resolvedOptions().timeZone; - chartHoveredTime.set( - dt - .setZone(systemTimeZone, { keepLocalTime: true }) - .toJSDate(), - ); - } else { - chartHoveredTime.set(undefined); - } - }} - onScrub={handleScrub} - onScrubClear={() => { - metricsExplorerStore.setSelectedScrubRange( - exploreName, - undefined, - ); - }} + timeGrain={activeTimeGrain} /> - {:else} -
- -
- {/if} +
+ {/if} + + {#each renderedMeasures as measure (measure.name)} + + + {#if activeTimeGrain} + handlePan("left")} + onPanRight={() => handlePan("right")} + {showComparison} + {showTimeDimensionDetail} + {tableHoverTime} + onHover={(dt) => { + if (dt) { + const systemTimeZone = + Intl.DateTimeFormat().resolvedOptions().timeZone; + chartHoveredTime.set( + dt + .setZone(systemTimeZone, { keepLocalTime: true }) + .toJSDate(), + ); + } else { + chartHoveredTime.set(undefined); + } + }} + onScrub={handleScrub} + onScrubClear={() => { + metricsExplorerStore.setSelectedScrubRange( + exploreName, + undefined, + ); + }} + /> + {:else} +
+ +
+ {/if} {/each}
{/if} diff --git a/web-common/src/features/dashboards/time-series/measure-chart/ComparisonTooltip.svelte b/web-common/src/features/dashboards/time-series/measure-chart/ComparisonTooltip.svelte new file mode 100644 index 00000000000..1f8e687ebf4 --- /dev/null +++ b/web-common/src/features/dashboards/time-series/measure-chart/ComparisonTooltip.svelte @@ -0,0 +1,24 @@ + + + + + {valueFormatter(tooltipCurrentValue)} + + + vs {valueFormatter(tooltipComparisonValue)} + + {#if showDelta} + + ({tooltipDeltaPositive ? "+" : ""}{tooltipDeltaLabel}) + + {/if} + diff --git a/web-common/src/features/dashboards/time-series/measure-chart/MeasureChart.svelte b/web-common/src/features/dashboards/time-series/measure-chart/MeasureChart.svelte index f883955329b..7a937aeb647 100644 --- a/web-common/src/features/dashboards/time-series/measure-chart/MeasureChart.svelte +++ b/web-common/src/features/dashboards/time-series/measure-chart/MeasureChart.svelte @@ -61,14 +61,13 @@ import MeasureChartAnnotationPopover from "./MeasureChartAnnotationPopover.svelte"; import { measureSelection } from "../measure-selection/measure-selection"; import { formatDateTimeByGrain } from "@rilldata/web-common/lib/time/ranges/formatter"; - import { V1TimeGrainToOrder } from "@rilldata/web-common/lib/time/new-grains"; import { formatMeasurePercentageDifference } from "@rilldata/web-common/lib/number-formatting/percentage-formatter"; import { numberPartsToString } from "@rilldata/web-common/lib/number-formatting/utils/number-parts-utils"; import { snapIndex, dateToIndex } from "./utils"; + import ComparisonTooltip from "./ComparisonTooltip.svelte"; const chartId = Math.random().toString(36).slice(2, 11); - const Y_DASH_ARRAY = "1,1"; - const DAY_GRAIN_ORDER = V1TimeGrainToOrder[V1TimeGrain.TIME_GRAIN_DAY]; + const Y_DASH_ARRAY = "1,1.5"; const X_PAD = 8; const CLICK_THRESHOLD_PX = 4; // Max mouse movement to still count as a click const LINE_MODE_MIN_POINTS = 6; // Minimum data points to show line instead of bar @@ -120,7 +119,6 @@ export let onPanLeft: (() => void) | undefined = undefined; export let onPanRight: (() => void) | undefined = undefined; export let scrubController: ScrubController; - export let showAxis: boolean = false; let container: HTMLDivElement; let clientWidth = 425; @@ -319,67 +317,6 @@ : dataLastIndex >= 1 ? [0, dataLastIndex] : [0]; - // Sub-day grains (hour, minute, etc.) show time + date on separate lines - $: isSubDayGrain = timeGranularity - ? V1TimeGrainToOrder[timeGranularity] < DAY_GRAIN_ORDER - : false; - $: axisHeight = isSubDayGrain ? 26 : 16; - $: axisTicks = buildAxisTicks(xTickIndices, data, mode, isSubDayGrain); - - function buildAxisTicks( - indices: number[], - d: TimeSeriesPoint[], - m: ChartMode, - subDay: boolean, - ) { - return indices.map((idx, i) => { - const dt = d[idx]?.ts; - const anchor = - m === "bar" - ? "middle" - : i === 0 - ? "start" - : i === indices.length - 1 - ? "end" - : "middle"; - - if (!dt) return { x: xScale(idx), anchor, timeLine: "", dateLine: "" }; - - if (subDay) { - const grainOrder = V1TimeGrainToOrder[timeGranularity!]; - const fmt: Intl.DateTimeFormatOptions = { - hour: "numeric", - hour12: true, - }; - if (grainOrder < 1) fmt.minute = "2-digit"; - const timeLine = dt.toLocaleString(fmt); - - const prevDt = i > 0 ? d[indices[i - 1]]?.ts : undefined; - const showDate = - !prevDt || - dt.day !== prevDt.day || - dt.month !== prevDt.month || - dt.year !== prevDt.year; - const dateFmt: Intl.DateTimeFormatOptions = { - month: "short", - day: "numeric", - }; - if (d[0]?.ts.year !== d[d.length - 1]?.ts.year) - dateFmt.year = "numeric"; - const dateLine = showDate ? dt.toLocaleString(dateFmt) : ""; - - return { x: xScale(idx), anchor, timeLine, dateLine }; - } - - return { - x: xScale(idx), - anchor, - timeLine: formatDateTimeByGrain(dt, timeGranularity), - dateLine: "", - }; - }); - } - // Keep scrub controller in sync with data length $: scrubController.setDataLength(data.length); @@ -584,14 +521,35 @@ function checkAnnotationHover(e: MouseEvent) { if (isScrubbing || annotationGroups.length === 0) { - hoveredAnnotationGroup = null; + scheduleAnnotationClear(); return; } const svg = e.currentTarget as SVGSVGElement; const rect = svg.getBoundingClientRect(); const mouseX = e.clientX - rect.left; const mouseY = e.clientY - rect.top; - hoveredAnnotationGroup = findHoveredGroup(annotationGroups, mouseX, mouseY); + const hit = findHoveredGroup(annotationGroups, mouseX, mouseY); + if (hit) { + // Direct hit on a diamond — show immediately, cancel any pending clear + if (annotationPopoverTimeout) { + clearTimeout(annotationPopoverTimeout); + annotationPopoverTimeout = null; + } + hoveredAnnotationGroup = hit; + } else if (hoveredAnnotationGroup && !annotationPopoverHovered) { + // Mouse moved off diamond but popover isn't hovered — schedule clear + scheduleAnnotationClear(); + } + } + + function scheduleAnnotationClear() { + if (annotationPopoverHovered || annotationPopoverTimeout) return; + annotationPopoverTimeout = setTimeout(() => { + if (!annotationPopoverHovered) { + hoveredAnnotationGroup = null; + } + annotationPopoverTimeout = null; + }, ANNOTATION_POPOVER_DELAY_MS); } function handleAnnotationPopoverHover(hovered: boolean) { @@ -601,12 +559,7 @@ annotationPopoverTimeout = null; } if (!hovered) { - annotationPopoverTimeout = setTimeout(() => { - if (!annotationPopoverHovered) { - hoveredAnnotationGroup = null; - } - annotationPopoverTimeout = null; - }, ANNOTATION_POPOVER_DELAY_MS); + scheduleAnnotationClear(); } } @@ -614,9 +567,8 @@ hoverState.set(EMPTY_HOVER); mousePageX = null; mousePageY = null; - if (!annotationPopoverHovered) { - hoveredAnnotationGroup = null; - } + // Don't immediately clear — give time to reach the popover + scheduleAnnotationClear(); } function handleMouseDown(e: MouseEvent) { @@ -795,33 +747,8 @@ />
{:else if data.length > 0} - {#if showAxis} - - {#each axisTicks as tick, tickIdx (tickIdx)} - - {tick.timeLine} - - {#if tick.dateLine} - - {tick.dateLine} - - {/if} - {/each} - - {/if} - {/each} @@ -897,7 +824,7 @@ x2={xScale(idx)} y1={config.plotBounds.top} y2={config.plotBounds.top + config.plotBounds.height} - stroke-width="0.5" + stroke-width="0.75" stroke-dasharray={Y_DASH_ARRAY} /> {/each} @@ -958,7 +885,7 @@ {@const showDelta = showComparison && tooltipComparisonValue !== null && - tooltipDeltaLabel} + !!tooltipDeltaLabel} - {#if showComparison} - - - {valueFormatter(tooltipCurrentValue)} - - - vs {valueFormatter(tooltipComparisonValue)} - - {#if showDelta} - - ({tooltipDeltaPositive ? "+" : ""}{tooltipDeltaLabel}) - - {/if} - + y={config.plotBounds.top + 22} + {tooltipCurrentValue} + {tooltipComparisonValue} + {tooltipDeltaLabel} + {tooltipDeltaPositive} + {showDelta} + {valueFormatter} + /> {/if} {/if} diff --git a/web-common/src/features/dashboards/time-series/measure-chart/MeasureChartAnnotationMarkers.svelte b/web-common/src/features/dashboards/time-series/measure-chart/MeasureChartAnnotationMarkers.svelte index b78b2bc9ecc..d7b8cf1b7d4 100644 --- a/web-common/src/features/dashboards/time-series/measure-chart/MeasureChartAnnotationMarkers.svelte +++ b/web-common/src/features/dashboards/time-series/measure-chart/MeasureChartAnnotationMarkers.svelte @@ -24,7 +24,7 @@ $: halfSize = (AnnotationWidth / 2) * 0.7; -{#each groups as group (group.index)} +{#each groups as group (group.key)} {@const hovered = hoveredGroup === group} {@const cx = group.left} {@const cy = group.top + AnnotationHeight / 2} @@ -34,7 +34,9 @@ width={halfSize * 2} height={halfSize * 2} fill={AnnotationDiamondColor} - opacity={hovered ? 1 : 0.4} + stroke="white" + stroke-width={1} + opacity={hovered ? 1 : 0.7} transform="rotate(45 {cx} {cy})" /> {/each} diff --git a/web-common/src/features/dashboards/time-series/measure-chart/MeasureChartXAxis.svelte b/web-common/src/features/dashboards/time-series/measure-chart/MeasureChartXAxis.svelte new file mode 100644 index 00000000000..3366bf9487a --- /dev/null +++ b/web-common/src/features/dashboards/time-series/measure-chart/MeasureChartXAxis.svelte @@ -0,0 +1,192 @@ + + +
+ {#if bins.length > 0 && clientWidth > 0} + + {#each ticks as tick, tickIdx (tickIdx)} + + {tick.timeLine} + + {#if tick.dateLine} + + {tick.dateLine} + + {/if} + {/each} + + {/if} +
diff --git a/web-common/src/features/dashboards/time-series/measure-chart/annotation-utils.ts b/web-common/src/features/dashboards/time-series/measure-chart/annotation-utils.ts index a0b1a6f5c7e..7b9b23eb068 100644 --- a/web-common/src/features/dashboards/time-series/measure-chart/annotation-utils.ts +++ b/web-common/src/features/dashboards/time-series/measure-chart/annotation-utils.ts @@ -7,6 +7,8 @@ import { dateToIndex } from "./utils"; export type AnnotationGroup = { items: Annotation[]; + /** Unique key for this group (grain-truncated ISO string) */ + key: string; /** Data index this group maps to */ index: number; /** Pixel x of the group diamond */ @@ -66,12 +68,18 @@ export function groupAnnotations( // Convert buckets to groups with pixel positions const groups: AnnotationGroup[] = []; - for (const [, bucket] of buckets) { + // Data time range — annotations outside this are not visible + const dataStartMs = data[0].ts.toMillis(); + const dataEndMs = data[data.length - 1].ts.toMillis(); + + for (const [bucketKey, bucket] of buckets) { // Use the first annotation's startTime for positioning - const startIdx = dateToIndex( - data, - bucket.annotations[0].startTime.getTime(), - ); + const annotationMs = bucket.annotations[0].startTime.getTime(); + + // Skip annotations whose start falls outside the data range + if (annotationMs < dataStartMs || annotationMs > dataEndMs) continue; + + const startIdx = dateToIndex(data, annotationMs); if (startIdx === null) continue; const left = scales.x(startIdx); @@ -97,6 +105,7 @@ export function groupAnnotations( groups.push({ items: bucket.annotations, + key: bucketKey, index: startIdx, left, right: Math.min(right, config.plotBounds.left + config.plotBounds.width), diff --git a/web-common/src/features/dashboards/time-series/measure-chart/index.ts b/web-common/src/features/dashboards/time-series/measure-chart/index.ts index 65f15ef19f8..085494b495f 100644 --- a/web-common/src/features/dashboards/time-series/measure-chart/index.ts +++ b/web-common/src/features/dashboards/time-series/measure-chart/index.ts @@ -1 +1,2 @@ export { default as MeasureChart } from "./MeasureChart.svelte"; +export { default as MeasureChartXAxis } from "./MeasureChartXAxis.svelte"; diff --git a/web-common/src/features/dashboards/time-series/measure-chart/types.ts b/web-common/src/features/dashboards/time-series/measure-chart/types.ts index ccb97251142..71d4d73eee6 100644 --- a/web-common/src/features/dashboards/time-series/measure-chart/types.ts +++ b/web-common/src/features/dashboards/time-series/measure-chart/types.ts @@ -118,6 +118,16 @@ export interface ChartSeries { strokeWidth?: number; } +/** + * A single tick on the x-axis. + */ +export interface AxisTick { + x: number; + anchor: string; + timeLine: string; + dateLine: string; +} + /** * Rendering mode for TimeSeriesChart. */ diff --git a/web-common/src/features/dashboards/time-series/measure-chart/use-dimension-data.ts b/web-common/src/features/dashboards/time-series/measure-chart/use-dimension-data.ts index ae312e6378d..c548efe2d29 100644 --- a/web-common/src/features/dashboards/time-series/measure-chart/use-dimension-data.ts +++ b/web-common/src/features/dashboards/time-series/measure-chart/use-dimension-data.ts @@ -42,12 +42,13 @@ export function createDimensionAggregationQuery( timeZone: string, enabled: boolean, ): CreateQueryResult { - const updatedFilter = where + const baseFilter = where ? (filterExpressions(where, () => true) ?? createAndExpression([])) : createAndExpression([]); - updatedFilter.cond?.exprs?.push( + const updatedFilter = createAndExpression([ + ...(baseFilter.cond?.exprs ?? []), createInExpression(dimensionName, dimensionValues), - ); + ]); return createQueryServiceMetricsViewAggregation( instanceId, diff --git a/web-local/tests/explores/scrub.spec.ts b/web-local/tests/explores/scrub.spec.ts new file mode 100644 index 00000000000..1f43ee81b04 --- /dev/null +++ b/web-local/tests/explores/scrub.spec.ts @@ -0,0 +1,153 @@ +import { expect, type Page } from "@playwright/test"; +import { test } from "../setup/base"; +import { gotoNavEntry } from "../utils/waitHelpers"; +import { interactWithTimeRangeMenu } from "@rilldata/web-common/tests/utils/explore-interactions"; + +async function setupDashboard(page: Page) { + await page.getByLabel("/dashboards").click(); + await gotoNavEntry(page, "/dashboards/AdBids_metrics_explore.yaml"); + + const bigNumber = page + .locator(".big-number") + .filter({ hasText: "Total records" }); + await expect(bigNumber).toBeVisible({ timeout: 10_000 }); + + await page.getByRole("button", { name: "Preview" }).click(); + await expect(bigNumber).toBeVisible({ timeout: 10_000 }); + + await interactWithTimeRangeMenu(page, async () => { + await page.getByRole("menuitem", { name: "All Time" }).click(); + }); + await page.waitForTimeout(1000); + + const valueLocator = bigNumber.locator('div[role="button"]'); + await expect(valueLocator).toBeVisible({ timeout: 5000 }); + + const chartSvg = page.locator('svg[aria-label*="Measure Chart"]').first(); + await expect(chartSvg).toBeVisible({ timeout: 5000 }); + const box = await chartSvg.boundingBox(); + expect(box).toBeTruthy(); + + return { valueLocator, chartSvg, box: box! }; +} + +/** Drag across startPct–endPct (0–1) of chart width to create a scrub selection. */ +async function scrub( + page: Page, + box: { x: number; y: number; width: number; height: number }, + startPct: number, + endPct: number, +) { + const startX = box.x + box.width * startPct; + const endX = box.x + box.width * endPct; + const centerY = box.y + box.height / 2; + + await page.mouse.move(startX, centerY); + await page.mouse.down(); + await page.mouse.move(endX, centerY, { steps: 15 }); + await page.mouse.up(); + await page.waitForTimeout(1500); +} + +test.describe("chart scrub and zoom", () => { + test.use({ project: "AdBids" }); + + test("scrub selection updates big number, zoom changes time range", async ({ + page, + }) => { + const { valueLocator, box } = await setupDashboard(page); + const initialValue = await valueLocator.textContent(); + + await scrub(page, box, 0.2, 0.6); + + const scrubValue = await valueLocator.textContent(); + expect(scrubValue).toBeTruthy(); + expect(scrubValue).not.toBe(initialValue); + + await expect(page.getByLabel("Zoom")).toBeVisible({ timeout: 3000 }); + + await page.keyboard.press("z"); + await page.waitForTimeout(1500); + + await expect(page.getByLabel("Undo zoom")).toBeVisible({ timeout: 3000 }); + const timeRangeText = await page + .getByLabel("Select time range") + .textContent(); + expect(timeRangeText).toContain("Custom"); + + const zoomedValue = await valueLocator.textContent(); + expect(zoomedValue).toBe(scrubValue); + }); + + test("move scrub range updates big number", async ({ page }) => { + const { valueLocator, box } = await setupDashboard(page); + + await scrub(page, box, 0.2, 0.5); + const scrubValue = await valueLocator.textContent(); + expect(scrubValue).toBeTruthy(); + + // Grab center of selection (35%) and drag right to 65% + const grabX = box.x + box.width * 0.35; + const dropX = box.x + box.width * 0.65; + const centerY = box.y + box.height / 2; + + await page.mouse.move(grabX, centerY); + await page.mouse.down(); + await page.mouse.move(dropX, centerY, { steps: 15 }); + await page.mouse.up(); + await page.waitForTimeout(1500); + + const movedValue = await valueLocator.textContent(); + expect(movedValue).toBeTruthy(); + expect(movedValue).not.toBe(scrubValue); + await expect(page.getByLabel("Zoom")).toBeVisible({ timeout: 3000 }); + }); + + test("resize scrub start edge updates big number", async ({ page }) => { + const { valueLocator, box } = await setupDashboard(page); + + await scrub(page, box, 0.3, 0.7); + const scrubValue = await valueLocator.textContent(); + expect(scrubValue).toBeTruthy(); + + // Drag left edge from 30% to 10% + const edgeX = box.x + box.width * 0.3; + const newEdgeX = box.x + box.width * 0.1; + const centerY = box.y + box.height / 2; + + await page.mouse.move(edgeX, centerY); + await page.mouse.down(); + await page.mouse.move(newEdgeX, centerY, { steps: 15 }); + await page.mouse.up(); + await page.waitForTimeout(1500); + + const resizedValue = await valueLocator.textContent(); + expect(resizedValue).toBeTruthy(); + expect(resizedValue).not.toBe(scrubValue); + await expect(page.getByLabel("Zoom")).toBeVisible({ timeout: 3000 }); + }); + + test("resize scrub end edge updates big number", async ({ page }) => { + const { valueLocator, box } = await setupDashboard(page); + + await scrub(page, box, 0.2, 0.5); + const scrubValue = await valueLocator.textContent(); + expect(scrubValue).toBeTruthy(); + + // Drag right edge from 50% to 80% + const edgeX = box.x + box.width * 0.5; + const newEdgeX = box.x + box.width * 0.8; + const centerY = box.y + box.height / 2; + + await page.mouse.move(edgeX, centerY); + await page.mouse.down(); + await page.mouse.move(newEdgeX, centerY, { steps: 15 }); + await page.mouse.up(); + await page.waitForTimeout(1500); + + const resizedValue = await valueLocator.textContent(); + expect(resizedValue).toBeTruthy(); + expect(resizedValue).not.toBe(scrubValue); + await expect(page.getByLabel("Zoom")).toBeVisible({ timeout: 3000 }); + }); +}); From 4500f815a01b34bca6696ce41576400e0464fec5 Mon Sep 17 00:00:00 2001 From: Brian Holmes Date: Wed, 4 Feb 2026 12:54:58 -0500 Subject: [PATCH 15/36] type fixes --- scripts/tsc-with-whitelist.sh | 4 ---- .../measure-chart/MeasureChartAnnotationMarkers.svelte | 4 ++-- .../time-series/measure-chart/MeasureChartScrub.svelte | 6 ++---- 3 files changed, 4 insertions(+), 10 deletions(-) diff --git a/scripts/tsc-with-whitelist.sh b/scripts/tsc-with-whitelist.sh index 2bc3a608df5..ca2fdffbf5a 100755 --- a/scripts/tsc-with-whitelist.sh +++ b/scripts/tsc-with-whitelist.sh @@ -22,10 +22,6 @@ web-admin/src/routes/[organization]/-/settings/usage/+page.ts: error TS2307 web-admin/src/routes/[organization]/-/upgrade-callback/+page.ts: error TS2307 web-admin/src/routes/[organization]/[project]/-/open-query/+page.ts: error TS2307 web-admin/src/routes/[organization]/[project]/-/share/[token]/+page.ts: error TS2345 -web-common/src/components/data-graphic/actions/mouse-position-to-domain-action-factory.ts: error TS2322 -web-common/src/components/data-graphic/marks/segment.ts: error TS2345 -web-common/src/components/data-graphic/utils.ts: error TS2362 -web-common/src/components/data-graphic/utils.ts: error TS2363 web-common/src/components/editor/line-status/line-number-gutter.ts: error TS2322 web-common/src/components/editor/line-status/line-number-gutter.ts: error TS2339 web-common/src/components/editor/line-status/line-status-gutter.ts: error TS2339 diff --git a/web-common/src/features/dashboards/time-series/measure-chart/MeasureChartAnnotationMarkers.svelte b/web-common/src/features/dashboards/time-series/measure-chart/MeasureChartAnnotationMarkers.svelte index d7b8cf1b7d4..ce6cc90f4ce 100644 --- a/web-common/src/features/dashboards/time-series/measure-chart/MeasureChartAnnotationMarkers.svelte +++ b/web-common/src/features/dashboards/time-series/measure-chart/MeasureChartAnnotationMarkers.svelte @@ -24,7 +24,7 @@ $: halfSize = (AnnotationWidth / 2) * 0.7; -{#each groups as group (group.key)} +{#each groups as group (group.index)} {@const hovered = hoveredGroup === group} {@const cx = group.left} {@const cy = group.top + AnnotationHeight / 2} @@ -34,7 +34,7 @@ width={halfSize * 2} height={halfSize * 2} fill={AnnotationDiamondColor} - stroke="white" + class="stroke-surface-background" stroke-width={1} opacity={hovered ? 1 : 0.7} transform="rotate(45 {cx} {cy})" diff --git a/web-common/src/features/dashboards/time-series/measure-chart/MeasureChartScrub.svelte b/web-common/src/features/dashboards/time-series/measure-chart/MeasureChartScrub.svelte index 9e3b00896ea..57595332b25 100644 --- a/web-common/src/features/dashboards/time-series/measure-chart/MeasureChartScrub.svelte +++ b/web-common/src/features/dashboards/time-series/measure-chart/MeasureChartScrub.svelte @@ -106,8 +106,7 @@ cy={y1} r={3} paint-order="stroke" - class="fill-primary-700" - stroke="white" + class="fill-primary-700 stroke-surface-background" stroke-width="3" /> @@ -124,8 +123,7 @@ cy={y1} r={3} paint-order="stroke" - class="fill-primary-700" - stroke="white" + class="fill-primary-700 stroke-surface-background" stroke-width="3" /> {/if} From dccc4b486afb99e4eddd45f874af013ab6fc3b96 Mon Sep 17 00:00:00 2001 From: Brian Holmes Date: Wed, 4 Feb 2026 13:14:49 -0500 Subject: [PATCH 16/36] bug fixes --- .../charts/TDDAlternateChart.svelte | 62 +++++++++---------- .../measure-chart/MeasureChart.svelte | 6 +- .../measure-chart/ScrubController.ts | 10 ++- .../measure-chart/use-dimension-data.ts | 4 ++ .../measure-chart/use-measure-time-series.ts | 2 +- 5 files changed, 47 insertions(+), 37 deletions(-) diff --git a/web-common/src/features/dashboards/time-dimension-details/charts/TDDAlternateChart.svelte b/web-common/src/features/dashboards/time-dimension-details/charts/TDDAlternateChart.svelte index 733855fdb06..1cbe35e3fe0 100644 --- a/web-common/src/features/dashboards/time-dimension-details/charts/TDDAlternateChart.svelte +++ b/web-common/src/features/dashboards/time-dimension-details/charts/TDDAlternateChart.svelte @@ -179,7 +179,7 @@ timeGrain, ); - function updateAdaptiveScrubRange(interval) { + const updateAdaptiveScrubRange = (() => { let rafId: number | null = null; let lastUpdateTime = 0; let currentInterval = 1000 / 60; // Start with 60fps @@ -188,40 +188,40 @@ const MAX_INTERVAL = 1000 / 30; // Min 30fps const ADJUSTMENT_FACTOR = 1.2; // Adjust interval based on performance - if (rafId) { - cancelAnimationFrame(rafId); - } - - rafId = requestAnimationFrame((timestamp) => { - const elapsed = timestamp - lastUpdateTime; - if (elapsed >= currentInterval) { - onChartBrush(interval); - lastUpdateTime = timestamp; - - // Adjust interval based on performance - if (elapsed > currentInterval * ADJUSTMENT_FACTOR) { - currentInterval = Math.min( - currentInterval * ADJUSTMENT_FACTOR, - MAX_INTERVAL, - ); - } else { - currentInterval = Math.max( - currentInterval / ADJUSTMENT_FACTOR, - MIN_INTERVAL, - ); - } + onDestroy(() => { + if (rafId) { + cancelAnimationFrame(rafId); } - rafId = null; }); - onDestroy(() => { + return (interval: { start: Date; end: Date }) => { if (rafId) { cancelAnimationFrame(rafId); } - }); - return updateAdaptiveScrubRange; - } + rafId = requestAnimationFrame((timestamp) => { + const elapsed = timestamp - lastUpdateTime; + if (elapsed >= currentInterval) { + onChartBrush(interval); + lastUpdateTime = timestamp; + + // Adjust interval based on performance + if (elapsed > currentInterval * ADJUSTMENT_FACTOR) { + currentInterval = Math.min( + currentInterval * ADJUSTMENT_FACTOR, + MAX_INTERVAL, + ); + } else { + currentInterval = Math.max( + currentInterval / ADJUSTMENT_FACTOR, + MIN_INTERVAL, + ); + } + } + rafId = null; + }); + }; + })(); const signalListeners = { hover: (_name: string, value) => { @@ -234,11 +234,11 @@ const interval = resolveSignalIntervalField(value); // Update view to prevent race condition - viewVL.runAsync(); + void viewVL.runAsync(); - updateAdaptiveScrubRange(interval); + if (interval) updateAdaptiveScrubRange(interval); }, - brush_end: (_name: string, value: boolean) => { + brush_end: (_name: string, value) => { const interval = resolveSignalIntervalField(value); if (interval) onChartBrushEnd(interval); diff --git a/web-common/src/features/dashboards/time-series/measure-chart/MeasureChart.svelte b/web-common/src/features/dashboards/time-series/measure-chart/MeasureChart.svelte index 7a937aeb647..7034fca9997 100644 --- a/web-common/src/features/dashboards/time-series/measure-chart/MeasureChart.svelte +++ b/web-common/src/features/dashboards/time-series/measure-chart/MeasureChart.svelte @@ -32,7 +32,7 @@ createQueryServiceMetricsViewTimeSeries, type V1Expression, } from "@rilldata/web-common/runtime-client"; - import type { HTTPError } from "@rilldata/web-common/runtime-client/fetchWrapper"; + import { isHTTPError } from "@rilldata/web-common/runtime-client/fetchWrapper"; import { keepPreviousData } from "@tanstack/svelte-query"; import { scaleLinear } from "d3-scale"; import Spinner from "@rilldata/web-common/features/entity-management/Spinner.svelte"; @@ -207,7 +207,9 @@ ); $: isError = $timeSeriesQuery.isError; - $: error = ($timeSeriesQuery.error as HTTPError)?.response?.data?.message; + $: error = isHTTPError($timeSeriesQuery.error) + ? $timeSeriesQuery.error.response.data.message + : $timeSeriesQuery.error?.message; // Dimension comparison data $: hasDimensionComparison = diff --git a/web-common/src/features/dashboards/time-series/measure-chart/ScrubController.ts b/web-common/src/features/dashboards/time-series/measure-chart/ScrubController.ts index 64e7eb804df..1c0ee03deca 100644 --- a/web-common/src/features/dashboards/time-series/measure-chart/ScrubController.ts +++ b/web-common/src/features/dashboards/time-series/measure-chart/ScrubController.ts @@ -124,11 +124,15 @@ export class ScrubController { this.mode = this.detectMode(screenX, xScale); - if (this.mode === "move") { + if ( + this.mode === "move" && + state.startIndex !== null && + state.endIndex !== null + ) { this.moveStartX = screenX; this.moveStartIndices = { - start: state.startIndex!, - end: state.endIndex!, + start: state.startIndex, + end: state.endIndex, }; this._state.update((s) => ({ ...s, isScrubbing: true })); } else if (this.mode === "create") { diff --git a/web-common/src/features/dashboards/time-series/measure-chart/use-dimension-data.ts b/web-common/src/features/dashboards/time-series/measure-chart/use-dimension-data.ts index c548efe2d29..5f286f119d5 100644 --- a/web-common/src/features/dashboards/time-series/measure-chart/use-dimension-data.ts +++ b/web-common/src/features/dashboards/time-series/measure-chart/use-dimension-data.ts @@ -69,6 +69,10 @@ export function createDimensionAggregationQuery( { desc: true, name: measureName }, { desc: false, name: timeDimension }, ], + // Upper bound: dimensions × time-grain buckets. Matches the limit + // used in multiple-dimension-queries.ts. Results exceeding this are + // silently truncated, which is acceptable since the leaderboard caps + // visible dimensions well below this threshold. limit: "10000", offset: "0", }, diff --git a/web-common/src/features/dashboards/time-series/measure-chart/use-measure-time-series.ts b/web-common/src/features/dashboards/time-series/measure-chart/use-measure-time-series.ts index 75f5e97cacc..13418cacad1 100644 --- a/web-common/src/features/dashboards/time-series/measure-chart/use-measure-time-series.ts +++ b/web-common/src/features/dashboards/time-series/measure-chart/use-measure-time-series.ts @@ -24,7 +24,7 @@ export function transformTimeSeriesData( const ts = DateTime.fromISO(originalPt.ts, { zone: timezone }); - if (!ts || typeof ts === "string") { + if (!ts.isValid) { return { ts: DateTime.invalid("Invalid timestamp"), value: null }; } From ac0d307cdce746eb80aa9b37f72990279e9d5bc6 Mon Sep 17 00:00:00 2001 From: Brian Holmes Date: Wed, 4 Feb 2026 13:27:26 -0500 Subject: [PATCH 17/36] ci fixes --- .../time-series/measure-chart/MeasureChart.svelte | 7 +++---- web-local/tests/utils/dataSpecifcHelpers.ts | 4 ++-- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/web-common/src/features/dashboards/time-series/measure-chart/MeasureChart.svelte b/web-common/src/features/dashboards/time-series/measure-chart/MeasureChart.svelte index 7034fca9997..eab8119a1d0 100644 --- a/web-common/src/features/dashboards/time-series/measure-chart/MeasureChart.svelte +++ b/web-common/src/features/dashboards/time-series/measure-chart/MeasureChart.svelte @@ -32,7 +32,7 @@ createQueryServiceMetricsViewTimeSeries, type V1Expression, } from "@rilldata/web-common/runtime-client"; - import { isHTTPError } from "@rilldata/web-common/runtime-client/fetchWrapper"; + import type { HTTPError } from "@rilldata/web-common/runtime-client/fetchWrapper"; import { keepPreviousData } from "@tanstack/svelte-query"; import { scaleLinear } from "d3-scale"; import Spinner from "@rilldata/web-common/features/entity-management/Spinner.svelte"; @@ -207,9 +207,8 @@ ); $: isError = $timeSeriesQuery.isError; - $: error = isHTTPError($timeSeriesQuery.error) - ? $timeSeriesQuery.error.response.data.message - : $timeSeriesQuery.error?.message; + $: error = ($timeSeriesQuery.error as HTTPError | undefined)?.response?.data + ?.message; // Dimension comparison data $: hasDimensionComparison = diff --git a/web-local/tests/utils/dataSpecifcHelpers.ts b/web-local/tests/utils/dataSpecifcHelpers.ts index 8e920d61c8b..21da33bc334 100644 --- a/web-local/tests/utils/dataSpecifcHelpers.ts +++ b/web-local/tests/utils/dataSpecifcHelpers.ts @@ -107,9 +107,9 @@ export function interceptTimeseriesResponse( * Gets the chart container element for timeseries */ export function getChartContainer(page: Page) { - // The chart SVG has role="application" and contains path elements for the line + // The chart SVG has an aria-label and contains path elements for the line return page - .locator('svg[role="application"]') + .locator('svg[aria-label*="Measure Chart"]') .filter({ has: page.locator("path") }) .first(); } From 35c8eb5f824124d469f31927cbe2b45bcf07f92b Mon Sep 17 00:00:00 2001 From: Brian Holmes Date: Wed, 4 Feb 2026 14:59:23 -0500 Subject: [PATCH 18/36] tweaks --- web-common/src/components/BarAndLabel.svelte | 2 +- .../timestamp-profile/TimestampSpark.svelte | 51 +++--------------- .../TimestampTooltipContent.svelte | 4 +- .../column-types/NumericProfile.svelte | 2 - .../column-types/TimestampProfile.svelte | 2 - .../column-types/details/NumericPlot.svelte | 54 +++++++------------ .../column-types/histogram-utils.ts | 52 ++++++++++++++++++ .../column-types/sparks/NumericSpark.svelte | 39 +++----------- .../src/features/column-profile/queries.ts | 8 +-- .../connectors/olap/TableInspector.svelte | 2 +- .../MeasureChartPointIndicator.svelte | 2 +- .../inspector/WorkspaceInspector.svelte | 2 +- 12 files changed, 97 insertions(+), 123 deletions(-) create mode 100644 web-common/src/features/column-profile/column-types/histogram-utils.ts diff --git a/web-common/src/components/BarAndLabel.svelte b/web-common/src/components/BarAndLabel.svelte index 3dba580e36d..43285d948e0 100644 --- a/web-common/src/components/BarAndLabel.svelte +++ b/web-common/src/components/BarAndLabel.svelte @@ -34,7 +34,7 @@ {customBackgroundColor !== '' ? customBackgroundColor : showBackground - ? 'bg-gray-100' + ? 'bg-surface-muted' : 'bg-transparent'} " style:flex="1" diff --git a/web-common/src/components/data-graphic/compositions/timestamp-profile/TimestampSpark.svelte b/web-common/src/components/data-graphic/compositions/timestamp-profile/TimestampSpark.svelte index a896d6d1f25..5f5bc114c41 100644 --- a/web-common/src/components/data-graphic/compositions/timestamp-profile/TimestampSpark.svelte +++ b/web-common/src/components/data-graphic/compositions/timestamp-profile/TimestampSpark.svelte @@ -8,20 +8,16 @@ createAreaGenerator, pathDoesNotDropToZero, } from "../../utils"; - import { cubicOut } from "svelte/easing"; - import { fade } from "svelte/transition"; import type { TimestampDataPoint } from "@rilldata/web-common/features/column-profile/queries"; const gradientId = `spark-gradient-${guidGenerator()}`; export let data: TimestampDataPoint[]; - export let width = 360; - export let height = 120; - export let color = "hsl(217, 10%, 50%)"; - export let zoomWindowColor = "hsla(217, 90%, 60%, .2)"; - export let zoomWindowBoundaryColor = "rgb(100,100,100)"; + export let color = "var(--color-teal-700)"; export let zoomWindowXMin: Date | undefined = undefined; export let zoomWindowXMax: Date | undefined = undefined; + export let width = 360; + export let height = 120; export let left = 0; export let right = 0; export let top = 12; @@ -58,37 +54,6 @@ $: linePath = lineGen(data); $: areaPath = areaGen(data); - - function scaleVertical( - node: Element, - { - delay = 0, - duration = 400, - easing = cubicOut, - start = 0, - opacity = 0, - } = {}, - ) { - const style = getComputedStyle(node); - const target_opacity = +style.opacity; - const transform = style.transform === "none" ? "" : style.transform; - - const sd = 1 - start; - const od = target_opacity * (1 - opacity); - - return { - delay, - duration, - easing, - css: (_t: number, u: number) => { - return ` - transform: ${transform} scaleY(${1 - sd * u}); - transform-origin: 100% calc(100% - ${0}px); - opacity: ${target_opacity - od * u} - `; - }, - }; - } {#if data.length} @@ -99,7 +64,7 @@ - + {#if linePath} {/if} @@ -108,13 +73,13 @@ {/if} {#if zoomWindowXMin && zoomWindowXMax} - + @@ -123,14 +88,14 @@ x2={xScale(zoomWindowXMin)} y1={plotTop} y2={plotBottom} - stroke={zoomWindowBoundaryColor} + class="stroke-gray-300" /> {/if} diff --git a/web-common/src/components/data-graphic/compositions/timestamp-profile/TimestampTooltipContent.svelte b/web-common/src/components/data-graphic/compositions/timestamp-profile/TimestampTooltipContent.svelte index f657ccc6832..6d11890bfeb 100644 --- a/web-common/src/components/data-graphic/compositions/timestamp-profile/TimestampTooltipContent.svelte +++ b/web-common/src/components/data-graphic/compositions/timestamp-profile/TimestampTooltipContent.svelte @@ -83,9 +83,7 @@ right={0} top={0} bottom={0} - color="hsla(217,1%,99%, .5)" - zoomWindowColor="hsla(217, 70%, 60%, .6)" - zoomWindowBoundaryColor="hsla(217, 10%, 90%, .9)" + color="var(--color-teal-300)" {zoomWindowXMin} {zoomWindowXMax} /> diff --git a/web-common/src/features/column-profile/column-types/NumericProfile.svelte b/web-common/src/features/column-profile/column-types/NumericProfile.svelte index 782f4abb73d..c2db676a614 100644 --- a/web-common/src/features/column-profile/column-types/NumericProfile.svelte +++ b/web-common/src/features/column-profile/column-types/NumericProfile.svelte @@ -64,7 +64,6 @@ columnName, QueryServiceColumnNumericHistogramHistogramMethod.HISTOGRAM_METHOD_DIAGNOSTIC, enableProfiling, - active, ); let fdHistogram; $: if (isFloat(type)) { @@ -77,7 +76,6 @@ columnName, QueryServiceColumnNumericHistogramHistogramMethod.HISTOGRAM_METHOD_FD, enableProfiling, - active, ); } diff --git a/web-common/src/features/column-profile/column-types/TimestampProfile.svelte b/web-common/src/features/column-profile/column-types/TimestampProfile.svelte index d1b5bc03864..77258e5929e 100644 --- a/web-common/src/features/column-profile/column-types/TimestampProfile.svelte +++ b/web-common/src/features/column-profile/column-types/TimestampProfile.svelte @@ -82,11 +82,9 @@
{columnName}
-
{#if data} - - - - - - {#if d?.length} - + {/if} diff --git a/web-common/src/features/column-profile/queries.ts b/web-common/src/features/column-profile/queries.ts index a186b2f66c6..67032b4e76d 100644 --- a/web-common/src/features/column-profile/queries.ts +++ b/web-common/src/features/column-profile/queries.ts @@ -13,7 +13,10 @@ import { type V1TimeSeriesValue, } from "@rilldata/web-common/runtime-client"; import type { HTTPError } from "@rilldata/web-common/runtime-client/fetchWrapper"; -import { getPriorityForColumn } from "@rilldata/web-common/runtime-client/http-request-queue/priorities"; +import { + getPriority, + getPriorityForColumn, +} from "@rilldata/web-common/runtime-client/http-request-queue/priorities"; import { keepPreviousData, type QueryObserverResult, @@ -334,7 +337,6 @@ export function getNumericHistogram( columnName: string, histogramMethod: QueryServiceColumnNumericHistogramHistogramMethod, enabled = true, - active = false, ) { return createQueryServiceColumnNumericHistogram( instanceId, @@ -345,7 +347,7 @@ export function getNumericHistogram( databaseSchema, columnName, histogramMethod, - priority: getPriorityForColumn("numeric-histogram", active), + priority: getPriority("numeric-histogram"), }, { query: { diff --git a/web-common/src/features/connectors/olap/TableInspector.svelte b/web-common/src/features/connectors/olap/TableInspector.svelte index c7f6c94e7aa..8c17b925585 100644 --- a/web-common/src/features/connectors/olap/TableInspector.svelte +++ b/web-common/src/features/connectors/olap/TableInspector.svelte @@ -87,7 +87,7 @@ From 7b6d2e7b4198271782531d40ad33119c65839b7a Mon Sep 17 00:00:00 2001 From: Brian Holmes Date: Wed, 4 Feb 2026 17:11:03 -0500 Subject: [PATCH 19/36] reorg, cleanup --- .../time-series-chart/BarChart.svelte | 5 +- .../big-number/MeasureBigNumber.svelte | 2 - .../TimeDimensionDisplay.svelte | 26 +- .../time-dimension-data-store.ts | 2 - .../MetricsTimeSeriesCharts.svelte | 63 +-- .../AnnotationPopoverController.ts | 70 +++ .../measure-chart/MeasureChart.svelte | 447 +++++------------- .../measure-chart/MeasureChartGrid.svelte | 60 +++ .../measure-chart/annotation-utils.ts | 7 - .../time-series/measure-chart/chart-series.ts | 80 ++++ .../time-series/measure-chart/hover-index.ts | 30 ++ .../time-series/measure-chart/index.ts | 2 - 12 files changed, 401 insertions(+), 393 deletions(-) create mode 100644 web-common/src/features/dashboards/time-series/measure-chart/AnnotationPopoverController.ts create mode 100644 web-common/src/features/dashboards/time-series/measure-chart/MeasureChartGrid.svelte create mode 100644 web-common/src/features/dashboards/time-series/measure-chart/chart-series.ts create mode 100644 web-common/src/features/dashboards/time-series/measure-chart/hover-index.ts delete mode 100644 web-common/src/features/dashboards/time-series/measure-chart/index.ts diff --git a/web-common/src/components/time-series-chart/BarChart.svelte b/web-common/src/components/time-series-chart/BarChart.svelte index efdfddcd9ae..aa86902a63b 100644 --- a/web-common/src/components/time-series-chart/BarChart.svelte +++ b/web-common/src/components/time-series-chart/BarChart.svelte @@ -3,6 +3,8 @@ import { computeBarSlotGeometry } from "@rilldata/web-common/features/dashboards/time-series/measure-chart/utils"; import type { ScaleLinear } from "d3-scale"; + const BAR_RADIUS = 3; + export let series: ChartSeries[]; export let yScale: ScaleLinear; export let stacked: boolean = false; @@ -60,7 +62,6 @@ {/each} {/each} {:else} - {@const radius = 4} {#each { length: visibleCount } as _, slot (slot)} {@const ptIdx = visibleStart + slot} {@const cx = plotLeft + (slot + 0.5) * geo.slotWidth} @@ -71,7 +72,7 @@ cx - geo.bandWidth / 2 + sIdx * (geo.singleBarWidth + geo.barGap)} {@const by = Math.min(zeroY, yScale(v))} {@const bh = Math.abs(zeroY - yScale(v))} - {@const r = Math.min(radius, geo.singleBarWidth / 2, bh / 2)} + {@const r = Math.min(BAR_RADIUS, geo.singleBarWidth / 2, bh / 2)} {@const isPositive = v >= 0} { - const idx = columnHeaders.findIndex( - (h) => - h?.value && - new Date(h.value).getTime() === $chartHoveredTime!.getTime(), - ); - return idx >= 0 ? idx : undefined; - })() - : undefined; + $: highlightedCol = $hoverIndex; // Create a time formatter for the column headers $: timeFormatter = timeFormat( @@ -108,15 +98,22 @@ ) as (d: Date) => string; function highlightCell(x: number | undefined, y: number | undefined) { - if (x === undefined || y === undefined) return; + if (x === undefined || y === undefined) { + hoverIndex.clear("table"); + tableInteractionStore.set({ + dimensionValue: undefined, + time: undefined, + }); + return; + } const dimensionValue = formattedData?.rowHeaderData[y]?.[0]?.value; let time: Date | undefined = undefined; - const colHeader = columnHeaders?.[x]?.value; if (colHeader) { time = new Date(colHeader); } + hoverIndex.set(x, "table"); tableInteractionStore.set({ dimensionValue, time: time, @@ -184,6 +181,7 @@ } onDestroy(() => { + hoverIndex.clear("table"); tableInteractionStore.set({ dimensionValue: undefined, time: undefined, diff --git a/web-common/src/features/dashboards/time-dimension-details/time-dimension-data-store.ts b/web-common/src/features/dashboards/time-dimension-details/time-dimension-data-store.ts index 345c5219e14..af0044ec5e5 100644 --- a/web-common/src/features/dashboards/time-dimension-details/time-dimension-data-store.ts +++ b/web-common/src/features/dashboards/time-dimension-details/time-dimension-data-store.ts @@ -475,6 +475,4 @@ export const tableInteractionStore = writable({ time: undefined, }); -export const chartHoveredTime = writable(undefined); - export const lastKnownPosition = writable(undefined); diff --git a/web-common/src/features/dashboards/time-series/MetricsTimeSeriesCharts.svelte b/web-common/src/features/dashboards/time-series/MetricsTimeSeriesCharts.svelte index f42443b62ac..add2e1bc3cd 100644 --- a/web-common/src/features/dashboards/time-series/MetricsTimeSeriesCharts.svelte +++ b/web-common/src/features/dashboards/time-series/MetricsTimeSeriesCharts.svelte @@ -34,27 +34,18 @@ import Spinner from "../../entity-management/Spinner.svelte"; import { featureFlags } from "../../feature-flags"; import MeasureBigNumber from "../big-number/MeasureBigNumber.svelte"; - import { MeasureChart } from "./measure-chart"; + import MeasureChart from "./measure-chart/MeasureChart.svelte"; import MeasureChartXAxis from "./measure-chart/MeasureChartXAxis.svelte"; import { ScrubController } from "./measure-chart/ScrubController"; import { getAnnotationsForMeasure } from "./annotations-selectors"; import ChartInteractions from "./ChartInteractions.svelte"; - import { chartHoveredTime } from "../time-dimension-details/time-dimension-data-store"; import { mergeDimensionAndMeasureFilters } from "@rilldata/web-common/features/dashboards/filters/measure-filters/measure-filter-utils"; import { sanitiseExpression } from "@rilldata/web-common/features/dashboards/stores/filter-utils"; - import { derived, writable } from "svelte/store"; - import { DateTime } from "luxon"; - import { tableInteractionStore } from "@rilldata/web-common/features/dashboards/time-dimension-details/time-dimension-data-store"; + import { DateTime, Interval } from "luxon"; import { runtime } from "@rilldata/web-common/runtime-client/runtime-store"; - // Derive a readable of the table-hovered time (Date | undefined) - const tableHoverTime = derived(tableInteractionStore, ($s) => $s.time); - const { rillTime } = featureFlags; - // Shared hover index store — all MeasureChart instances read/write this - const sharedHoverIndex = writable(undefined); - // Singleton scrub controller — shared across all charts const scrubController = new ScrubController(); @@ -97,11 +88,26 @@ } = $timeControlsStore); // Use the full selected time range for chart data fetching (not modified by scrub) - $: chartTimeStart = selectedTimeRange?.start?.toISOString(); - $: chartTimeEnd = selectedTimeRange?.end?.toISOString(); - $: chartComparisonTimeStart = - selectedComparisonTimeRange?.start?.toISOString(); - $: chartComparisonTimeEnd = selectedComparisonTimeRange?.end?.toISOString(); + $: chartTimeRange = + selectedTimeRange?.start && selectedTimeRange?.end + ? Interval.fromDateTimes( + DateTime.fromJSDate(selectedTimeRange.start, { + zone: chartTimeZone, + }), + DateTime.fromJSDate(selectedTimeRange.end, { zone: chartTimeZone }), + ) + : undefined; + $: chartComparisonTimeRange = + selectedComparisonTimeRange?.start && selectedComparisonTimeRange?.end + ? Interval.fromDateTimes( + DateTime.fromJSDate(selectedComparisonTimeRange.start, { + zone: chartTimeZone, + }), + DateTime.fromJSDate(selectedComparisonTimeRange.end, { + zone: chartTimeZone, + }), + ) + : undefined; $: exploreState = useExploreState(exploreName); @@ -342,8 +348,8 @@
@@ -376,7 +382,6 @@ handlePan("right")} {showComparison} {showTimeDimensionDetail} - {tableHoverTime} - onHover={(dt) => { - if (dt) { - const systemTimeZone = - Intl.DateTimeFormat().resolvedOptions().timeZone; - chartHoveredTime.set( - dt - .setZone(systemTimeZone, { keepLocalTime: true }) - .toJSDate(), - ); - } else { - chartHoveredTime.set(undefined); - } - }} onScrub={handleScrub} onScrubClear={() => { metricsExplorerStore.setSelectedScrubRange( diff --git a/web-common/src/features/dashboards/time-series/measure-chart/AnnotationPopoverController.ts b/web-common/src/features/dashboards/time-series/measure-chart/AnnotationPopoverController.ts new file mode 100644 index 00000000000..f1909942c60 --- /dev/null +++ b/web-common/src/features/dashboards/time-series/measure-chart/AnnotationPopoverController.ts @@ -0,0 +1,70 @@ +import { writable } from "svelte/store"; +import { findHoveredGroup, type AnnotationGroup } from "./annotation-utils"; + +const POPOVER_DELAY_MS = 150; + +/** + * Manages annotation popover hover state: hit-testing mouse position against + * annotation groups, delayed hiding so the user can reach the popover, and + * popover-is-hovered tracking. + */ +export class AnnotationPopoverController { + readonly hoveredGroup = writable(null); + + private popoverHovered = false; + private timeout: ReturnType | null = null; + private currentGroup: AnnotationGroup | null = null; + + /** Call from SVG mousemove. */ + checkHover(e: MouseEvent, groups: AnnotationGroup[], isScrubbing: boolean) { + if (isScrubbing || groups.length === 0) { + this.scheduleClear(); + return; + } + const svg = e.currentTarget as SVGSVGElement; + const rect = svg.getBoundingClientRect(); + const hit = findHoveredGroup( + groups, + e.clientX - rect.left, + e.clientY - rect.top, + ); + if (hit) { + this.cancelTimeout(); + this.setGroup(hit); + } else if (this.currentGroup && !this.popoverHovered) { + this.scheduleClear(); + } + } + + /** Call when the popover itself is hovered / unhovered. */ + setPopoverHovered(hovered: boolean) { + this.popoverHovered = hovered; + this.cancelTimeout(); + if (!hovered) this.scheduleClear(); + } + + /** Schedule a delayed clear (e.g. on mouseleave). */ + scheduleClear() { + if (this.popoverHovered || this.timeout) return; + this.timeout = setTimeout(() => { + if (!this.popoverHovered) this.setGroup(null); + this.timeout = null; + }, POPOVER_DELAY_MS); + } + + destroy() { + this.cancelTimeout(); + } + + private setGroup(group: AnnotationGroup | null) { + this.currentGroup = group; + this.hoveredGroup.set(group); + } + + private cancelTimeout() { + if (this.timeout) { + clearTimeout(this.timeout); + this.timeout = null; + } + } +} diff --git a/web-common/src/features/dashboards/time-series/measure-chart/MeasureChart.svelte b/web-common/src/features/dashboards/time-series/measure-chart/MeasureChart.svelte index eab8119a1d0..2608a6fae7c 100644 --- a/web-common/src/features/dashboards/time-series/measure-chart/MeasureChart.svelte +++ b/web-common/src/features/dashboards/time-series/measure-chart/MeasureChart.svelte @@ -1,13 +1,7 @@ + + + {#each yTicks as tick (tick)} + + {axisFormatter(tick)} + + + {/each} + + + + {#each xTickIndices as idx (idx)} + + {/each} + + + + diff --git a/web-common/src/features/dashboards/time-series/measure-chart/annotation-utils.ts b/web-common/src/features/dashboards/time-series/measure-chart/annotation-utils.ts index 7b9b23eb068..2ee646cb4e0 100644 --- a/web-common/src/features/dashboards/time-series/measure-chart/annotation-utils.ts +++ b/web-common/src/features/dashboards/time-series/measure-chart/annotation-utils.ts @@ -7,19 +7,12 @@ import { dateToIndex } from "./utils"; export type AnnotationGroup = { items: Annotation[]; - /** Unique key for this group (grain-truncated ISO string) */ key: string; - /** Data index this group maps to */ index: number; - /** Pixel x of the group diamond */ left: number; - /** Pixel x of rightmost annotation end (for range annotations) */ right: number; - /** Pixel y top of diamond area */ top: number; - /** Pixel y bottom of diamond area */ bottom: number; - /** Whether any item in the group has a range (endTime) */ hasRange: boolean; }; diff --git a/web-common/src/features/dashboards/time-series/measure-chart/chart-series.ts b/web-common/src/features/dashboards/time-series/measure-chart/chart-series.ts new file mode 100644 index 00000000000..3ed9552fe3f --- /dev/null +++ b/web-common/src/features/dashboards/time-series/measure-chart/chart-series.ts @@ -0,0 +1,80 @@ +import type { + TimeSeriesPoint, + DimensionSeriesData, + ChartSeries, + ChartMode, +} from "./types"; +import { + MainLineColor, + MainAreaColorGradientDark, + MainAreaColorGradientLight, + TimeComparisonLineColor, +} from "../chart-colors"; +import { COMPARISON_COLORS } from "@rilldata/web-common/features/dashboards/config"; +import { formatMeasurePercentageDifference } from "@rilldata/web-common/lib/number-formatting/percentage-formatter"; +import { numberPartsToString } from "@rilldata/web-common/lib/number-formatting/utils/number-parts-utils"; + +const LINE_MODE_MIN_POINTS = 6; + +export function buildChartSeries( + data: TimeSeriesPoint[], + dimData: DimensionSeriesData[], + showComparison: boolean, +): ChartSeries[] { + if (dimData.length > 0) { + return dimData.map((dim, i) => ({ + id: `dim-${dim.dimensionValue ?? i}`, + values: dim.data.map((pt) => pt.value), + color: dim.color || COMPARISON_COLORS[i % COMPARISON_COLORS.length], + opacity: dim.isFetching ? 0.5 : 1, + strokeWidth: 1.5, + })); + } + + const result: ChartSeries[] = []; + + if (data.length > 0) { + result.push({ + id: "primary", + values: data.map((pt) => pt.value), + color: MainLineColor, + areaGradient: { + dark: MainAreaColorGradientDark, + light: MainAreaColorGradientLight, + }, + }); + } + + if (showComparison && data.length > 0) { + result.push({ + id: "comparison", + values: data.map((pt) => pt.comparisonValue ?? null), + color: TimeComparisonLineColor, + opacity: 0.5, + }); + } + + return result; +} + +export function determineMode(data: TimeSeriesPoint[]): ChartMode { + return data.length >= LINE_MODE_MIN_POINTS ? "line" : "bar"; +} + +export function computeTooltipDelta(point: TimeSeriesPoint | null) { + const currentValue = point?.value ?? null; + const comparisonValue = point?.comparisonValue ?? null; + const delta = + currentValue !== null && comparisonValue !== null && comparisonValue !== 0 + ? (currentValue - comparisonValue) / comparisonValue + : null; + return { + currentValue, + comparisonValue, + deltaLabel: + delta !== null + ? numberPartsToString(formatMeasurePercentageDifference(delta)) + : null, + deltaPositive: delta !== null && delta >= 0, + }; +} diff --git a/web-common/src/features/dashboards/time-series/measure-chart/hover-index.ts b/web-common/src/features/dashboards/time-series/measure-chart/hover-index.ts new file mode 100644 index 00000000000..6e7df79d6bc --- /dev/null +++ b/web-common/src/features/dashboards/time-series/measure-chart/hover-index.ts @@ -0,0 +1,30 @@ +import { writable } from "svelte/store"; +import type { ScaleLinear } from "d3-scale"; + +function createHoverIndex() { + const { subscribe, set: _set } = writable(undefined); + let currentOwner: string | null = null; + let _xScale: ScaleLinear | null = null; + + return { + subscribe, + set(index: number, owner: string) { + currentOwner = owner; + _set(index); + }, + clear(owner: string) { + if (owner === currentOwner) { + currentOwner = null; + _set(undefined); + } + }, + registerScale(scale: ScaleLinear) { + _xScale = scale; + }, + get xScale() { + return _xScale; + }, + }; +} + +export const hoverIndex = createHoverIndex(); diff --git a/web-common/src/features/dashboards/time-series/measure-chart/index.ts b/web-common/src/features/dashboards/time-series/measure-chart/index.ts deleted file mode 100644 index 085494b495f..00000000000 --- a/web-common/src/features/dashboards/time-series/measure-chart/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { default as MeasureChart } from "./MeasureChart.svelte"; -export { default as MeasureChartXAxis } from "./MeasureChartXAxis.svelte"; From 2175f6579acc8d1c8270802a570d64bf7cf6181c Mon Sep 17 00:00:00 2001 From: Brian Holmes Date: Wed, 4 Feb 2026 17:26:55 -0500 Subject: [PATCH 20/36] cleanup --- .../MetricsTimeSeriesCharts.svelte | 62 +++++++++---------- .../measure-chart/MeasureChart.svelte | 54 ++++++++-------- .../measure-chart/MeasureChartXAxis.svelte | 26 +++----- 3 files changed, 66 insertions(+), 76 deletions(-) diff --git a/web-common/src/features/dashboards/time-series/MetricsTimeSeriesCharts.svelte b/web-common/src/features/dashboards/time-series/MetricsTimeSeriesCharts.svelte index add2e1bc3cd..63fb583d9ef 100644 --- a/web-common/src/features/dashboards/time-series/MetricsTimeSeriesCharts.svelte +++ b/web-common/src/features/dashboards/time-series/MetricsTimeSeriesCharts.svelte @@ -78,7 +78,6 @@ selectedComparisonTimeRange, timeDimension, ready, - showTimeComparison, timeEnd, timeStart, @@ -87,26 +86,31 @@ aggregationOptions, } = $timeControlsStore); + $: ({ whereFilter, dimensionThresholdFilters, selectedTimezone } = + $dashboardStore); + // Use the full selected time range for chart data fetching (not modified by scrub) - $: chartTimeRange = + $: chartInterval = selectedTimeRange?.start && selectedTimeRange?.end - ? Interval.fromDateTimes( + ? (Interval.fromDateTimes( DateTime.fromJSDate(selectedTimeRange.start, { - zone: chartTimeZone, + zone: selectedTimezone, + }), + DateTime.fromJSDate(selectedTimeRange.end, { + zone: selectedTimezone, }), - DateTime.fromJSDate(selectedTimeRange.end, { zone: chartTimeZone }), - ) + ) as Interval) : undefined; - $: chartComparisonTimeRange = + $: chartComparisonInterval = selectedComparisonTimeRange?.start && selectedComparisonTimeRange?.end - ? Interval.fromDateTimes( + ? (Interval.fromDateTimes( DateTime.fromJSDate(selectedComparisonTimeRange.start, { - zone: chartTimeZone, + zone: selectedTimezone, }), DateTime.fromJSDate(selectedComparisonTimeRange.end, { - zone: chartTimeZone, + zone: selectedTimezone, }), - ) + ) as Interval) : undefined; $: exploreState = useExploreState(exploreName); @@ -123,15 +127,15 @@ $: activeTimeGrain = selectedTimeRange?.interval; - $: chartScrubRange = $exploreState?.lastDefinedScrubRange - ? { - start: DateTime.fromJSDate($exploreState.lastDefinedScrubRange.start, { - zone: chartTimeZone, + $: chartScrubInterval = $exploreState?.lastDefinedScrubRange + ? (Interval.fromDateTimes( + DateTime.fromJSDate($exploreState.lastDefinedScrubRange.start, { + zone: selectedTimezone, }), - end: DateTime.fromJSDate($exploreState.lastDefinedScrubRange.end, { - zone: chartTimeZone, + DateTime.fromJSDate($exploreState.lastDefinedScrubRange.end, { + zone: selectedTimezone, }), - } + ) as Interval) : undefined; $: includedValuesForDimension = $includedDimensionValues( comparisonDimension as string, @@ -160,14 +164,10 @@ $: chartMetricsViewName = $metricsViewName; $: chartWhere = sanitiseExpression( - mergeDimensionAndMeasureFilters( - $dashboardStore.whereFilter, - $dashboardStore.dimensionThresholdFilters, - ), + mergeDimensionAndMeasureFilters(whereFilter, dimensionThresholdFilters), undefined, ); - $: chartTimeZone = $dashboardStore.selectedTimezone; $: chartReady = !!ready; // Annotation stores per measure — keyed by measure name @@ -177,7 +177,7 @@ exploreName, measureName, selectedTimeRange, - dashboardTimezone: chartTimeZone, + dashboardTimezone: selectedTimezone, }); } @@ -348,10 +348,8 @@
import { onMount, onDestroy } from "svelte"; - import { writable, get, type Readable } from "svelte/store"; + import { get, type Readable } from "svelte/store"; import type { TimeSeriesPoint, HoverState } from "./types"; import { computeChartConfig, @@ -59,18 +59,14 @@ const X_PAD = 8; const CLICK_THRESHOLD_PX = 4; const VISIBILITY_ROOT_MARGIN = "120px"; - const { visible, observe } = createVisibilityObserver(VISIBILITY_ROOT_MARGIN); - const selMeasure = measureSelection.measure; - const selStart = measureSelection.start; - const selEnd = measureSelection.end; export let measure: MetricsViewSpecMeasure; export let instanceId: string; export let metricsViewName: string; export let where: V1Expression | undefined = undefined; export let timeDimension: string | undefined = undefined; - export let timeRange: Interval | undefined = undefined; - export let comparisonTimeRange: Interval | undefined = undefined; + export let interval: Interval | undefined = undefined; + export let comparisonInterval: Interval | undefined = undefined; export let timeGranularity: V1TimeGrain | undefined = undefined; export let timeZone: string = "UTC"; export let comparisonDimension: string | undefined = undefined; @@ -80,8 +76,7 @@ export let showComparison: boolean = false; export let showTimeDimensionDetail: boolean = false; export let ready: boolean = true; - export let scrubRange: { start: DateTime; end: DateTime } | undefined = - undefined; + export let chartScrubInterval: Interval | undefined = undefined; export let canPanLeft: boolean = false; export let canPanRight: boolean = false; export let tddChartType: TDDChart = TDDChart.DEFAULT; @@ -97,23 +92,28 @@ export let onPanRight: (() => void) | undefined = undefined; export let scrubController: ScrubController; + const annotationPopover = new AnnotationPopoverController(); + const hoveredAnnotationGroup = annotationPopover.hoveredGroup; + const { visible, observe } = createVisibilityObserver(VISIBILITY_ROOT_MARGIN); + const selMeasure = measureSelection.measure; + const selStart = measureSelection.start; + const selEnd = measureSelection.end; + let container: HTMLDivElement; let clientWidth = 425; let unobserve: (() => void) | undefined; let tddIsScrubbing = false; - const annotationPopover = new AnnotationPopoverController(); - const hoveredAnnotationGroup = annotationPopover.hoveredGroup; let mouseDownX: number | null = null; let mouseDownY: number | null = null; let mousePageX: number | null = null; let mousePageY: number | null = null; let wasDragging = false; // Track if we just finished a drag (to skip click handler) - - const hoverState = writable(EMPTY_HOVER); + let hoverState: HoverState = EMPTY_HOVER; onMount(() => { if (container) unobserve = observe(container); }); + onDestroy(() => { unobserve?.(); hoverIndex.clear(chartId); @@ -126,10 +126,10 @@ $: pb = config.plotBounds; // Extract ISO strings for API calls - $: timeStart = timeRange?.start?.toISO() ?? undefined; - $: timeEnd = timeRange?.end?.toISO() ?? undefined; - $: comparisonTimeStart = comparisonTimeRange?.start?.toISO() ?? undefined; - $: comparisonTimeEnd = comparisonTimeRange?.end?.toISO() ?? undefined; + $: timeStart = interval?.start?.toISO() ?? undefined; + $: timeEnd = interval?.end?.toISO() ?? undefined; + $: comparisonTimeStart = comparisonInterval?.start?.toISO() ?? undefined; + $: comparisonTimeEnd = comparisonInterval?.end?.toISO() ?? undefined; // Time series queries $: timeSeriesQuery = createQueryServiceMetricsViewTimeSeries( @@ -306,11 +306,11 @@ $: isScrubbing = currentScrubState.isScrubbing; // Scrub indices: use local (active) state while scrubbing, external (URL) state otherwise - $: externalScrubStartIndex = scrubRange - ? dateToIndex(data, scrubRange.start.toMillis()) + $: externalScrubStartIndex = chartScrubInterval + ? dateToIndex(data, chartScrubInterval.start.toMillis()) : null; - $: externalScrubEndIndex = scrubRange - ? dateToIndex(data, scrubRange.end.toMillis()) + $: externalScrubEndIndex = chartScrubInterval + ? dateToIndex(data, chartScrubInterval.end.toMillis()) : null; $: scrubStartIndex = currentScrubState.startIndex ?? externalScrubStartIndex; $: scrubEndIndex = currentScrubState.endIndex ?? externalScrubEndIndex; @@ -319,15 +319,15 @@ // Hover state $: hoverIndex.registerScale(xScale); $: isLocallyHovered = - $hoverState.isHovered && $hoverState.index !== null && data.length > 0; + hoverState.isHovered && hoverState.index !== null && data.length > 0; $: if (isLocallyHovered) { - hoverIndex.set(snapIndex($hoverState.index!, data.length), chartId); + hoverIndex.set(snapIndex(hoverState.index!, data.length), chartId); } else { hoverIndex.clear(chartId); } $: hoveredIndex = $hoverIndex ?? -1; $: hoveredPoint = data[hoveredIndex] ?? null; - $: cursorStyle = scrubController.getCursorStyle($hoverState.screenX, xScale); + $: cursorStyle = scrubController.getCursorStyle(hoverState.screenX, xScale); // Formatters $: measureFormatter = createMeasureValueFormatter(measure); @@ -449,7 +449,7 @@ } function handleSvgMouseLeave() { - hoverState.set(EMPTY_HOVER); + hoverState = EMPTY_HOVER; mousePageX = null; mousePageY = null; annotationPopover.scheduleClear(); @@ -620,12 +620,12 @@ const x = clampX(e.offsetX); const fractionalIndex = xScale.invert(x); - hoverState.set({ + hoverState = { index: fractionalIndex, screenX: x, screenY: e.offsetY, isHovered: true, - }); + }; // Update scrub if dragging if (get(scrubController.state).isScrubbing) { diff --git a/web-common/src/features/dashboards/time-series/measure-chart/MeasureChartXAxis.svelte b/web-common/src/features/dashboards/time-series/measure-chart/MeasureChartXAxis.svelte index 3366bf9487a..7ea6946f519 100644 --- a/web-common/src/features/dashboards/time-series/measure-chart/MeasureChartXAxis.svelte +++ b/web-common/src/features/dashboards/time-series/measure-chart/MeasureChartXAxis.svelte @@ -1,6 +1,6 @@ diff --git a/web-common/src/features/dashboards/time-series/measure-chart/MeasureChartXAxis.svelte b/web-common/src/features/dashboards/time-series/measure-chart/MeasureChartXAxis.svelte index e976b67e24e..2ac4b6b2d37 100644 --- a/web-common/src/features/dashboards/time-series/measure-chart/MeasureChartXAxis.svelte +++ b/web-common/src/features/dashboards/time-series/measure-chart/MeasureChartXAxis.svelte @@ -6,10 +6,7 @@ V1TimeGrainToOrder, V1TimeGrainToDateTimeUnit, } from "@rilldata/web-common/lib/time/new-grains"; - - const MARGIN_RIGHT = 40; - const X_PAD = 8; - const LINE_MODE_MIN_POINTS = 6; + import { LINE_MODE_MIN_POINTS, X_PAD, MARGIN_RIGHT } from "./scales"; const DAY_GRAIN_ORDER = V1TimeGrainToOrder[V1TimeGrain.TIME_GRAIN_DAY]; export let interval: Interval | undefined = undefined; diff --git a/web-common/src/features/dashboards/time-series/measure-chart/PanButton.svelte b/web-common/src/features/dashboards/time-series/measure-chart/PanButton.svelte index 9572b591cd9..106a4cebb2e 100644 --- a/web-common/src/features/dashboards/time-series/measure-chart/PanButton.svelte +++ b/web-common/src/features/dashboards/time-series/measure-chart/PanButton.svelte @@ -17,9 +17,10 @@ diff --git a/web-common/src/features/dashboards/time-series/measure-chart/chart-series.ts b/web-common/src/features/dashboards/time-series/measure-chart/chart-series.ts index 3ed9552fe3f..635fcd0195c 100644 --- a/web-common/src/features/dashboards/time-series/measure-chart/chart-series.ts +++ b/web-common/src/features/dashboards/time-series/measure-chart/chart-series.ts @@ -13,8 +13,7 @@ import { import { COMPARISON_COLORS } from "@rilldata/web-common/features/dashboards/config"; import { formatMeasurePercentageDifference } from "@rilldata/web-common/lib/number-formatting/percentage-formatter"; import { numberPartsToString } from "@rilldata/web-common/lib/number-formatting/utils/number-parts-utils"; - -const LINE_MODE_MIN_POINTS = 6; +import { LINE_MODE_MIN_POINTS } from "./scales"; export function buildChartSeries( data: TimeSeriesPoint[], diff --git a/web-common/src/features/dashboards/time-series/measure-chart/scales.ts b/web-common/src/features/dashboards/time-series/measure-chart/scales.ts index 858c22d6781..a96f4279126 100644 --- a/web-common/src/features/dashboards/time-series/measure-chart/scales.ts +++ b/web-common/src/features/dashboards/time-series/measure-chart/scales.ts @@ -14,6 +14,10 @@ interface ExtentConfig { /** * Default extent configuration. */ +export const LINE_MODE_MIN_POINTS = 6; +export const X_PAD = 8; +export const MARGIN_RIGHT = 40; + const DEFAULT_EXTENT_CONFIG: ExtentConfig = { includeZero: true, paddingFactor: 1.3, @@ -119,7 +123,7 @@ export function computeChartConfig( ): ChartConfig { const margin = { top: 4, // Space for data readout labels - right: 40, + right: MARGIN_RIGHT, bottom: isExpanded ? 25 : 10, left: 0, }; From 32118f0d9e0fb6389ca58f4b16919de87f33948b Mon Sep 17 00:00:00 2001 From: Brian Holmes Date: Fri, 6 Feb 2026 09:59:38 -0500 Subject: [PATCH 24/36] annotations reorg --- .../MetricsTimeSeriesCharts.svelte | 19 ++--- .../time-series/annotations-selectors.ts | 85 +++---------------- .../measure-chart/MeasureChart.svelte | 79 ++++++++++++++--- 3 files changed, 84 insertions(+), 99 deletions(-) diff --git a/web-common/src/features/dashboards/time-series/MetricsTimeSeriesCharts.svelte b/web-common/src/features/dashboards/time-series/MetricsTimeSeriesCharts.svelte index 6f9628503e9..d3bafc1dfa7 100644 --- a/web-common/src/features/dashboards/time-series/MetricsTimeSeriesCharts.svelte +++ b/web-common/src/features/dashboards/time-series/MetricsTimeSeriesCharts.svelte @@ -37,7 +37,7 @@ import MeasureChart from "./measure-chart/MeasureChart.svelte"; import MeasureChartXAxis from "./measure-chart/MeasureChartXAxis.svelte"; import { ScrubController } from "./measure-chart/ScrubController"; - import { getAnnotationsForMeasure } from "./annotations-selectors"; + import { useExploreValidSpec } from "@rilldata/web-common/features/explores/selectors"; import ChartInteractions from "./ChartInteractions.svelte"; import { mergeDimensionAndMeasureFilters } from "@rilldata/web-common/features/dashboards/filters/measure-filters/measure-filter-utils"; import { sanitiseExpression } from "@rilldata/web-common/features/dashboards/stores/filter-utils"; @@ -170,17 +170,10 @@ $: chartReady = !!ready; - // Annotation stores per measure — keyed by measure name - function getAnnotationsForMeasureStore(measureName: string) { - console.log("get"); - return getAnnotationsForMeasure({ - instanceId, - exploreName, - measureName, - selectedTimeRange, - dashboardTimezone: selectedTimezone, - }); - } + // Check if annotations are enabled for this explore + $: exploreValidSpec = useExploreValidSpec(instanceId, exploreName); + $: annotationsEnabled = + !!$exploreValidSpec.data?.metricsView?.annotations?.length; // Pan handler function handlePan(direction: "left" | "right") { @@ -397,7 +390,7 @@ {comparisonDimension} dimensionValues={chartDimensionValues} dimensionWhere={whereFilter} - annotations={getAnnotationsForMeasureStore(measure.name ?? "")} + {annotationsEnabled} canPanLeft={$canPanLeft} canPanRight={$canPanRight} onPanLeft={() => handlePan("left")} diff --git a/web-common/src/features/dashboards/time-series/annotations-selectors.ts b/web-common/src/features/dashboards/time-series/annotations-selectors.ts index b09a41f4b4d..769b609050f 100644 --- a/web-common/src/features/dashboards/time-series/annotations-selectors.ts +++ b/web-common/src/features/dashboards/time-series/annotations-selectors.ts @@ -1,92 +1,22 @@ import type { Annotation } from "@rilldata/web-common/components/data-graphic/marks/annotations.ts"; -import { useExploreValidSpec } from "@rilldata/web-common/features/explores/selectors.ts"; -import { TIME_GRAIN } from "@rilldata/web-common/lib/time/config.ts"; import { prettyFormatTimeRange } from "@rilldata/web-common/lib/time/ranges/formatter.ts"; import { - type DashboardTimeControls, Period, TimeUnit, } from "@rilldata/web-common/lib/time/types.ts"; import { - getQueryServiceMetricsViewAnnotationsQueryOptions, type V1MetricsViewAnnotationsResponseAnnotation, V1TimeGrain, } from "@rilldata/web-common/runtime-client"; -import { createQuery } from "@tanstack/svelte-query"; import { DateTime, Interval } from "luxon"; -import { derived, type Readable } from "svelte/store"; - -export function getAnnotationsForMeasure({ - instanceId, - exploreName, - measureName, - selectedTimeRange, - dashboardTimezone, -}: { - instanceId: string; - exploreName: string; - measureName: string; - selectedTimeRange: DashboardTimeControls | undefined; - dashboardTimezone: string; -}): Readable { - const exploreValidSpec = useExploreValidSpec(instanceId, exploreName); - const selectedPeriod = TIME_GRAIN[selectedTimeRange?.interval ?? ""] - ?.duration as Period | undefined; - - const annotationsQueryOptions = derived( - exploreValidSpec, - (exploreValidSpec) => { - const metricsViewSpec = exploreValidSpec.data?.metricsView; - const exploreSpec = exploreValidSpec.data?.explore; - const metricsViewName = exploreSpec?.metricsView ?? ""; - - return getQueryServiceMetricsViewAnnotationsQueryOptions( - instanceId, - metricsViewName, - { - timeRange: { - start: selectedTimeRange?.start.toISOString(), - end: selectedTimeRange?.end.toISOString(), - }, - timeGrain: selectedTimeRange?.interval, - measures: [measureName], - }, - { - query: { - enabled: - !!metricsViewSpec?.annotations?.length && - !!metricsViewName && - !!selectedTimeRange, - }, - }, - ); - }, - ); - - const annotationsQuery = createQuery(annotationsQueryOptions); - - return derived(annotationsQuery, (annotationsQuery) => { - const annotations = - annotationsQuery.data?.rows?.map((a) => - convertV1AnnotationsResponseItemToAnnotation( - a, - selectedPeriod, - selectedTimeRange?.interval ?? V1TimeGrain.TIME_GRAIN_UNSPECIFIED, - - dashboardTimezone, - ), - ) ?? []; - annotations.sort((a, b) => a.startTime.toMillis() - b.startTime.toMillis()); - return annotations; - }); -} +import { TIME_GRAIN } from "@rilldata/web-common/lib/time/config.ts"; -function convertV1AnnotationsResponseItemToAnnotation( +export function convertV1AnnotationsResponseItemToAnnotation( annotation: V1MetricsViewAnnotationsResponseAnnotation, period: Period | undefined, selectedTimeGrain: V1TimeGrain, dashboardTimezone: string, -) { +): Annotation { let startTime = DateTime.fromISO(annotation.time as string, { zone: dashboardTimezone, }); @@ -118,3 +48,12 @@ function convertV1AnnotationsResponseItemToAnnotation( formattedTimeOrRange, }; } + +/** + * Derive the Period from a time grain string, used for annotation truncation. + */ +export function getPeriodFromTimeGrain( + timeGrain: V1TimeGrain | string | undefined, +): Period | undefined { + return TIME_GRAIN[timeGrain ?? ""]?.duration as Period | undefined; +} diff --git a/web-common/src/features/dashboards/time-series/measure-chart/MeasureChart.svelte b/web-common/src/features/dashboards/time-series/measure-chart/MeasureChart.svelte index 761dbf15718..15be6290450 100644 --- a/web-common/src/features/dashboards/time-series/measure-chart/MeasureChart.svelte +++ b/web-common/src/features/dashboards/time-series/measure-chart/MeasureChart.svelte @@ -1,6 +1,6 @@ -
+
{#if !$visible || (isFetching && data.length === 0)}
{:else if data.length > 0} - { - const x = clampX(e.offsetX); - const fractionalIndex = xScale.invert(x); - - hoverState = { - index: fractionalIndex, - screenX: x, - screenY: e.offsetY, - isHovered: true, - }; - - // Update scrub if dragging - if (get(scrubController.state).isScrubbing) { - scrubController.update(x, xScale); - } - - annotationPopover.checkHover(e, annotationGroups, isScrubbing); - mousePageX = e.pageX; - mousePageY = e.pageY; - }} - on:mouseleave={handleSvgMouseLeave} - on:mousedown={handleMouseDown} - on:mouseup={handleMouseUp} - on:click={handleChartClick} - > - - - - - - - - - - - - {#if mode === "line"} - - {:else} - - {/if} - - - {#if !isScrubbing && hoveredPoint} - 0} - isBarMode={mode === "bar"} - visibleStart={0} - visibleEnd={dataLastIndex} - formatter={valueFormatter} - /> - {/if} - - - {#if !isScrubbing && hoveredPoint && (!showComparison || !isLocallyHovered) && !isComparingDimension} - {@const showDelta = - showComparison && - tooltipComparisonValue !== null && - !!tooltipDeltaLabel} - - - - {formatGrainBucket(hoveredPoint.ts, timeGranularity, interval)} - - - {#if showComparison} - - {/if} - - {/if} - - - {#if singleSelectIdx !== null && singleSelectX !== null && isThisMeasureSelected} - {@const selPt = data[singleSelectIdx]} - {#if selPt?.value !== null && selPt?.value !== undefined} - - {/if} - {/if} - - - - {#if isLocallyHovered} - - {/if} - - {#if annotationGroups.length > 0} - - {/if} - - - - {#if !isScrubbing && isLocallyHovered && hoveredPoint && mousePageX !== null && mousePageY !== null && (showComparison || isComparingDimension)} - - {/if} - - {#if annotationGroups.length > 0} - annotationPopover.setPopoverHovered(h)} - /> - {/if} - - - {#if !isScrubbing && explainX !== null} - - measureSelection.startAnomalyExplanationChat(metricsViewName)} - /> - {/if} + {:else}
No data available diff --git a/web-common/src/features/dashboards/time-series/measure-chart/MeasureChartBody.svelte b/web-common/src/features/dashboards/time-series/measure-chart/MeasureChartBody.svelte new file mode 100644 index 00000000000..8b6b6411f82 --- /dev/null +++ b/web-common/src/features/dashboards/time-series/measure-chart/MeasureChartBody.svelte @@ -0,0 +1,580 @@ + + +
+ { + const x = clampX(e.offsetX); + const fractionalIndex = xScale.invert(x); + + hoverState = { + index: fractionalIndex, + screenX: x, + screenY: e.offsetY, + isHovered: true, + }; + + // Update scrub if dragging + if (get(scrubController.state).isScrubbing) { + scrubController.update(x, xScale); + } + + annotationPopover.checkHover(e, annotationGroups, isScrubbing); + mousePageX = e.pageX; + mousePageY = e.pageY; + }} + on:mouseleave={handleSvgMouseLeave} + on:mousedown={handleMouseDown} + on:mouseup={handleMouseUp} + on:click={handleChartClick} + > + + + + + + + + + + + + {#if mode === "line"} + + {:else} + + {/if} + + + {#if !isScrubbing && hoveredPoint} + 0} + isBarMode={mode === "bar"} + visibleStart={0} + visibleEnd={dataLastIndex} + formatter={valueFormatter} + /> + {/if} + + + {#if !isScrubbing && hoveredPoint && (!showComparison || !isLocallyHovered) && !isComparingDimension} + {@const showDelta = + showComparison && + tooltipComparisonValue !== null && + !!tooltipDeltaLabel} + + + + {formatGrainBucket(hoveredPoint.ts, timeGranularity, interval)} + + + {#if showComparison} + + {/if} + + {/if} + + + {#if singleSelectIdx !== null && singleSelectX !== null && isThisMeasureSelected} + {@const selPt = data[singleSelectIdx]} + {#if selPt?.value !== null && selPt?.value !== undefined} + + {/if} + {/if} + + + + {#if isLocallyHovered} + + {/if} + + {#if annotationGroups.length > 0} + + {/if} + + + + {#if !isScrubbing && isLocallyHovered && hoveredPoint && mousePageX !== null && mousePageY !== null && (showComparison || isComparingDimension)} + + {/if} + + {#if annotationGroups.length > 0} + annotationPopover.setPopoverHovered(h)} + /> + {/if} + + + {#if !isScrubbing && explainX !== null} + + measureSelection.startAnomalyExplanationChat(metricsViewName)} + /> + {/if} +
From a902e94d91a09f3ba8d93c599155906ee9402816 Mon Sep 17 00:00:00 2001 From: Brian Holmes Date: Fri, 6 Feb 2026 14:47:33 -0500 Subject: [PATCH 28/36] feedback --- .../time-series-chart/TimeSeriesChart.svelte | 198 ++++++++++++------ .../time-dimension-details/TDDTable.svelte | 12 +- .../TimeDimensionDisplay.svelte | 6 +- .../dashboards/time-dimension-details/util.ts | 15 +- .../MetricsTimeSeriesCharts.svelte | 31 +++ .../measure-chart/MeasureChart.svelte | 2 + .../measure-chart/MeasureChartBody.svelte | 61 +++--- .../MeasureChartHoverTooltip.svelte | 53 ++++- .../MeasureChartPointIndicator.svelte | 16 +- .../measure-chart/MeasureChartScrub.svelte | 46 ---- .../measure-chart/MeasureChartTooltip.svelte | 2 - .../measure-chart/MeasureChartXAxis.svelte | 14 +- .../time-series/measure-chart/hover-index.ts | 15 +- .../time-series/measure-chart/scales.ts | 17 ++ 14 files changed, 311 insertions(+), 177 deletions(-) diff --git a/web-common/src/components/time-series-chart/TimeSeriesChart.svelte b/web-common/src/components/time-series-chart/TimeSeriesChart.svelte index 41028993fbb..1d20f5a8405 100644 --- a/web-common/src/components/time-series-chart/TimeSeriesChart.svelte +++ b/web-common/src/components/time-series-chart/TimeSeriesChart.svelte @@ -8,13 +8,28 @@ ChartScales, } from "@rilldata/web-common/features/dashboards/time-series/measure-chart/types"; + const MAX_BRIDGE_GAP_PX = 40; + + interface Segment { + startIndex: number; + endIndex: number; + } + + interface BridgeResult { + values: (number | null)[]; + bridges: Segment[]; + + inputSegments: Segment[]; + } + + const chartId = Math.random().toString(36).slice(2, 11); + export let series: ChartSeries[]; export let scales: ChartScales; export let hasScrubSelection: boolean = false; export let scrubStartIndex: number | null = null; export let scrubEndIndex: number | null = null; - - const chartId = Math.random().toString(36).slice(2, 11); + export let connectNulls: boolean = true; // Index-based line/area generators — x is just the index $: lineGen = createLineGenerator({ @@ -30,47 +45,30 @@ defined: (d) => d !== null, }); - // Contiguous non-null segments as {startIndex, endIndex} pairs - function computeSegments( - values: (number | null)[], - ): { startIndex: number; endIndex: number }[] { - const segments: { startIndex: number; endIndex: number }[] = []; - let segStart = -1; - for (let i = 0; i < values.length; i++) { - if (values[i] !== null) { - if (segStart === -1) segStart = i; - } else if (segStart !== -1) { - segments.push({ startIndex: segStart, endIndex: i - 1 }); - segStart = -1; - } - } - if (segStart !== -1) - segments.push({ startIndex: segStart, endIndex: values.length - 1 }); - return segments; - } - - function findSingletonIndices(values: (number | null)[]): number[] { - return computeSegments(values) - .filter((s) => s.startIndex === s.endIndex) - .map((s) => s.startIndex); - } - $: primarySeries = series[0]; + // Bridge small gaps for smoother rendering + $: primaryBridgeResult = primarySeries + ? bridgeSmallGaps(primarySeries.values, scales.x, connectNulls) + : { values: [] as (number | null)[], bridges: [], inputSegments: [] }; + $: primaryBridged = primaryBridgeResult.values; + $: primaryBridges = primaryBridgeResult.bridges; + // Direct path computation — no tweening - $: primaryLinePath = primarySeries - ? (lineGen(primarySeries.values) ?? "") - : ""; - $: primaryAreaPath = primarySeries - ? (areaGen(primarySeries.values) ?? "") - : ""; - - $: primarySegments = primarySeries - ? computeSegments(primarySeries.values) - : []; - $: primarySingletons = primarySeries - ? findSingletonIndices(primarySeries.values) - : []; + $: primaryLinePath = primarySeries ? (lineGen(primaryBridged) ?? "") : ""; + $: primaryAreaPath = primarySeries ? (areaGen(primaryBridged) ?? "") : ""; + + // Original segments (real data only) — reused from bridgeSmallGaps + $: primaryRealSegments = primaryBridgeResult.inputSegments; + // Merged segments (real + bridged) — used for area fill clip + $: primarySegments = primarySeries ? computeSegments(primaryBridged) : []; + // Singletons from bridged data — only shown when connectNulls is off + $: primarySingletons = + primarySeries && !connectNulls + ? primarySegments + .filter((s) => s.startIndex === s.endIndex) + .map((s) => s.startIndex) + : []; // Scrub clip region (index-based) $: scrubClipX = @@ -99,6 +97,62 @@ ? "var(--color-gray-50)" : primarySeries.areaGradient.light : "transparent"; + + // Contiguous non-null segments as {startIndex, endIndex} pairs + function computeSegments(values: (number | null)[]): Segment[] { + const segments: Segment[] = []; + let segStart = -1; + for (let i = 0; i < values.length; i++) { + if (values[i] !== null) { + if (segStart === -1) segStart = i; + } else if (segStart !== -1) { + segments.push({ startIndex: segStart, endIndex: i - 1 }); + segStart = -1; + } + } + if (segStart !== -1) + segments.push({ startIndex: segStart, endIndex: values.length - 1 }); + return segments; + } + + /** + * Bridge small gaps between non-null segments by linearly interpolating. + * Returns the interpolated values, the bridged gap regions, and the + * original input segments (so callers don't need to recompute them). + */ + function bridgeSmallGaps( + values: (number | null)[], + xScale: (i: number) => number, + shouldBridge: boolean, + ): BridgeResult { + const inputSegments = computeSegments(values); + + if (!shouldBridge || values.length < 3 || inputSegments.length <= 1) { + return { values, bridges: [], inputSegments }; + } + + const result = [...values]; + const bridges: Segment[] = []; + + for (let i = 0; i < inputSegments.length - 1; i++) { + const prev = inputSegments[i]; + const next = inputSegments[i + 1]; + const gapPx = xScale(next.startIndex) - xScale(prev.endIndex); + + if (gapPx <= MAX_BRIDGE_GAP_PX) { + const v0 = values[prev.endIndex]!; + const v1 = values[next.startIndex]!; + const span = next.startIndex - prev.endIndex; + for (let j = prev.endIndex + 1; j < next.startIndex; j++) { + const t = (j - prev.endIndex) / span; + result[j] = v0 + t * (v1 - v0); + } + bridges.push({ startIndex: prev.endIndex, endIndex: next.startIndex }); + } + } + + return { values: result, bridges, inputSegments }; + } @@ -122,7 +176,26 @@ {/if} {#if primarySeries} + + {#each primaryRealSegments as seg (seg.startIndex)} + {@const x = scales.x(seg.startIndex)} + {@const width = scales.x(seg.endIndex) - x} + + {/each} + + + {#if primaryBridges.length > 0} + + {#each primaryBridges as bridge (bridge.startIndex)} + {@const x = scales.x(bridge.startIndex)} + {@const width = scales.x(bridge.endIndex) - x} + + {/each} + + {/if} + + {#each primarySegments as seg (seg.startIndex)} {@const x = scales.x(seg.startIndex)} {@const width = scales.x(seg.endIndex) - x} @@ -148,14 +221,15 @@ {/if} {#each series.slice(1) as s (s.id)} + {@const bridged = bridgeSmallGaps(s.values, scales.x, connectNulls)} {#if hasScrubSelection && scrubStartIndex !== null && scrubEndIndex !== null} + {#if primarySeries} - + {#if connectNulls} + + + {:else} + + {/if} {#each primarySingletons as idx (idx)} @@ -194,13 +279,6 @@ r={1.5} fill={primaryLineColor} /> - {/each} {/if} @@ -208,13 +286,13 @@ {#if hasScrubSelection && scrubStartIndex !== null && scrubEndIndex !== null && primarySeries} {#if primarySeries.areaGradient} {/if} = highlightedColStart && + colIdx <= highlightedColEnd; const isHighlighted = isRowHighlighted || isColHighlighted; const isDoubleHighlighted = isRowHighlighted && isColHighlighted; diff --git a/web-common/src/features/dashboards/time-series/MetricsTimeSeriesCharts.svelte b/web-common/src/features/dashboards/time-series/MetricsTimeSeriesCharts.svelte index d3bafc1dfa7..78455129d80 100644 --- a/web-common/src/features/dashboards/time-series/MetricsTimeSeriesCharts.svelte +++ b/web-common/src/features/dashboards/time-series/MetricsTimeSeriesCharts.svelte @@ -43,6 +43,14 @@ import { sanitiseExpression } from "@rilldata/web-common/features/dashboards/stores/filter-utils"; import { DateTime, Interval } from "luxon"; import { runtime } from "@rilldata/web-common/runtime-client/runtime-store"; + import MoreHorizontal from "@rilldata/web-common/components/icons/MoreHorizontal.svelte"; + import IconButton from "@rilldata/web-common/components/button/IconButton.svelte"; + import Switch from "@rilldata/web-common/components/forms/Switch.svelte"; + import { + Popover, + PopoverContent, + PopoverTrigger, + } from "@rilldata/web-common/components/popover"; const { rillTime } = featureFlags; @@ -70,6 +78,8 @@ const timeControlsStore = useTimeControlStore(StateManagers); let grainDropdownOpen = false; + let connectNulls = true; + let chartSettingsOpen = false; $: ({ instanceId } = $runtime); @@ -315,6 +325,26 @@ {/if} + + + + + + + + Connect sparse data + (connectNulls = !connectNulls)} + /> + + + {#if !hideStartPivotButton}