From b4190e443b17fec7ab4737ee3a5d1d8acc2d1780 Mon Sep 17 00:00:00 2001 From: graphieros Date: Sat, 7 Feb 2026 22:47:01 +0100 Subject: [PATCH 1/3] chore: bump vue-data-ui from 3.14.8 to 3.14.9 --- package.json | 2 +- pnpm-lock.yaml | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 5c3369f86..62b29500b 100644 --- a/package.json +++ b/package.json @@ -104,7 +104,7 @@ "vite-plugin-pwa": "1.2.0", "vite-plus": "0.0.0-833c515fa25cef20905a7f9affb156dfa6f151ab", "vue": "3.5.27", - "vue-data-ui": "3.14.8" + "vue-data-ui": "3.14.9" }, "devDependencies": { "@e18e/eslint-plugin": "0.1.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d9bc40c43..601179831 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -201,8 +201,8 @@ importers: specifier: 3.5.27 version: 3.5.27(typescript@5.9.3) vue-data-ui: - specifier: 3.14.8 - version: 3.14.8(vue@3.5.27(typescript@5.9.3)) + specifier: 3.14.9 + version: 3.14.9(vue@3.5.27(typescript@5.9.3)) devDependencies: '@e18e/eslint-plugin': specifier: 0.1.4 @@ -9325,8 +9325,8 @@ packages: vue-component-type-helpers@3.2.4: resolution: {integrity: sha512-05lR16HeZDcDpB23ku5b5f1fBOoHqFnMiKRr2CiEvbG5Ux4Yi0McmQBOET0dR0nxDXosxyVqv67q6CzS3AK8rw==} - vue-data-ui@3.14.8: - resolution: {integrity: sha512-nF6klDiXVzL/zs/ENCR+lR/Xan5UvTR+Va6pUCQqgY7v8apID48xmt6KDjBWGSx0hGWd5rB1u2kNXjDexHteKA==} + vue-data-ui@3.14.9: + resolution: {integrity: sha512-ITq2xDK1LC2JrlDw0V17j/KsgVs/TXQEkdC3gPl6dkB4AvX88FsaNU1abGR1D5nXyCxaluPqIOiqSa/qDPDFSg==} peerDependencies: jspdf: '>=3.0.1' vue: '>=3.3.0' @@ -20818,7 +20818,7 @@ snapshots: vue-component-type-helpers@3.2.4: {} - vue-data-ui@3.14.8(vue@3.5.27(typescript@5.9.3)): + vue-data-ui@3.14.9(vue@3.5.27(typescript@5.9.3)): dependencies: vue: 3.5.27(typescript@5.9.3) From fbc6a11cdd0ce4d763ab2ef759d83f3a9ea7c99d Mon Sep 17 00:00:00 2001 From: graphieros Date: Sat, 7 Feb 2026 22:47:30 +0100 Subject: [PATCH 2/3] feat: add en & fr translation --- i18n/locales/en.json | 3 ++- i18n/locales/fr-FR.json | 3 ++- lunaria/files/en-GB.json | 3 ++- lunaria/files/en-US.json | 3 ++- lunaria/files/fr-FR.json | 3 ++- 5 files changed, 10 insertions(+), 5 deletions(-) diff --git a/i18n/locales/en.json b/i18n/locales/en.json index 99718fd4d..a19c77355 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -301,7 +301,8 @@ "loading": "Loading...", "y_axis_label": "{granularity} downloads", "download_file": "Download {fileType}", - "toggle_annotator": "Toggle annotator" + "toggle_annotator": "Toggle annotator", + "legend_estimation": "Estimation" }, "install_scripts": { "title": "Install Scripts", diff --git a/i18n/locales/fr-FR.json b/i18n/locales/fr-FR.json index a9f006fb0..243747649 100644 --- a/i18n/locales/fr-FR.json +++ b/i18n/locales/fr-FR.json @@ -274,7 +274,8 @@ "loading": "Chargement...", "y_axis_label": "Téléchargements {granularity}", "download_file": "Télécharger {fileType}", - "toggle_annotator": "Afficher/Masquer l'annotateur" + "toggle_annotator": "Afficher/Masquer l'annotateur", + "legend_estimation": "Estimation" }, "install_scripts": { "title": "Scripts d'installation", diff --git a/lunaria/files/en-GB.json b/lunaria/files/en-GB.json index 22c6572fa..10cf973e7 100644 --- a/lunaria/files/en-GB.json +++ b/lunaria/files/en-GB.json @@ -301,7 +301,8 @@ "loading": "Loading...", "y_axis_label": "{granularity} downloads", "download_file": "Download {fileType}", - "toggle_annotator": "Toggle annotator" + "toggle_annotator": "Toggle annotator", + "legend_estimation": "Estimation" }, "install_scripts": { "title": "Install Scripts", diff --git a/lunaria/files/en-US.json b/lunaria/files/en-US.json index 99718fd4d..a19c77355 100644 --- a/lunaria/files/en-US.json +++ b/lunaria/files/en-US.json @@ -301,7 +301,8 @@ "loading": "Loading...", "y_axis_label": "{granularity} downloads", "download_file": "Download {fileType}", - "toggle_annotator": "Toggle annotator" + "toggle_annotator": "Toggle annotator", + "legend_estimation": "Estimation" }, "install_scripts": { "title": "Install Scripts", diff --git a/lunaria/files/fr-FR.json b/lunaria/files/fr-FR.json index a9f006fb0..243747649 100644 --- a/lunaria/files/fr-FR.json +++ b/lunaria/files/fr-FR.json @@ -274,7 +274,8 @@ "loading": "Chargement...", "y_axis_label": "Téléchargements {granularity}", "download_file": "Télécharger {fileType}", - "toggle_annotator": "Afficher/Masquer l'annotateur" + "toggle_annotator": "Afficher/Masquer l'annotateur", + "legend_estimation": "Estimation" }, "install_scripts": { "title": "Scripts d'installation", From 36b99b4e4043b3c5ddd633fd539be0fba3f796ff Mon Sep 17 00:00:00 2001 From: graphieros Date: Sat, 7 Feb 2026 22:47:54 +0100 Subject: [PATCH 3/3] feat: add downloads estimations --- app/components/Package/DownloadAnalytics.vue | 644 ++++++++++++++++++- 1 file changed, 615 insertions(+), 29 deletions(-) diff --git a/app/components/Package/DownloadAnalytics.vue b/app/components/Package/DownloadAnalytics.vue index 63f9b8576..cd051da88 100644 --- a/app/components/Package/DownloadAnalytics.vue +++ b/app/components/Package/DownloadAnalytics.vue @@ -30,9 +30,16 @@ const { accentColors, selectedAccentColor } = useAccentColor() const colorMode = useColorMode() const resolvedMode = shallowRef<'light' | 'dark'>('light') const rootEl = shallowRef(null) +const isZoomed = shallowRef(false) + +function setIsZoom({ isZoom }: { isZoom: boolean }) { + isZoomed.value = isZoom +} const { width } = useElementSize(rootEl) +const compactNumberFormatter = useCompactNumberFormatter() + onMounted(async () => { rootEl.value = document.documentElement resolvedMode.value = colorMode.value === 'dark' ? 'dark' : 'light' @@ -308,6 +315,33 @@ const xAxisLabel = computed(() => { const selectedGranularity = shallowRef('weekly') const displayedGranularity = shallowRef('weekly') +const isEndDateOnPeriodEnd = computed(() => { + const g = selectedGranularity.value + if (g !== 'monthly' && g !== 'yearly') return false + + const iso = String(endDate.value ?? '').slice(0, 10) + if (!/^\d{4}-\d{2}-\d{2}$/.test(iso)) return false + + const [year, month, day] = iso.split('-').map(Number) + if (!year || !month || !day) return false + + // Monthly: endDate is the last day of its month (UTC) + if (g === 'monthly') { + const lastDayOfMonth = new Date(Date.UTC(year, month, 0)).getUTCDate() + return day === lastDayOfMonth + } + + // Yearly: endDate is the last day of the year (UTC) + return month === 12 && day === 31 +}) + +const isEstimationGranularity = computed( + () => displayedGranularity.value === 'monthly' || displayedGranularity.value === 'yearly', +) +const shouldRenderEstimationOverlay = computed( + () => !pending.value && isEstimationGranularity.value, +) + const startDate = shallowRef('') // YYYY-MM-DD const endDate = shallowRef('') // YYYY-MM-DD const hasUserEditedDates = shallowRef(false) @@ -750,6 +784,79 @@ const chartData = computed<{ dataset: VueUiXyDatasetItem[] | null; dates: number return { dataset, dates } }) +/** + * Maximum estimated value across all series when the chart is + * displaying a partially completed time bucket (monthly or yearly). + * + * Used to determine whether the Y-axis upper bound must be extended to accommodate extrapolated values. + * It does not mutate chart state or rendering directly. + * + * Behavior: + * - Returns `0` when: + * - the chart is loading (`pending === true`) + * - the current granularity is not `monthly` or `yearly` + * - the dataset is empty or has fewer than two points + * - the last bucket is fully completed + * + * - For partially completed buckets: + * - Computes the bucket completion ratio using UTC boundaries + * - Linearly extrapolates the last datapoint of each series + * - Returns the maximum extrapolated value across all series + * + * The reference time used for completion is: + * - the end of `endDate` (UTC) when provided, or + * - the current time (`Date.now()`) otherwise + * + * @returns The maximum extrapolated value across all series, or `0` when + * estimation is not applicable. + */ +const estimatedMaxFromData = computed(() => { + if (pending.value) return 0 + if (!isEstimationGranularity.value) return 0 + + const dataset = chartData.value.dataset + const dates = chartData.value.dates + if (!dataset?.length || dates.length < 2) return 0 + + const lastBucketTimestampMs = dates[dates.length - 1] ?? 0 + const endDateMs = endDate.value ? endDateOnlyToUtcMs(endDate.value) : null + const referenceMs = endDateMs ?? Date.now() + + const completionRatio = getCompletionRatioForBucket({ + bucketTimestampMs: lastBucketTimestampMs, + granularity: displayedGranularity.value as 'monthly' | 'yearly', + referenceMs, + }) + + if (!(completionRatio > 0 && completionRatio < 1)) return 0 + + let maxEstimated = 0 + + for (const serie of dataset) { + const values = Array.isArray((serie as any).series) ? ((serie as any).series as number[]) : [] + if (values.length < 2) continue + + const lastValue = Number(values[values.length - 1]) + if (!Number.isFinite(lastValue) || lastValue <= 0) continue + + const estimated = lastValue / completionRatio + if (Number.isFinite(estimated) && estimated > maxEstimated) maxEstimated = estimated + } + + return maxEstimated +}) + +const yAxisScaleMax = computed(() => { + if (!isEstimationGranularity.value || pending.value) return undefined + + const datasetMax = getDatasetMaxValue(chartData.value.dataset) + const estimatedMax = estimatedMaxFromData.value + const candidateMax = Math.max(datasetMax, estimatedMax) + + const niceMax = candidateMax > 0 ? niceMaxScale(candidateMax) : 0 + return niceMax > datasetMax ? niceMax : undefined +}) + const loadFile = (link: string, filename: string) => { const a = document.createElement('a') a.href = link @@ -798,7 +905,410 @@ function getGranularityLabel(granularity: ChartTimeGranularity) { return granularityLabels.value[granularity] } -const compactNumberFormatter = useCompactNumberFormatter() +function clampRatio(value: number): number { + if (value < 0) return 0 + if (value > 1) return 1 + return value +} + +/** + * Convert a `YYYY-MM-DD` date to UTC timestamp representing the end of that day. + * The returned timestamp corresponds to `23:59:59.999` in UTC + * + * @param endDateOnly - ISO-like date string (`YYYY-MM-DD`) + * @returns The UTC timestamp in milliseconds for the end of the given day, + * or `null` if the input is invalid. + */ +function endDateOnlyToUtcMs(endDateOnly: string): number | null { + if (!/^\d{4}-\d{2}-\d{2}$/.test(endDateOnly)) return null + const [y, m, d] = endDateOnly.split('-').map(Number) + if (!y || !m || !d) return null + return Date.UTC(y, m - 1, d, 23, 59, 59, 999) +} + +/** + * Computes the UTC timestamp corresponding to the start of the time bucket + * that contains the given timestamp. + * + * This function is used to derive period boundaries when computing completion + * ratios or extrapolating values for partially completed periods. + * + * Bucket boundaries are defined in UTC: + * - **monthly** : first day of the month at `00:00:00.000` UTC + * - **yearly** : January 1st of the year at `00:00:00.000` UTC + * + * @param timestampMs - Reference timestamp in milliseconds + * @param granularity - Bucket granularity (`monthly` or `yearly`) + * @returns The UTC timestamp representing the start of the corresponding + * time bucket. + */ +function getBucketStartUtc(timestampMs: number, granularity: 'monthly' | 'yearly'): number { + const date = new Date(timestampMs) + if (granularity === 'yearly') return Date.UTC(date.getUTCFullYear(), 0, 1, 0, 0, 0, 0) + return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1, 0, 0, 0, 0) +} + +/** + * Computes the UTC timestamp corresponding to the end of the time + * bucket that contains the given timestamp. This end timestamp is paired with `getBucketStartUtc` to define + * a half-open interval `[start, end)` when computing elapsed time or completion + * ratios within a period. + * + * Bucket boundaries are defined in UTC and are **exclusive**: + * - **monthly** : first day of the following month at `00:00:00.000` UTC + * - **yearly** : January 1st of the following year at `00:00:00.000` UTC + * + * @param timestampMs - Reference timestamp in milliseconds + * @param granularity - Bucket granularity (`monthly` or `yearly`) + * @returns The UTC timestamp (in milliseconds) representing the exclusive end + * of the corresponding time bucket. + */ +function getBucketEndUtc(timestampMs: number, granularity: 'monthly' | 'yearly'): number { + const date = new Date(timestampMs) + if (granularity === 'yearly') return Date.UTC(date.getUTCFullYear() + 1, 0, 1, 0, 0, 0, 0) + return Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + 1, 1, 0, 0, 0, 0) +} + +/** + * Computes the completion ratio of a time bucket relative to a reference time. + * + * The ratio represents how much of the bucket’s duration has elapsed at + * `referenceMs`, expressed as a normalized value in the range `[0, 1]`. + * + * The bucket is defined by the calendar period (monthly or yearly) that + * contains `bucketTimestampMs`, using UTC boundaries: + * - start: `getBucketStartUtc(...)` + * - end: `getBucketEndUtc(...)` + * + * The returned value is clamped to `[0, 1]`: + * - `0`: reference time is at or before the start of the bucket + * - `1`: reference time is at or after the end of the bucket + * + * This function is used to detect partially completed periods and to + * extrapolate full period values from partial data. + * + * @param params.bucketTimestampMs - Timestamp belonging to the bucket + * @param params.granularity - Bucket granularity (`monthly` or `yearly`) + * @param params.referenceMs - Reference timestamp used to measure progress + * @returns A normalized completion ratio in the range `[0, 1]`. + */ +function getCompletionRatioForBucket(params: { + bucketTimestampMs: number + granularity: 'monthly' | 'yearly' + referenceMs: number +}): number { + const start = getBucketStartUtc(params.bucketTimestampMs, params.granularity) + const end = getBucketEndUtc(params.bucketTimestampMs, params.granularity) + const total = end - start + if (total <= 0) return 1 + return clampRatio((params.referenceMs - start) / total) +} + +/** + * Returns a "nice" rounded upper bound for a positive value, suitable for + * chart axis scaling. + * + * The value is converted to a power-of-ten range and then rounded up to the + * next monotonic step within that decade (1, 1.25, 1.5, 2, 2.5, 3, 4, 5, 6, 8, 10). + * + * VueUiXy computes its own nice scale from the dataset. + * However, when injecting an estimation for partial datapoints, the scale must be forced to avoid + * overflowing the estimation if it were to become the max value. This scale is fed into the `scaleMax` + * config attribute of VueUiXy. + * + * Examples: + * - `niceMaxScale(2_340)` returns `2_500` + * - `niceMaxScale(7_100)` returns `8_000` + * - `niceMaxScale(12)` returns `12.5` + * + * @param value - Candidate maximum value + * @returns A nice maximum >= `value`, or `0` when `value` is not finite or <= 0. + */ +function niceMaxScale(value: number): number { + const v = Number(value) + if (!Number.isFinite(v) || v <= 0) return 0 + + const exponent = Math.floor(Math.log10(v)) + const base = 10 ** exponent + const fraction = v / base + + // Monotonic scale steps + if (fraction <= 1) return 1 * base + if (fraction <= 1.25) return 1.25 * base + if (fraction <= 1.5) return 1.5 * base + if (fraction <= 2) return 2 * base + if (fraction <= 2.5) return 2.5 * base + if (fraction <= 3) return 3 * base + if (fraction <= 4) return 4 * base + if (fraction <= 5) return 5 * base + if (fraction <= 6) return 6 * base + if (fraction <= 8) return 8 * base + return 10 * base +} + +/** + * Extrapolates the last datapoint of a series when it belongs to a partially + * completed time bucket (monthly or yearly). + * + * The extrapolation assumes that the observed value of the last datapoint + * grows linearly with time within its bucket. The value is scaled by the + * inverse of the bucket completion ratio, and the corresponding y + * coordinate is computed by projecting along the segment defined by the + * previous and last datapoints. + * + * Extrapolation is performed only when: + * - the granularity is `monthly` or `yearly` + * - the bucket completion ratio is strictly between `0` and `1` + * + * In all other cases, the original `lastPoint` is returned unchanged. + * + * The reference time used to compute the completion ratio is: + * - the end of `endDateOnly` (UTC) when provided, or + * - the current time (`Date.now()`) otherwise + * + * @param params.previousPoint - Datapoint immediately preceding the last one + * @param params.lastPoint - Last observed datapoint (potentially incomplete) + * @param params.lastBucketTimestampMs - Timestamp identifying the bucket of the last datapoint + * @param params.granularity - Chart granularity + * @param params.endDateOnly - Optional `YYYY-MM-DD` end date used as a fixed reference time + * @returns A new datapoint representing the extrapolated estimate, or the + * original `lastPoint` when extrapolation is not applicable. + */ +function extrapolateIncompleteLastPoint(params: { + previousPoint: { x: number; y: number; value: number } + lastPoint: { x: number; y: number; value: number; comment?: string } + lastBucketTimestampMs: number + granularity: ChartTimeGranularity + endDateOnly?: string +}) { + if (params.granularity !== 'monthly' && params.granularity !== 'yearly') + return { ...params.lastPoint } + + const endDateMs = params.endDateOnly ? endDateOnlyToUtcMs(params.endDateOnly) : null + const referenceMs = endDateMs ?? Date.now() + + const completionRatio = getCompletionRatioForBucket({ + bucketTimestampMs: params.lastBucketTimestampMs, + granularity: params.granularity, + referenceMs, + }) + + if (!(completionRatio > 0 && completionRatio < 1)) return { ...params.lastPoint } + + const extrapolatedValue = params.lastPoint.value / completionRatio + if (!Number.isFinite(extrapolatedValue)) return { ...params.lastPoint } + + const valueDelta = params.lastPoint.value - params.previousPoint.value + const yDelta = params.lastPoint.y - params.previousPoint.y + + if (valueDelta === 0) + return { ...params.lastPoint, value: extrapolatedValue, comment: 'extrapolated' } + + const valueToYPixelRatio = yDelta / valueDelta + const extrapolatedY = + params.previousPoint.y + (extrapolatedValue - params.previousPoint.value) * valueToYPixelRatio + + return { + x: params.lastPoint.x, + y: extrapolatedY, + value: extrapolatedValue, + comment: 'extrapolated', + } +} + +/** + * Compute the max value across all series in a `VueUiXy` dataset. + * + * @param dataset - Array of `VueUiXyDatasetItem` objects, or `null` + * @returns The maximum finite value found across all series, or `0` when + * the dataset is empty or absent. + */ +function getDatasetMaxValue(dataset: VueUiXyDatasetItem[] | null): number { + if (!dataset?.length) return 0 + let max = 0 + for (const serie of dataset) { + const values = Array.isArray((serie as any).series) ? ((serie as any).series as number[]) : [] + for (const v of values) { + const n = Number(v) + if (Number.isFinite(n) && n > max) max = n + } + } + return max +} + +/** + * Build and return svg markup for estimation overlays on the chart. + * + * This function is used in the `#svg` slot of `VueUiXy` to visually indicate + * estimated values for partially completed monthly or yearly periods. + * + * For each series: + * - extrapolates the last datapoint when it belongs to an incomplete time bucket + * - draws a dashed line from the previous datapoint to the extrapolated position + * - masks the original line segment to avoid visual overlap + * - renders marker circles at relevant points + * - displays a formatted label for the estimated value + * + * While computing estimations, the function also evaluates whether the Y-axis + * scale needs to be extended to accommodate estimated values. When required, + * it commits a deferred `scaleMax` update using `commitYAxisScaleMaxLater`. + * + * The function returns an empty string when: + * - estimation overlays are disabled + * - no valid series or datapoints are available + * + * @param svg - svg context object provided by `VueUiXy` via the `#svg` slot + * @returns A string containing SVG elements to be injected, or an empty string + * when no estimation overlay should be rendered. + */ +function drawEstimationLine(svg: Record) { + if (!shouldRenderEstimationOverlay.value) return '' + + const data = Array.isArray(svg?.data) ? svg.data : [] + if (!data.length) return '' + + // Collect per-series estimates and a global max candidate for the y-axis + const lines: string[] = [] + + // Use the last bucket timestamp once (shared x-axis dates) + const lastBucketTimestampMs = chartData.value?.dates?.at(-1) ?? 0 + + for (const serie of data) { + const plots = serie?.plots + if (!Array.isArray(plots) || plots.length < 2) continue + + const previousPoint = plots.at(-2) + const lastPoint = plots.at(-1) + if (!previousPoint || !lastPoint) continue + + const estimationPoint = extrapolateIncompleteLastPoint({ + previousPoint, + lastPoint, + lastBucketTimestampMs, + granularity: displayedGranularity.value, + endDateOnly: endDate.value, + }) + + const stroke = String(serie?.color ?? colors.value.fg) + + /** + * The following svg elements are injected in the #svg slot of VueUiXy: + * - a dashed line connecting the last datapoint to its ancestor + * - a line overlay covering the path segment of 'real data' between last datapoint and its ancestor + * - circles on the estimation coordinates, and another on the ancestor to mitigate the line overlay + * - the formatted data label + */ + + lines.push(` + + + + + + + ${compactNumberFormatter.value.format(Number.isFinite(estimationPoint.value) ? estimationPoint.value : 0)} + + `) + } + + if (!lines.length) return '' + + return lines.join('\n') +} + +/** + * Build and return svg text label for the last datapoint of each series. + * + * This function is used in the `#svg` slot of `VueUiXy` to render a value label + * next to the final datapoint of each series when the data represents fully + * completed periods (for example, daily or weekly granularities). + * + * For each series: + * - retrieves the last plotted point + * - renders a text label slightly offset to the right of the point + * - formats the value using the compact number formatter + * + * Return an empty string when no series data is available. + * + * @param svg - SVG context object provided by `VueUiXy` via the `#svg` slot + * @returns A string containing SVG `` elements, or an empty string when + * no labels should be rendered. + */ +function drawLastDatapointLabel(svg: Record) { + const data = Array.isArray(svg?.data) ? svg.data : [] + if (!data.length) return '' + + const dataLabels: string[] = [] + + for (const serie of data) { + const lastPlot = serie.plots.at(-1) + + dataLabels.push(` + + ${compactNumberFormatter.value.format(Number.isFinite(lastPlot.value) ? lastPlot.value : 0)} + + `) + } + + return dataLabels.join('\n') +} // VueUiXy chart component configuration const chartConfig = computed(() => { @@ -806,7 +1316,8 @@ const chartConfig = computed(() => { theme: isDarkMode.value ? 'dark' : 'default', chart: { height: isMobile.value ? 950 : 600, - padding: { bottom: 36 }, + backgroundColor: colors.value.bg, + padding: { bottom: 36, right: 100 }, // padding right is set to leave space of last datapoint label(s) userOptions: { buttons: { pdf: false, labels: false, fullscreen: false, table: false, tooltip: false }, buttonTitles: { @@ -843,11 +1354,11 @@ const chartConfig = computed(() => { }, }, }, - backgroundColor: colors.value.bg, grid: { stroke: colors.value.border, labels: { fontSize: isMobile.value ? 24 : 16, + color: pending.value ? colors.value.border : colors.value.fgSubtle, axis: { yLabel: $t('package.downloads.y_axis_label', { granularity: getGranularityLabel(selectedGranularity.value), @@ -867,8 +1378,11 @@ const chartConfig = computed(() => { }, }, yAxis: { - formatter: compactNumberFormatter.value.format, - useNiceScale: true, + formatter: ({ value }: { value: number }) => { + return compactNumberFormatter.value.format(Number.isFinite(value) ? value : 0) + }, + useNiceScale: !isEstimationGranularity.value || pending.value, // daily/weekly -> true, monthly/yearly -> false + scaleMax: yAxisScaleMax.value, gap: 24, // vertical gap between individual series in stacked mode }, }, @@ -896,7 +1410,7 @@ const chartConfig = computed(() => { const hasMultipleItems = items.length > 1 const rows = items - .map((d: any) => { + .map((d: Record) => { const label = String(d?.name ?? '').trim() const raw = Number(d?.value ?? 0) const v = compactNumberFormatter.value.format(Number.isFinite(raw) ? raw : 0) @@ -1046,8 +1560,50 @@ const chartConfig = computed(() => {
-
- +
+ + + + - @@ -1204,4 +1786,8 @@ const chartConfig = computed(() => { left: calc(100% + 2rem) !important; } } + +[data-pending='true'] .vue-data-ui-zoom { + opacity: 0.1; +}