Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 6 additions & 13 deletions app/components/Chart/SplitSparkline.vue
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ const props = defineProps<{
dataset?: Array<
VueUiXyDatasetItem & {
color?: string
series: number[]
series: Array<number | null>
dashIndices?: number[]
}
>
Expand All @@ -29,7 +29,6 @@ const props = defineProps<{
day: string
}
showLastDatapointEstimation: boolean
nullifyZeroValues?: boolean
largePackageSeries?: boolean[]
granularity: ChartTimeGranularity
}>()
Expand Down Expand Up @@ -94,12 +93,13 @@ function formatDataLabel(seriesIndex: number, value: number): string {
}
}

if (rawLastValue == null) return $t('package.trends.no_data_short')
return numberFormatter.value.format(value)
}

// Hover | kbd interaction: show the actual value at the hovered index
const rawValue = series[selectedIndex.value]
if (isLargeSeries(seriesIndex) && isMissingDownloadValue(rawValue)) {
if (rawValue == null || (isLargeSeries(seriesIndex) && isMissingDownloadValue(rawValue))) {
return $t('package.trends.no_data_short')
}

Expand All @@ -124,14 +124,7 @@ const datasets = computed<VueUiSparklineDatasetItem[][]>(() => {

return {
period,
value:
rawValue === 0
? props.nullifyZeroValues && hasNoDataTail
? index === props.dates.length - 1
? 0
: null
: 0
: rawValue || (index === props.dates.length - 1 ? 0 : null),
value: rawValue ?? null,
}
})
})
Expand Down Expand Up @@ -234,9 +227,9 @@ const configs = computed(() => {
strokeWidth: 1,
dashIndices,
dashArray: 3,
cutNullValues: false,
cutNullValues: true,
nullDashes: {
show: true,
show: false,
},
},
plot: {
Expand Down
86 changes: 67 additions & 19 deletions app/components/Package/TrendsChart.vue
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,13 @@ import {
buildTrendsChartData,
isWeeklyDataset,
getTrendsDatetimeFormatterOptions,
nullifyZeroValues,
isMissingDownloadValue,
isLargeDownloadSeries,
MULTI_PACKAGE_MODE_TOP_PADDING,
} from '#shared/utils/trends-chart'
import { downloadFileLink } from '~/utils/download'
import { useCopyChartPng } from '~/composables/useCopyChartPng'
import { getDownloadChartGaps } from '#shared/utils/download-chart-gaps'
import { createLastDatapointLabelsSvg } from '#shared/utils/download-chart-last-label'

import('vue-data-ui/style.css')
Expand Down Expand Up @@ -72,6 +72,7 @@ const { copy, copied } = useClipboard()

const colorMode = useColorMode()
const resolvedMode = shallowRef<'light' | 'dark'>('light')
const gapPatternId = useId()
const rootEl = shallowRef<HTMLElement | null>(null)
const isZoomed = shallowRef(false)
const zoomStartIndex = shallowRef(0)
Expand Down Expand Up @@ -200,17 +201,11 @@ const normalisedDataset = computed(() => {
data.forEach((item, index) => {
const packageName = getPackageNameForSeriesIndex(index)

item.series = nullifyZeroValues({
enabled: selectedMetric.value === DEFAULT_METRIC_ID && isLargePackage.value[packageName],
values: item.series,
keepLastZero: false,
}) as number[]

if (selectedMetric.value !== DEFAULT_METRIC_ID || !item.series.length) return

const rawLastValue = chartData.value.dataset?.[index]?.series?.at(-1)

if (!isLargePackage.value[packageName] && isMissingDownloadValue(rawLastValue)) {
if (!isLargePackage.value[packageName] && rawLastValue === 0) {
const series = item.series as Array<number | null>
series[series.length - 1] = 0
}
Expand Down Expand Up @@ -354,10 +349,18 @@ const supportsEstimation = computed(
selectedMetric.value !== 'contributors',
)

const hasDownloadAnomalies = computed(() =>
normalisedDataset.value?.some(
datapoint => !!datapoint?.dashIndices?.length || datapoint?.series?.some(d => d == null),
),
const hasDashedSegments = computed(() =>
normalisedDataset.value.some((datapoint, index) => {
if (datapoint.dashIndices?.length) return true

return (
selectedMetric.value === DEFAULT_METRIC_ID &&
isLargePackage.value[getPackageNameForSeriesIndex(index)] === true &&
datapoint.series.length > 1 &&
isMissingDownloadValue(datapoint.series.at(-1)) &&
datapoint.series.slice(0, -1).some(value => !isMissingDownloadValue(value))
)
}),
)

const shouldRenderEstimationOverlay = computed(() => !pending.value && supportsEstimation.value)
Expand Down Expand Up @@ -1095,7 +1098,7 @@ function drawEstimationLine(svg: Record<string, any>) {

const previousPoint = plots.at(-2)
const lastPoint = plots.at(-1)
if (!previousPoint || !lastPoint) continue
if (previousPoint?.value == null || lastPoint?.value == null) continue

const stroke = String(serie?.color ?? colors.value.fg)

Expand Down Expand Up @@ -1206,6 +1209,7 @@ function drawLastDatapointLabel(svg: VueUiXySvgSlotProps['svg']) {

return createLastDatapointLabelsSvg({
series: labelSeries,
missingValueLabel: isDownloadsMetric.value ? $t('package.trends.no_data_short') : undefined,
drawingArea: svg.drawingArea,
svgWidth: svg.width,
fontSize: 20,
Expand Down Expand Up @@ -1262,7 +1266,7 @@ function drawSvgPrintLegend(svg: Record<string, any>) {
// Inject the estimation legend item when necessary
if (
(supportsEstimation.value && !isEndDateOnPeriodEnd.value && !isZoomed.value) ||
hasDownloadAnomalies.value
hasDashedSegments.value
) {
seriesNames.push(`
<line
Expand Down Expand Up @@ -1441,8 +1445,9 @@ const chartConfig = computed<VueUiXyConfig>(() => {
: datapoint?.value
const isNoDataValue =
selectedMetric.value === DEFAULT_METRIC_ID &&
isLargePackage.value[packageName] === true &&
isMissingDownloadValue(rawDataValue)
(rawDataValue == null ||
(isLargePackage.value[packageName] === true &&
isMissingDownloadValue(rawDataValue)))
const rawValue = Number(datapoint?.value ?? 0)
const value = isNoDataValue
? $t('package.trends.no_data_short')
Expand Down Expand Up @@ -1909,7 +1914,6 @@ const copyEmbedUrl = () => copyEmbed(embedUrl.value)
:dates="chartData.dates"
:datetimeFormatterOptions
:showLastDatapointEstimation="shouldRenderEstimationOverlay && !isEndDateOnPeriodEnd"
:nullify-zero-values="selectedMetric === DEFAULT_METRIC_ID"
:granularity="selectedGranularity"
:large-package-series="largePackageSeries"
/>
Expand Down Expand Up @@ -1942,7 +1946,51 @@ const copyEmbedUrl = () => copyEmbed(embedUrl.value)
</p>
</template>

<!-- Injecting custom svg elements -->
<!-- Injecting custom svg elements on the bottom layer -->
<template #svg-underlayer="{ svg }">
<g v-if="!pending && isDownloadsMetric" pointer-events="none" aria-hidden="true">
<template v-for="(gap, index) in getDownloadChartGaps(svg)" :key="index">
<defs>
<pattern
:id="`${gapPatternId}-${index}`"
width="6"
height="6"
patternUnits="userSpaceOnUse"
patternTransform="rotate(45)"
>
<line
x1="0"
y1="0"
x2="0"
y2="10"
:stroke="colors.borderHover"
stroke-width="5"
/>
</pattern>
</defs>
<rect
:x="gap.x"
:y="gap.y"
:width="gap.width"
:height="gap.height"
:fill="`url(#${gapPatternId}-${index})`"
opacity="0.45"
/>
<line
v-for="x in [gap.x, gap.x + gap.width]"
:key="x"
:x1="x"
:x2="x"
:y1="gap.y"
:y2="gap.y + gap.height"
:stroke="colors.borderHover"
stroke-width="0.5"
/>
</template>
</g>
</template>

<!-- Injecting custom svg elements on the top layer -->
<template #svg="{ svg }">
<!-- Estimation lines for monthly & yearly granularities when the end date induces a downwards trend -->
<g
Expand Down Expand Up @@ -2051,7 +2099,7 @@ const copyEmbedUrl = () => copyEmbed(embedUrl.value)
<!-- Estimation extra legend item -->
<div
class="flex shrink-0 items-center gap-1 whitespace-nowrap"
v-if="supportsEstimation || hasDownloadAnomalies"
v-if="supportsEstimation || hasDashedSegments"
>
<svg viewBox="0 0 20 2" width="20" class="shrink-0">
<line
Expand Down
52 changes: 34 additions & 18 deletions app/utils/chart-data-prediction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,28 +148,44 @@ export interface DataPipelineContext {
* 2. Data correction – smoothing & averaging
*/
export function applyDataPipeline(
series: number[],
series: Array<number | null>,
settings: DataPipelineSettings,
ctx: DataPipelineContext,
): number[] {
): Array<number | null> {
if (series.length === 0) return series

// Step 1: prediction
let s = series
if (!ctx.isAbsoluteMetric) {
const projected = extrapolateLastValue({
series,
granularity: ctx.granularity,
lastDateMs: ctx.lastDateMs,
referenceMs: ctx.referenceMs,
predictionPoints: settings.predictionPoints,
// Keep gaps at their original indices and process each continuous run independently.
const result = [...series]
let start = 0
while (start < series.length) {
if (series[start] === null) {
start += 1
continue
}

let end = start
while (end < series.length && series[end] !== null) end += 1
const run = series.slice(start, end) as number[]

// Only the run containing the final bucket can have a partial endpoint.
if (end === series.length && !ctx.isAbsoluteMetric) {
run[run.length - 1] = extrapolateLastValue({
series: run,
granularity: ctx.granularity,
lastDateMs: ctx.lastDateMs,
referenceMs: ctx.referenceMs,
predictionPoints: settings.predictionPoints,
})
}

const corrected = applyDataCorrection(
run.map(value => ({ value })),
settings,
)
corrected.forEach((point, index) => {
result[start + index] = point.value
})
s = [...series.slice(0, -1), projected]
start = end
}

// Step 2: smoothing & averaging
return applyDataCorrection(
s.map(value => ({ value })),
settings,
).map(d => d.value)
return result
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@
"vite-plugin-pwa": "2.0.0",
"vite-plus": "catalog:vite-plus",
"vue": "3.5.42",
"vue-data-ui": "3.27.1",
"vue-data-ui": "3.28.1",
"vue-router": "5.2.0"
},
"devDependencies": {
Expand Down
10 changes: 5 additions & 5 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion pnpm-workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ minimumReleaseAgeExclude:
- '@nuxt/nitro-server@4.5.2'
- '@nuxt/schema@4.5.2'
- '@nuxt/vite-builder@4.5.2'
- vue-data-ui@3.27.1
- vue-data-ui@3.28.1

minimumReleaseAgeExcludePrune: true

Expand Down
Loading
Loading