[READ-ONLY] a fast, modern browser for the npm registry
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add downloads estimations on large charts (#1177)

authored by

Alec Lloyd Probert and committed by
GitHub
a0ea7889 eb1aa46d

+631 -40
+615 -29
app/components/Package/DownloadAnalytics.vue
··· 30 30 const colorMode = useColorMode() 31 31 const resolvedMode = shallowRef<'light' | 'dark'>('light') 32 32 const rootEl = shallowRef<HTMLElement | null>(null) 33 + const isZoomed = shallowRef(false) 34 + 35 + function setIsZoom({ isZoom }: { isZoom: boolean }) { 36 + isZoomed.value = isZoom 37 + } 33 38 34 39 const { width } = useElementSize(rootEl) 40 + 41 + const compactNumberFormatter = useCompactNumberFormatter() 35 42 36 43 onMounted(async () => { 37 44 rootEl.value = document.documentElement ··· 307 314 308 315 const selectedGranularity = shallowRef<ChartTimeGranularity>('weekly') 309 316 const displayedGranularity = shallowRef<ChartTimeGranularity>('weekly') 317 + 318 + const isEndDateOnPeriodEnd = computed(() => { 319 + const g = selectedGranularity.value 320 + if (g !== 'monthly' && g !== 'yearly') return false 321 + 322 + const iso = String(endDate.value ?? '').slice(0, 10) 323 + if (!/^\d{4}-\d{2}-\d{2}$/.test(iso)) return false 324 + 325 + const [year, month, day] = iso.split('-').map(Number) 326 + if (!year || !month || !day) return false 327 + 328 + // Monthly: endDate is the last day of its month (UTC) 329 + if (g === 'monthly') { 330 + const lastDayOfMonth = new Date(Date.UTC(year, month, 0)).getUTCDate() 331 + return day === lastDayOfMonth 332 + } 333 + 334 + // Yearly: endDate is the last day of the year (UTC) 335 + return month === 12 && day === 31 336 + }) 337 + 338 + const isEstimationGranularity = computed( 339 + () => displayedGranularity.value === 'monthly' || displayedGranularity.value === 'yearly', 340 + ) 341 + const shouldRenderEstimationOverlay = computed( 342 + () => !pending.value && isEstimationGranularity.value, 343 + ) 310 344 311 345 const startDate = shallowRef<string>('') // YYYY-MM-DD 312 346 const endDate = shallowRef<string>('') // YYYY-MM-DD ··· 750 784 return { dataset, dates } 751 785 }) 752 786 787 + /** 788 + * Maximum estimated value across all series when the chart is 789 + * displaying a partially completed time bucket (monthly or yearly). 790 + * 791 + * Used to determine whether the Y-axis upper bound must be extended to accommodate extrapolated values. 792 + * It does not mutate chart state or rendering directly. 793 + * 794 + * Behavior: 795 + * - Returns `0` when: 796 + * - the chart is loading (`pending === true`) 797 + * - the current granularity is not `monthly` or `yearly` 798 + * - the dataset is empty or has fewer than two points 799 + * - the last bucket is fully completed 800 + * 801 + * - For partially completed buckets: 802 + * - Computes the bucket completion ratio using UTC boundaries 803 + * - Linearly extrapolates the last datapoint of each series 804 + * - Returns the maximum extrapolated value across all series 805 + * 806 + * The reference time used for completion is: 807 + * - the end of `endDate` (UTC) when provided, or 808 + * - the current time (`Date.now()`) otherwise 809 + * 810 + * @returns The maximum extrapolated value across all series, or `0` when 811 + * estimation is not applicable. 812 + */ 813 + const estimatedMaxFromData = computed<number>(() => { 814 + if (pending.value) return 0 815 + if (!isEstimationGranularity.value) return 0 816 + 817 + const dataset = chartData.value.dataset 818 + const dates = chartData.value.dates 819 + if (!dataset?.length || dates.length < 2) return 0 820 + 821 + const lastBucketTimestampMs = dates[dates.length - 1] ?? 0 822 + const endDateMs = endDate.value ? endDateOnlyToUtcMs(endDate.value) : null 823 + const referenceMs = endDateMs ?? Date.now() 824 + 825 + const completionRatio = getCompletionRatioForBucket({ 826 + bucketTimestampMs: lastBucketTimestampMs, 827 + granularity: displayedGranularity.value as 'monthly' | 'yearly', 828 + referenceMs, 829 + }) 830 + 831 + if (!(completionRatio > 0 && completionRatio < 1)) return 0 832 + 833 + let maxEstimated = 0 834 + 835 + for (const serie of dataset) { 836 + const values = Array.isArray((serie as any).series) ? ((serie as any).series as number[]) : [] 837 + if (values.length < 2) continue 838 + 839 + const lastValue = Number(values[values.length - 1]) 840 + if (!Number.isFinite(lastValue) || lastValue <= 0) continue 841 + 842 + const estimated = lastValue / completionRatio 843 + if (Number.isFinite(estimated) && estimated > maxEstimated) maxEstimated = estimated 844 + } 845 + 846 + return maxEstimated 847 + }) 848 + 849 + const yAxisScaleMax = computed<number | undefined>(() => { 850 + if (!isEstimationGranularity.value || pending.value) return undefined 851 + 852 + const datasetMax = getDatasetMaxValue(chartData.value.dataset) 853 + const estimatedMax = estimatedMaxFromData.value 854 + const candidateMax = Math.max(datasetMax, estimatedMax) 855 + 856 + const niceMax = candidateMax > 0 ? niceMaxScale(candidateMax) : 0 857 + return niceMax > datasetMax ? niceMax : undefined 858 + }) 859 + 753 860 const loadFile = (link: string, filename: string) => { 754 861 const a = document.createElement('a') 755 862 a.href = link ··· 798 905 return granularityLabels.value[granularity] 799 906 } 800 907 801 - const compactNumberFormatter = useCompactNumberFormatter() 908 + function clampRatio(value: number): number { 909 + if (value < 0) return 0 910 + if (value > 1) return 1 911 + return value 912 + } 913 + 914 + /** 915 + * Convert a `YYYY-MM-DD` date to UTC timestamp representing the end of that day. 916 + * The returned timestamp corresponds to `23:59:59.999` in UTC 917 + * 918 + * @param endDateOnly - ISO-like date string (`YYYY-MM-DD`) 919 + * @returns The UTC timestamp in milliseconds for the end of the given day, 920 + * or `null` if the input is invalid. 921 + */ 922 + function endDateOnlyToUtcMs(endDateOnly: string): number | null { 923 + if (!/^\d{4}-\d{2}-\d{2}$/.test(endDateOnly)) return null 924 + const [y, m, d] = endDateOnly.split('-').map(Number) 925 + if (!y || !m || !d) return null 926 + return Date.UTC(y, m - 1, d, 23, 59, 59, 999) 927 + } 928 + 929 + /** 930 + * Computes the UTC timestamp corresponding to the start of the time bucket 931 + * that contains the given timestamp. 932 + * 933 + * This function is used to derive period boundaries when computing completion 934 + * ratios or extrapolating values for partially completed periods. 935 + * 936 + * Bucket boundaries are defined in UTC: 937 + * - **monthly** : first day of the month at `00:00:00.000` UTC 938 + * - **yearly** : January 1st of the year at `00:00:00.000` UTC 939 + * 940 + * @param timestampMs - Reference timestamp in milliseconds 941 + * @param granularity - Bucket granularity (`monthly` or `yearly`) 942 + * @returns The UTC timestamp representing the start of the corresponding 943 + * time bucket. 944 + */ 945 + function getBucketStartUtc(timestampMs: number, granularity: 'monthly' | 'yearly'): number { 946 + const date = new Date(timestampMs) 947 + if (granularity === 'yearly') return Date.UTC(date.getUTCFullYear(), 0, 1, 0, 0, 0, 0) 948 + return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1, 0, 0, 0, 0) 949 + } 950 + 951 + /** 952 + * Computes the UTC timestamp corresponding to the end of the time 953 + * bucket that contains the given timestamp. This end timestamp is paired with `getBucketStartUtc` to define 954 + * a half-open interval `[start, end)` when computing elapsed time or completion 955 + * ratios within a period. 956 + * 957 + * Bucket boundaries are defined in UTC and are **exclusive**: 958 + * - **monthly** : first day of the following month at `00:00:00.000` UTC 959 + * - **yearly** : January 1st of the following year at `00:00:00.000` UTC 960 + * 961 + * @param timestampMs - Reference timestamp in milliseconds 962 + * @param granularity - Bucket granularity (`monthly` or `yearly`) 963 + * @returns The UTC timestamp (in milliseconds) representing the exclusive end 964 + * of the corresponding time bucket. 965 + */ 966 + function getBucketEndUtc(timestampMs: number, granularity: 'monthly' | 'yearly'): number { 967 + const date = new Date(timestampMs) 968 + if (granularity === 'yearly') return Date.UTC(date.getUTCFullYear() + 1, 0, 1, 0, 0, 0, 0) 969 + return Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + 1, 1, 0, 0, 0, 0) 970 + } 971 + 972 + /** 973 + * Computes the completion ratio of a time bucket relative to a reference time. 974 + * 975 + * The ratio represents how much of the bucket’s duration has elapsed at 976 + * `referenceMs`, expressed as a normalized value in the range `[0, 1]`. 977 + * 978 + * The bucket is defined by the calendar period (monthly or yearly) that 979 + * contains `bucketTimestampMs`, using UTC boundaries: 980 + * - start: `getBucketStartUtc(...)` 981 + * - end: `getBucketEndUtc(...)` 982 + * 983 + * The returned value is clamped to `[0, 1]`: 984 + * - `0`: reference time is at or before the start of the bucket 985 + * - `1`: reference time is at or after the end of the bucket 986 + * 987 + * This function is used to detect partially completed periods and to 988 + * extrapolate full period values from partial data. 989 + * 990 + * @param params.bucketTimestampMs - Timestamp belonging to the bucket 991 + * @param params.granularity - Bucket granularity (`monthly` or `yearly`) 992 + * @param params.referenceMs - Reference timestamp used to measure progress 993 + * @returns A normalized completion ratio in the range `[0, 1]`. 994 + */ 995 + function getCompletionRatioForBucket(params: { 996 + bucketTimestampMs: number 997 + granularity: 'monthly' | 'yearly' 998 + referenceMs: number 999 + }): number { 1000 + const start = getBucketStartUtc(params.bucketTimestampMs, params.granularity) 1001 + const end = getBucketEndUtc(params.bucketTimestampMs, params.granularity) 1002 + const total = end - start 1003 + if (total <= 0) return 1 1004 + return clampRatio((params.referenceMs - start) / total) 1005 + } 1006 + 1007 + /** 1008 + * Returns a "nice" rounded upper bound for a positive value, suitable for 1009 + * chart axis scaling. 1010 + * 1011 + * The value is converted to a power-of-ten range and then rounded up to the 1012 + * next monotonic step within that decade (1, 1.25, 1.5, 2, 2.5, 3, 4, 5, 6, 8, 10). 1013 + * 1014 + * VueUiXy computes its own nice scale from the dataset. 1015 + * However, when injecting an estimation for partial datapoints, the scale must be forced to avoid 1016 + * overflowing the estimation if it were to become the max value. This scale is fed into the `scaleMax` 1017 + * config attribute of VueUiXy. 1018 + * 1019 + * Examples: 1020 + * - `niceMaxScale(2_340)` returns `2_500` 1021 + * - `niceMaxScale(7_100)` returns `8_000` 1022 + * - `niceMaxScale(12)` returns `12.5` 1023 + * 1024 + * @param value - Candidate maximum value 1025 + * @returns A nice maximum >= `value`, or `0` when `value` is not finite or <= 0. 1026 + */ 1027 + function niceMaxScale(value: number): number { 1028 + const v = Number(value) 1029 + if (!Number.isFinite(v) || v <= 0) return 0 1030 + 1031 + const exponent = Math.floor(Math.log10(v)) 1032 + const base = 10 ** exponent 1033 + const fraction = v / base 1034 + 1035 + // Monotonic scale steps 1036 + if (fraction <= 1) return 1 * base 1037 + if (fraction <= 1.25) return 1.25 * base 1038 + if (fraction <= 1.5) return 1.5 * base 1039 + if (fraction <= 2) return 2 * base 1040 + if (fraction <= 2.5) return 2.5 * base 1041 + if (fraction <= 3) return 3 * base 1042 + if (fraction <= 4) return 4 * base 1043 + if (fraction <= 5) return 5 * base 1044 + if (fraction <= 6) return 6 * base 1045 + if (fraction <= 8) return 8 * base 1046 + return 10 * base 1047 + } 1048 + 1049 + /** 1050 + * Extrapolates the last datapoint of a series when it belongs to a partially 1051 + * completed time bucket (monthly or yearly). 1052 + * 1053 + * The extrapolation assumes that the observed value of the last datapoint 1054 + * grows linearly with time within its bucket. The value is scaled by the 1055 + * inverse of the bucket completion ratio, and the corresponding y 1056 + * coordinate is computed by projecting along the segment defined by the 1057 + * previous and last datapoints. 1058 + * 1059 + * Extrapolation is performed only when: 1060 + * - the granularity is `monthly` or `yearly` 1061 + * - the bucket completion ratio is strictly between `0` and `1` 1062 + * 1063 + * In all other cases, the original `lastPoint` is returned unchanged. 1064 + * 1065 + * The reference time used to compute the completion ratio is: 1066 + * - the end of `endDateOnly` (UTC) when provided, or 1067 + * - the current time (`Date.now()`) otherwise 1068 + * 1069 + * @param params.previousPoint - Datapoint immediately preceding the last one 1070 + * @param params.lastPoint - Last observed datapoint (potentially incomplete) 1071 + * @param params.lastBucketTimestampMs - Timestamp identifying the bucket of the last datapoint 1072 + * @param params.granularity - Chart granularity 1073 + * @param params.endDateOnly - Optional `YYYY-MM-DD` end date used as a fixed reference time 1074 + * @returns A new datapoint representing the extrapolated estimate, or the 1075 + * original `lastPoint` when extrapolation is not applicable. 1076 + */ 1077 + function extrapolateIncompleteLastPoint(params: { 1078 + previousPoint: { x: number; y: number; value: number } 1079 + lastPoint: { x: number; y: number; value: number; comment?: string } 1080 + lastBucketTimestampMs: number 1081 + granularity: ChartTimeGranularity 1082 + endDateOnly?: string 1083 + }) { 1084 + if (params.granularity !== 'monthly' && params.granularity !== 'yearly') 1085 + return { ...params.lastPoint } 1086 + 1087 + const endDateMs = params.endDateOnly ? endDateOnlyToUtcMs(params.endDateOnly) : null 1088 + const referenceMs = endDateMs ?? Date.now() 1089 + 1090 + const completionRatio = getCompletionRatioForBucket({ 1091 + bucketTimestampMs: params.lastBucketTimestampMs, 1092 + granularity: params.granularity, 1093 + referenceMs, 1094 + }) 1095 + 1096 + if (!(completionRatio > 0 && completionRatio < 1)) return { ...params.lastPoint } 1097 + 1098 + const extrapolatedValue = params.lastPoint.value / completionRatio 1099 + if (!Number.isFinite(extrapolatedValue)) return { ...params.lastPoint } 1100 + 1101 + const valueDelta = params.lastPoint.value - params.previousPoint.value 1102 + const yDelta = params.lastPoint.y - params.previousPoint.y 1103 + 1104 + if (valueDelta === 0) 1105 + return { ...params.lastPoint, value: extrapolatedValue, comment: 'extrapolated' } 1106 + 1107 + const valueToYPixelRatio = yDelta / valueDelta 1108 + const extrapolatedY = 1109 + params.previousPoint.y + (extrapolatedValue - params.previousPoint.value) * valueToYPixelRatio 1110 + 1111 + return { 1112 + x: params.lastPoint.x, 1113 + y: extrapolatedY, 1114 + value: extrapolatedValue, 1115 + comment: 'extrapolated', 1116 + } 1117 + } 1118 + 1119 + /** 1120 + * Compute the max value across all series in a `VueUiXy` dataset. 1121 + * 1122 + * @param dataset - Array of `VueUiXyDatasetItem` objects, or `null` 1123 + * @returns The maximum finite value found across all series, or `0` when 1124 + * the dataset is empty or absent. 1125 + */ 1126 + function getDatasetMaxValue(dataset: VueUiXyDatasetItem[] | null): number { 1127 + if (!dataset?.length) return 0 1128 + let max = 0 1129 + for (const serie of dataset) { 1130 + const values = Array.isArray((serie as any).series) ? ((serie as any).series as number[]) : [] 1131 + for (const v of values) { 1132 + const n = Number(v) 1133 + if (Number.isFinite(n) && n > max) max = n 1134 + } 1135 + } 1136 + return max 1137 + } 1138 + 1139 + /** 1140 + * Build and return svg markup for estimation overlays on the chart. 1141 + * 1142 + * This function is used in the `#svg` slot of `VueUiXy` to visually indicate 1143 + * estimated values for partially completed monthly or yearly periods. 1144 + * 1145 + * For each series: 1146 + * - extrapolates the last datapoint when it belongs to an incomplete time bucket 1147 + * - draws a dashed line from the previous datapoint to the extrapolated position 1148 + * - masks the original line segment to avoid visual overlap 1149 + * - renders marker circles at relevant points 1150 + * - displays a formatted label for the estimated value 1151 + * 1152 + * While computing estimations, the function also evaluates whether the Y-axis 1153 + * scale needs to be extended to accommodate estimated values. When required, 1154 + * it commits a deferred `scaleMax` update using `commitYAxisScaleMaxLater`. 1155 + * 1156 + * The function returns an empty string when: 1157 + * - estimation overlays are disabled 1158 + * - no valid series or datapoints are available 1159 + * 1160 + * @param svg - svg context object provided by `VueUiXy` via the `#svg` slot 1161 + * @returns A string containing SVG elements to be injected, or an empty string 1162 + * when no estimation overlay should be rendered. 1163 + */ 1164 + function drawEstimationLine(svg: Record<string, any>) { 1165 + if (!shouldRenderEstimationOverlay.value) return '' 1166 + 1167 + const data = Array.isArray(svg?.data) ? svg.data : [] 1168 + if (!data.length) return '' 1169 + 1170 + // Collect per-series estimates and a global max candidate for the y-axis 1171 + const lines: string[] = [] 1172 + 1173 + // Use the last bucket timestamp once (shared x-axis dates) 1174 + const lastBucketTimestampMs = chartData.value?.dates?.at(-1) ?? 0 1175 + 1176 + for (const serie of data) { 1177 + const plots = serie?.plots 1178 + if (!Array.isArray(plots) || plots.length < 2) continue 1179 + 1180 + const previousPoint = plots.at(-2) 1181 + const lastPoint = plots.at(-1) 1182 + if (!previousPoint || !lastPoint) continue 1183 + 1184 + const estimationPoint = extrapolateIncompleteLastPoint({ 1185 + previousPoint, 1186 + lastPoint, 1187 + lastBucketTimestampMs, 1188 + granularity: displayedGranularity.value, 1189 + endDateOnly: endDate.value, 1190 + }) 1191 + 1192 + const stroke = String(serie?.color ?? colors.value.fg) 1193 + 1194 + /** 1195 + * The following svg elements are injected in the #svg slot of VueUiXy: 1196 + * - a dashed line connecting the last datapoint to its ancestor 1197 + * - a line overlay covering the path segment of 'real data' between last datapoint and its ancestor 1198 + * - circles on the estimation coordinates, and another on the ancestor to mitigate the line overlay 1199 + * - the formatted data label 1200 + */ 1201 + 1202 + lines.push(` 1203 + <line 1204 + x1="${previousPoint.x}" 1205 + y1="${previousPoint.y}" 1206 + x2="${lastPoint.x}" 1207 + y2="${estimationPoint.y}" 1208 + stroke="${stroke}" 1209 + stroke-width="3" 1210 + stroke-dasharray="4 8" 1211 + stroke-linecap="round" 1212 + /> 1213 + <line 1214 + x1="${previousPoint.x}" 1215 + y1="${previousPoint.y}" 1216 + x2="${lastPoint.x}" 1217 + y2="${lastPoint.y}" 1218 + stroke="${colors.value.bg}" 1219 + stroke-width="3" 1220 + opacity="0.7" 1221 + /> 1222 + <circle 1223 + cx="${lastPoint.x}" 1224 + cy="${lastPoint.y}" 1225 + r="4" 1226 + fill="${colors.value.bg}" 1227 + opacity="0.7" 1228 + /> 1229 + <circle 1230 + cx="${lastPoint.x}" 1231 + cy="${estimationPoint.y}" 1232 + r="4" 1233 + fill="${stroke}" 1234 + stroke="${colors.value.bg}" 1235 + stroke-width="2" 1236 + /> 1237 + <circle 1238 + cx="${previousPoint.x}" 1239 + cy="${previousPoint.y}" 1240 + r="4" 1241 + fill="${stroke}" 1242 + stroke="${colors.value.bg}" 1243 + stroke-width="2" 1244 + /> 1245 + <text 1246 + text-anchor="start" 1247 + dominant-baseline="middle" 1248 + x="${lastPoint.x + 12}" 1249 + y="${estimationPoint.y}" 1250 + font-size="24" 1251 + fill="${colors.value.fg}" 1252 + stroke="${colors.value.bg}" 1253 + stroke-width="1" 1254 + paint-order="stroke fill" 1255 + > 1256 + ${compactNumberFormatter.value.format(Number.isFinite(estimationPoint.value) ? estimationPoint.value : 0)} 1257 + </text> 1258 + `) 1259 + } 1260 + 1261 + if (!lines.length) return '' 1262 + 1263 + return lines.join('\n') 1264 + } 1265 + 1266 + /** 1267 + * Build and return svg text label for the last datapoint of each series. 1268 + * 1269 + * This function is used in the `#svg` slot of `VueUiXy` to render a value label 1270 + * next to the final datapoint of each series when the data represents fully 1271 + * completed periods (for example, daily or weekly granularities). 1272 + * 1273 + * For each series: 1274 + * - retrieves the last plotted point 1275 + * - renders a text label slightly offset to the right of the point 1276 + * - formats the value using the compact number formatter 1277 + * 1278 + * Return an empty string when no series data is available. 1279 + * 1280 + * @param svg - SVG context object provided by `VueUiXy` via the `#svg` slot 1281 + * @returns A string containing SVG `<text>` elements, or an empty string when 1282 + * no labels should be rendered. 1283 + */ 1284 + function drawLastDatapointLabel(svg: Record<string, any>) { 1285 + const data = Array.isArray(svg?.data) ? svg.data : [] 1286 + if (!data.length) return '' 1287 + 1288 + const dataLabels: string[] = [] 1289 + 1290 + for (const serie of data) { 1291 + const lastPlot = serie.plots.at(-1) 1292 + 1293 + dataLabels.push(` 1294 + <text 1295 + text-anchor="start" 1296 + dominant-baseline="middle" 1297 + x="${lastPlot.x + 12}" 1298 + y="${lastPlot.y}" 1299 + font-size="24" 1300 + fill="${colors.value.fg}" 1301 + stroke="${colors.value.bg}" 1302 + stroke-width="1" 1303 + paint-order="stroke fill" 1304 + > 1305 + ${compactNumberFormatter.value.format(Number.isFinite(lastPlot.value) ? lastPlot.value : 0)} 1306 + </text> 1307 + `) 1308 + } 1309 + 1310 + return dataLabels.join('\n') 1311 + } 802 1312 803 1313 // VueUiXy chart component configuration 804 1314 const chartConfig = computed(() => { ··· 806 1316 theme: isDarkMode.value ? 'dark' : 'default', 807 1317 chart: { 808 1318 height: isMobile.value ? 950 : 600, 809 - padding: { bottom: 36 }, 1319 + backgroundColor: colors.value.bg, 1320 + padding: { bottom: 36, right: 100 }, // padding right is set to leave space of last datapoint label(s) 810 1321 userOptions: { 811 1322 buttons: { pdf: false, labels: false, fullscreen: false, table: false, tooltip: false }, 812 1323 buttonTitles: { ··· 843 1354 }, 844 1355 }, 845 1356 }, 846 - backgroundColor: colors.value.bg, 847 1357 grid: { 848 1358 stroke: colors.value.border, 849 1359 labels: { 850 1360 fontSize: isMobile.value ? 24 : 16, 1361 + color: pending.value ? colors.value.border : colors.value.fgSubtle, 851 1362 axis: { 852 1363 yLabel: $t('package.downloads.y_axis_label', { 853 1364 granularity: getGranularityLabel(selectedGranularity.value), ··· 867 1378 }, 868 1379 }, 869 1380 yAxis: { 870 - formatter: compactNumberFormatter.value.format, 871 - useNiceScale: true, 1381 + formatter: ({ value }: { value: number }) => { 1382 + return compactNumberFormatter.value.format(Number.isFinite(value) ? value : 0) 1383 + }, 1384 + useNiceScale: !isEstimationGranularity.value || pending.value, // daily/weekly -> true, monthly/yearly -> false 1385 + scaleMax: yAxisScaleMax.value, 872 1386 gap: 24, // vertical gap between individual series in stacked mode 873 1387 }, 874 1388 }, ··· 896 1410 const hasMultipleItems = items.length > 1 897 1411 898 1412 const rows = items 899 - .map((d: any) => { 1413 + .map((d: Record<string, any>) => { 900 1414 const label = String(d?.name ?? '').trim() 901 1415 const raw = Number(d?.value ?? 0) 902 1416 const v = compactNumberFormatter.value.format(Number.isFinite(raw) ? raw : 0) ··· 1046 1560 1047 1561 <div role="region" aria-labelledby="download-analytics-title"> 1048 1562 <ClientOnly v-if="chartData.dataset"> 1049 - <div> 1050 - <VueUiXy :dataset="chartData.dataset" :config="chartConfig" class="[direction:ltr]"> 1563 + <div :data-pending="pending"> 1564 + <VueUiXy 1565 + :dataset="chartData.dataset" 1566 + :config="chartConfig" 1567 + class="[direction:ltr]" 1568 + @zoomStart="setIsZoom" 1569 + @zoomEnd="setIsZoom" 1570 + @zoomReset="isZoomed = false" 1571 + > 1572 + <!-- Injecting custom svg elements --> 1573 + <template #svg="{ svg }"> 1574 + <!-- Estimation lines for monthly & yearly granularities when the end date induces a downwards trend --> 1575 + <g 1576 + v-if=" 1577 + !pending && 1578 + ['monthly', 'yearly'].includes(displayedGranularity) && 1579 + !isEndDateOnPeriodEnd && 1580 + !isZoomed 1581 + " 1582 + v-html="drawEstimationLine(svg)" 1583 + /> 1584 + 1585 + <!-- Last value label for all other cases --> 1586 + <g 1587 + v-if=" 1588 + !pending && 1589 + (['daily', 'weekly'].includes(displayedGranularity) || 1590 + isEndDateOnPeriodEnd || 1591 + isZoomed) 1592 + " 1593 + v-html="drawLastDatapointLabel(svg)" 1594 + /> 1595 + 1596 + <!-- Overlay covering the chart area to hide line resizing when switching granularities recalculates VueUiXy scaleMax when estimation lines are necessary --> 1597 + <rect 1598 + v-if="pending" 1599 + :x="svg.drawingArea.left" 1600 + :y="svg.drawingArea.top - 12" 1601 + :width="svg.drawingArea.width + 12" 1602 + :height="svg.drawingArea.height + 24" 1603 + :fill="colors.bg" 1604 + /> 1605 + </template> 1606 + 1051 1607 <!-- Subtle gradient applied for a unique series (chart modal) --> 1052 1608 <template #area-gradient="{ series: chartModalSeries, id: gradientId }"> 1053 1609 <linearGradient :id="gradientId" x1="0" x2="0" y1="0" y2="1"> ··· 1057 1613 </template> 1058 1614 1059 1615 <!-- Custom legend for multiple series --> 1060 - <template v-if="isMultiPackageMode" #legend="{ legend }"> 1616 + <template 1617 + v-if="isMultiPackageMode || ['monthly', 'yearly'].includes(displayedGranularity)" 1618 + #legend="{ legend }" 1619 + > 1061 1620 <div class="flex gap-4 flex-wrap justify-center"> 1062 - <button 1063 - v-for="datapoint in legend" 1064 - :key="datapoint.name" 1065 - :aria-pressed="datapoint.isSegregated" 1066 - :aria-label="datapoint.name" 1067 - type="button" 1621 + <template v-if="isMultiPackageMode"> 1622 + <button 1623 + v-for="datapoint in legend" 1624 + :key="datapoint.name" 1625 + :aria-pressed="datapoint.isSegregated" 1626 + :aria-label="datapoint.name" 1627 + type="button" 1628 + class="flex gap-1 place-items-center" 1629 + @click="datapoint.segregate()" 1630 + > 1631 + <div class="h-3 w-3"> 1632 + <svg viewBox="0 0 2 2" class="w-full"> 1633 + <rect x="0" y="0" width="2" height="2" rx="0.3" :fill="datapoint.color" /> 1634 + </svg> 1635 + </div> 1636 + <span 1637 + :style="{ 1638 + textDecoration: datapoint.isSegregated ? 'line-through' : undefined, 1639 + }" 1640 + > 1641 + {{ datapoint.name }} 1642 + </span> 1643 + </button> 1644 + </template> 1645 + 1646 + <!-- Estimation extra legend item --> 1647 + <div 1068 1648 class="flex gap-1 place-items-center" 1069 - @click="datapoint.segregate()" 1649 + v-if="['monthly', 'yearly'].includes(selectedGranularity)" 1070 1650 > 1071 - <div class="h-3 w-3"> 1072 - <svg viewBox="0 0 2 2" class="w-full"> 1073 - <rect x="0" y="0" width="2" height="2" rx="0.3" :fill="datapoint.color" /> 1074 - </svg> 1075 - </div> 1076 - <span 1077 - :style="{ 1078 - textDecoration: datapoint.isSegregated ? 'line-through' : undefined, 1079 - }" 1080 - > 1081 - {{ datapoint.name }} 1082 - </span> 1083 - </button> 1651 + <svg viewBox="0 0 20 2" width="20"> 1652 + <line 1653 + x1="0" 1654 + y1="1" 1655 + x2="20" 1656 + y2="1" 1657 + :stroke="colors.fg" 1658 + stroke-dasharray="4" 1659 + stroke-linecap="round" 1660 + /> 1661 + </svg> 1662 + <span class="text-fg-subtle">{{ 1663 + $t('package.downloads.legend_estimation') 1664 + }}</span> 1665 + </div> 1084 1666 </div> 1085 1667 </template> 1086 1668 ··· 1203 1785 top: -0.6rem !important; 1204 1786 left: calc(100% + 2rem) !important; 1205 1787 } 1788 + } 1789 + 1790 + [data-pending='true'] .vue-data-ui-zoom { 1791 + opacity: 0.1; 1206 1792 } 1207 1793 </style>
+2 -1
i18n/locales/en.json
··· 301 301 "loading": "Loading...", 302 302 "y_axis_label": "{granularity} downloads", 303 303 "download_file": "Download {fileType}", 304 - "toggle_annotator": "Toggle annotator" 304 + "toggle_annotator": "Toggle annotator", 305 + "legend_estimation": "Estimation" 305 306 }, 306 307 "install_scripts": { 307 308 "title": "Install Scripts",
+2 -1
i18n/locales/fr-FR.json
··· 274 274 "loading": "Chargement...", 275 275 "y_axis_label": "Téléchargements {granularity}", 276 276 "download_file": "Télécharger {fileType}", 277 - "toggle_annotator": "Afficher/Masquer l'annotateur" 277 + "toggle_annotator": "Afficher/Masquer l'annotateur", 278 + "legend_estimation": "Estimation" 278 279 }, 279 280 "install_scripts": { 280 281 "title": "Scripts d'installation",
+2 -1
lunaria/files/en-GB.json
··· 301 301 "loading": "Loading...", 302 302 "y_axis_label": "{granularity} downloads", 303 303 "download_file": "Download {fileType}", 304 - "toggle_annotator": "Toggle annotator" 304 + "toggle_annotator": "Toggle annotator", 305 + "legend_estimation": "Estimation" 305 306 }, 306 307 "install_scripts": { 307 308 "title": "Install Scripts",
+2 -1
lunaria/files/en-US.json
··· 301 301 "loading": "Loading...", 302 302 "y_axis_label": "{granularity} downloads", 303 303 "download_file": "Download {fileType}", 304 - "toggle_annotator": "Toggle annotator" 304 + "toggle_annotator": "Toggle annotator", 305 + "legend_estimation": "Estimation" 305 306 }, 306 307 "install_scripts": { 307 308 "title": "Install Scripts",
+2 -1
lunaria/files/fr-FR.json
··· 274 274 "loading": "Chargement...", 275 275 "y_axis_label": "Téléchargements {granularity}", 276 276 "download_file": "Télécharger {fileType}", 277 - "toggle_annotator": "Afficher/Masquer l'annotateur" 277 + "toggle_annotator": "Afficher/Masquer l'annotateur", 278 + "legend_estimation": "Estimation" 278 279 }, 279 280 "install_scripts": { 280 281 "title": "Scripts d'installation",
+1 -1
package.json
··· 105 105 "vite-plugin-pwa": "1.2.0", 106 106 "vite-plus": "0.0.0-833c515fa25cef20905a7f9affb156dfa6f151ab", 107 107 "vue": "3.5.27", 108 - "vue-data-ui": "3.14.8" 108 + "vue-data-ui": "3.14.9" 109 109 }, 110 110 "devDependencies": { 111 111 "@e18e/eslint-plugin": "0.1.4",
+5 -5
pnpm-lock.yaml
··· 204 204 specifier: 3.5.27 205 205 version: 3.5.27(typescript@5.9.3) 206 206 vue-data-ui: 207 - specifier: 3.14.8 208 - version: 3.14.8(vue@3.5.27(typescript@5.9.3)) 207 + specifier: 3.14.9 208 + version: 3.14.9(vue@3.5.27(typescript@5.9.3)) 209 209 devDependencies: 210 210 '@e18e/eslint-plugin': 211 211 specifier: 0.1.4 ··· 9328 9328 vue-component-type-helpers@3.2.4: 9329 9329 resolution: {integrity: sha512-05lR16HeZDcDpB23ku5b5f1fBOoHqFnMiKRr2CiEvbG5Ux4Yi0McmQBOET0dR0nxDXosxyVqv67q6CzS3AK8rw==} 9330 9330 9331 - vue-data-ui@3.14.8: 9332 - resolution: {integrity: sha512-nF6klDiXVzL/zs/ENCR+lR/Xan5UvTR+Va6pUCQqgY7v8apID48xmt6KDjBWGSx0hGWd5rB1u2kNXjDexHteKA==} 9331 + vue-data-ui@3.14.9: 9332 + resolution: {integrity: sha512-ITq2xDK1LC2JrlDw0V17j/KsgVs/TXQEkdC3gPl6dkB4AvX88FsaNU1abGR1D5nXyCxaluPqIOiqSa/qDPDFSg==} 9333 9333 peerDependencies: 9334 9334 jspdf: '>=3.0.1' 9335 9335 vue: '>=3.3.0' ··· 20821 20821 20822 20822 vue-component-type-helpers@3.2.4: {} 20823 20823 20824 - vue-data-ui@3.14.8(vue@3.5.27(typescript@5.9.3)): 20824 + vue-data-ui@3.14.9(vue@3.5.27(typescript@5.9.3)): 20825 20825 dependencies: 20826 20826 vue: 3.5.27(typescript@5.9.3) 20827 20827