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

fix: only show estimation segment in last datapoint (#1338)

authored by

Alec Lloyd Probert and committed by
GitHub
78e01880 5b126986

+45 -264
+45 -264
app/components/Package/TrendsChart.vue
··· 848 848 return { dataset, dates } 849 849 }) 850 850 851 - const maxDatapoints = computed(() => 852 - Math.max(0, ...(chartData.value.dataset ?? []).map(d => d.series.length)), 853 - ) 854 - 855 - /** 856 - * Maximum estimated value across all series when the chart is 857 - * displaying a partially completed time bucket (monthly or yearly). 858 - * 859 - * Used to determine whether the Y-axis upper bound must be extended to accommodate extrapolated values. 860 - * It does not mutate chart state or rendering directly. 861 - * 862 - * Behavior: 863 - * - Returns `0` when: 864 - * - the chart is loading (`pending === true`) 865 - * - the current granularity is not `monthly` or `yearly` 866 - * - the dataset is empty or has fewer than two points 867 - * - the last bucket is fully completed 868 - * 869 - * - For partially completed buckets: 870 - * - Computes the bucket completion ratio using UTC boundaries 871 - * - Linearly extrapolates the last datapoint of each series 872 - * - Returns the maximum extrapolated value across all series 873 - * 874 - * The reference time used for completion is: 875 - * - the end of `endDate` (UTC) when provided, or 876 - * - the current time (`Date.now()`) otherwise 877 - * 878 - * @returns The maximum extrapolated value across all series, or `0` when 879 - * estimation is not applicable. 880 - */ 881 - const estimatedMaxFromData = computed<number>(() => { 882 - if (pending.value) return 0 883 - if (!isEstimationGranularity.value) return 0 884 - 885 - const dataset = chartData.value.dataset 886 - const dates = chartData.value.dates 887 - if (!dataset?.length || dates.length < 2) return 0 888 - 889 - const lastBucketTimestampMs = dates[dates.length - 1] ?? 0 890 - const endDateMs = endDate.value ? endDateOnlyToUtcMs(endDate.value) : null 891 - const referenceMs = endDateMs ?? Date.now() 892 - 893 - const completionRatio = getCompletionRatioForBucket({ 894 - bucketTimestampMs: lastBucketTimestampMs, 895 - granularity: displayedGranularity.value as 'monthly' | 'yearly', 896 - referenceMs, 851 + const normalisedDataset = computed(() => { 852 + return chartData.value.dataset?.map(d => { 853 + return { 854 + ...d, 855 + series: [...d.series.slice(0, -1), extrapolateLastValue(d.series.at(-1) ?? 0)], 856 + } 897 857 }) 898 - 899 - if (!(completionRatio > 0 && completionRatio < 1)) return 0 900 - 901 - let maxEstimated = 0 902 - 903 - for (const serie of dataset) { 904 - const values = Array.isArray((serie as any).series) ? ((serie as any).series as number[]) : [] 905 - if (values.length < 2) continue 906 - 907 - const lastValue = Number(values[values.length - 1]) 908 - if (!Number.isFinite(lastValue) || lastValue <= 0) continue 909 - 910 - const estimated = lastValue / completionRatio 911 - if (Number.isFinite(estimated) && estimated > maxEstimated) maxEstimated = estimated 912 - } 913 - 914 - return maxEstimated 915 858 }) 916 859 917 - const yAxisScaleMax = computed<number | undefined>(() => { 918 - if (!isEstimationGranularity.value || pending.value) return undefined 919 - 920 - const datasetMax = getDatasetMaxValue(chartData.value.dataset) 921 - const estimatedMax = estimatedMaxFromData.value 922 - const candidateMax = Math.max(datasetMax, estimatedMax) 923 - 924 - const niceMax = candidateMax > 0 ? niceMaxScale(candidateMax) : 0 925 - return niceMax > datasetMax ? niceMax : undefined 926 - }) 860 + const maxDatapoints = computed(() => 861 + Math.max(0, ...(chartData.value.dataset ?? []).map(d => d.series.length)), 862 + ) 927 863 928 864 const loadFile = (link: string, filename: string) => { 929 865 const a = document.createElement('a') ··· 1073 1009 } 1074 1010 1075 1011 /** 1076 - * Returns a "nice" rounded upper bound for a positive value, suitable for 1077 - * chart axis scaling. 1012 + * Extrapolate the last observed value of a time series when the last bucket 1013 + * (month or year) is only partially complete. 1078 1014 * 1079 - * The value is converted to a power-of-ten range and then rounded up to the 1080 - * next monotonic step within that decade (1, 1.25, 1.5, 2, 2.5, 3, 4, 5, 6, 8, 10). 1015 + * This is used to replace the final value in each `VueUiXy` series 1016 + * before rendering, so the chart can display an estimated full-period value 1017 + * for the current month or year. 1081 1018 * 1082 - * VueUiXy computes its own nice scale from the dataset. 1083 - * However, when injecting an estimation for partial datapoints, the scale must be forced to avoid 1084 - * overflowing the estimation if it were to become the max value. This scale is fed into the `scaleMax` 1085 - * config attribute of VueUiXy. 1019 + * Notes: 1020 + * - This function assumes `lastValue` is the value corresponding to the last 1021 + * date in `chartData.value.dates` 1086 1022 * 1087 - * Examples: 1088 - * - `niceMaxScale(2_340)` returns `2_500` 1089 - * - `niceMaxScale(7_100)` returns `8_000` 1090 - * - `niceMaxScale(12)` returns `12.5` 1091 - * 1092 - * @param value - Candidate maximum value 1093 - * @returns A nice maximum >= `value`, or `0` when `value` is not finite or <= 0. 1023 + * @param lastValue - The last observed numeric value for a series. 1024 + * @returns The extrapolated value for partially completed monthly or yearly granularities, 1025 + * or the original `lastValue` when no extrapolation should be applied. 1094 1026 */ 1095 - function niceMaxScale(value: number): number { 1096 - const v = Number(value) 1097 - if (!Number.isFinite(v) || v <= 0) return 0 1027 + function extrapolateLastValue(lastValue: number) { 1028 + if (displayedGranularity.value !== 'monthly' && displayedGranularity.value !== 'yearly') 1029 + return lastValue 1098 1030 1099 - const exponent = Math.floor(Math.log10(v)) 1100 - const base = 10 ** exponent 1101 - const fraction = v / base 1102 - 1103 - // Monotonic scale steps 1104 - if (fraction <= 1) return 1 * base 1105 - if (fraction <= 1.25) return 1.25 * base 1106 - if (fraction <= 1.5) return 1.5 * base 1107 - if (fraction <= 2) return 2 * base 1108 - if (fraction <= 2.5) return 2.5 * base 1109 - if (fraction <= 3) return 3 * base 1110 - if (fraction <= 4) return 4 * base 1111 - if (fraction <= 5) return 5 * base 1112 - if (fraction <= 6) return 6 * base 1113 - if (fraction <= 8) return 8 * base 1114 - return 10 * base 1115 - } 1116 - 1117 - /** 1118 - * Extrapolates the last datapoint of a series when it belongs to a partially 1119 - * completed time bucket (monthly or yearly). 1120 - * 1121 - * The extrapolation assumes that the observed value of the last datapoint 1122 - * grows linearly with time within its bucket. The value is scaled by the 1123 - * inverse of the bucket completion ratio, and the corresponding y 1124 - * coordinate is computed by projecting along the segment defined by the 1125 - * previous and last datapoints. 1126 - * 1127 - * Extrapolation is performed only when: 1128 - * - the granularity is `monthly` or `yearly` 1129 - * - the bucket completion ratio is strictly between `0` and `1` 1130 - * 1131 - * In all other cases, the original `lastPoint` is returned unchanged. 1132 - * 1133 - * The reference time used to compute the completion ratio is: 1134 - * - the end of `endDateOnly` (UTC) when provided, or 1135 - * - the current time (`Date.now()`) otherwise 1136 - * 1137 - * @param params.previousPoint - Datapoint immediately preceding the last one 1138 - * @param params.lastPoint - Last observed datapoint (potentially incomplete) 1139 - * @param params.lastBucketTimestampMs - Timestamp identifying the bucket of the last datapoint 1140 - * @param params.granularity - Chart granularity 1141 - * @param params.endDateOnly - Optional `YYYY-MM-DD` end date used as a fixed reference time 1142 - * @returns A new datapoint representing the extrapolated estimate, or the 1143 - * original `lastPoint` when extrapolation is not applicable. 1144 - */ 1145 - function extrapolateIncompleteLastPoint(params: { 1146 - previousPoint: { x: number; y: number; value: number } 1147 - lastPoint: { x: number; y: number; value: number; comment?: string } 1148 - lastBucketTimestampMs: number 1149 - granularity: ChartTimeGranularity 1150 - endDateOnly?: string 1151 - }) { 1152 - if (params.granularity !== 'monthly' && params.granularity !== 'yearly') 1153 - return { ...params.lastPoint } 1154 - 1155 - const endDateMs = params.endDateOnly ? endDateOnlyToUtcMs(params.endDateOnly) : null 1031 + const endDateMs = endDate.value ? endDateOnlyToUtcMs(endDate.value) : null 1156 1032 const referenceMs = endDateMs ?? Date.now() 1157 1033 1158 1034 const completionRatio = getCompletionRatioForBucket({ 1159 - bucketTimestampMs: params.lastBucketTimestampMs, 1160 - granularity: params.granularity, 1035 + bucketTimestampMs: chartData.value.dates.at(-1) ?? 0, 1036 + granularity: displayedGranularity.value, 1161 1037 referenceMs, 1162 1038 }) 1163 1039 1164 - if (!(completionRatio > 0 && completionRatio < 1)) return { ...params.lastPoint } 1040 + if (!(completionRatio > 0 && completionRatio < 1)) return lastValue 1165 1041 1166 - const extrapolatedValue = params.lastPoint.value / completionRatio 1167 - if (!Number.isFinite(extrapolatedValue)) return { ...params.lastPoint } 1042 + const extrapolatedValue = lastValue / completionRatio 1043 + if (!Number.isFinite(extrapolatedValue)) return lastValue 1168 1044 1169 - const valueDelta = params.lastPoint.value - params.previousPoint.value 1170 - const yDelta = params.lastPoint.y - params.previousPoint.y 1171 - 1172 - if (valueDelta === 0) 1173 - return { ...params.lastPoint, value: extrapolatedValue, comment: 'extrapolated' } 1174 - 1175 - const valueToYPixelRatio = yDelta / valueDelta 1176 - const extrapolatedY = 1177 - params.previousPoint.y + (extrapolatedValue - params.previousPoint.value) * valueToYPixelRatio 1178 - 1179 - return { 1180 - x: params.lastPoint.x, 1181 - y: extrapolatedY, 1182 - value: extrapolatedValue, 1183 - comment: 'extrapolated', 1184 - } 1185 - } 1186 - 1187 - /** 1188 - * Compute the max value across all series in a `VueUiXy` dataset. 1189 - * 1190 - * @param dataset - Array of `VueUiXyDatasetItem` objects, or `null` 1191 - * @returns The maximum finite value found across all series, or `0` when 1192 - * the dataset is empty or absent. 1193 - */ 1194 - function getDatasetMaxValue(dataset: VueUiXyDatasetItem[] | null): number { 1195 - if (!dataset?.length) return 0 1196 - let max = 0 1197 - for (const serie of dataset) { 1198 - const values = Array.isArray((serie as any).series) ? ((serie as any).series as number[]) : [] 1199 - for (const v of values) { 1200 - const n = Number(v) 1201 - if (Number.isFinite(n) && n > max) max = n 1202 - } 1203 - } 1204 - return max 1045 + return extrapolatedValue 1205 1046 } 1206 1047 1207 1048 /** 1208 1049 * Build and return svg markup for estimation overlays on the chart. 1209 1050 * 1210 - * This function is used in the `#svg` slot of `VueUiXy` to visually indicate 1211 - * estimated values for partially completed monthly or yearly periods. 1212 - * 1213 - * For each series: 1214 - * - extrapolates the last datapoint when it belongs to an incomplete time bucket 1215 - * - draws a dashed line from the previous datapoint to the extrapolated position 1216 - * - masks the original line segment to avoid visual overlap 1217 - * - renders marker circles at relevant points 1218 - * - displays a formatted label for the estimated value 1219 - * 1220 - * While computing estimations, the function also evaluates whether the Y-axis 1221 - * scale needs to be extended to accommodate estimated values. When required, 1222 - * it commits a deferred `scaleMax` update using `commitYAxisScaleMaxLater`. 1051 + * This function is used in the `#svg` slot of `VueUiXy` to draw a dashed line 1052 + * between the last datapoint and its ancestor, for partial month or year. 1223 1053 * 1224 1054 * The function returns an empty string when: 1225 1055 * - estimation overlays are disabled ··· 1238 1068 // Collect per-series estimates and a global max candidate for the y-axis 1239 1069 const lines: string[] = [] 1240 1070 1241 - // Use the last bucket timestamp once (shared x-axis dates) 1242 - const lastBucketTimestampMs = chartData.value?.dates?.at(-1) ?? 0 1243 - 1244 1071 for (const serie of data) { 1245 1072 const plots = serie?.plots 1246 1073 if (!Array.isArray(plots) || plots.length < 2) continue ··· 1249 1076 const lastPoint = plots.at(-1) 1250 1077 if (!previousPoint || !lastPoint) continue 1251 1078 1252 - const estimationPoint = extrapolateIncompleteLastPoint({ 1253 - previousPoint, 1254 - lastPoint, 1255 - lastBucketTimestampMs, 1256 - granularity: displayedGranularity.value, 1257 - endDateOnly: endDate.value, 1258 - }) 1259 - 1260 1079 const stroke = String(serie?.color ?? colors.value.fg) 1261 1080 1262 1081 /** 1263 1082 * The following svg elements are injected in the #svg slot of VueUiXy: 1083 + * - a line overlay covering the plain path bewteen the last datapoint and its ancestor 1264 1084 * - a dashed line connecting the last datapoint to its ancestor 1265 - * - a line overlay covering the path segment of 'real data' between last datapoint and its ancestor 1266 - * - circles on the estimation coordinates, and another on the ancestor to mitigate the line overlay 1267 - * - the formatted data label 1085 + * - a circle for the last datapoint 1268 1086 */ 1269 1087 1270 1088 lines.push(` 1271 - <line 1089 + <line 1272 1090 x1="${previousPoint.x}" 1273 1091 y1="${previousPoint.y}" 1274 1092 x2="${lastPoint.x}" 1275 - y2="${estimationPoint.y}" 1276 - stroke="${stroke}" 1093 + y2="${lastPoint.y}" 1094 + stroke="${colors.value.bg}" 1277 1095 stroke-width="3" 1278 - stroke-dasharray="4 8" 1279 - stroke-linecap="round" 1096 + opacity="1" 1280 1097 /> 1281 - <line 1098 + <line 1282 1099 x1="${previousPoint.x}" 1283 1100 y1="${previousPoint.y}" 1284 1101 x2="${lastPoint.x}" 1285 1102 y2="${lastPoint.y}" 1286 - stroke="${colors.value.bg}" 1103 + stroke="${stroke}" 1287 1104 stroke-width="3" 1288 - opacity="0.7" 1105 + stroke-dasharray="4 8" 1106 + stroke-linecap="round" 1289 1107 /> 1290 1108 <circle 1291 1109 cx="${lastPoint.x}" 1292 1110 cy="${lastPoint.y}" 1293 1111 r="4" 1294 - fill="${colors.value.bg}" 1295 - opacity="0.7" 1296 - /> 1297 - <circle 1298 - cx="${lastPoint.x}" 1299 - cy="${estimationPoint.y}" 1300 - r="4" 1301 1112 fill="${stroke}" 1302 1113 stroke="${colors.value.bg}" 1303 1114 stroke-width="2" 1304 1115 /> 1305 - <circle 1306 - cx="${previousPoint.x}" 1307 - cy="${previousPoint.y}" 1308 - r="4" 1309 - fill="${stroke}" 1310 - stroke="${colors.value.bg}" 1311 - stroke-width="2" 1312 - /> 1313 - <text 1314 - text-anchor="start" 1315 - dominant-baseline="middle" 1316 - x="${lastPoint.x + 12}" 1317 - y="${estimationPoint.y}" 1318 - font-size="24" 1319 - fill="${colors.value.fg}" 1320 - stroke="${colors.value.bg}" 1321 - stroke-width="1" 1322 - paint-order="stroke fill" 1323 - > 1324 - ${compactNumberFormatter.value.format(Number.isFinite(estimationPoint.value) ? estimationPoint.value : 0)} 1325 - </text> 1326 1116 `) 1327 1117 } 1328 1118 ··· 1556 1346 formatter: ({ value }: { value: number }) => { 1557 1347 return compactNumberFormatter.value.format(Number.isFinite(value) ? value : 0) 1558 1348 }, 1559 - useNiceScale: !isEstimationGranularity.value || pending.value, // daily/weekly -> true, monthly/yearly -> false 1560 - scaleMax: yAxisScaleMax.value, 1349 + useNiceScale: true, // daily/weekly -> true, monthly/yearly -> false 1561 1350 gap: 24, // vertical gap between individual series in stacked mode 1562 1351 }, 1563 1352 }, ··· 1745 1534 <ClientOnly v-if="chartData.dataset"> 1746 1535 <div :data-pending="pending" :data-minimap-visible="maxDatapoints > 6"> 1747 1536 <VueUiXy 1748 - :dataset="chartData.dataset" 1537 + :dataset="normalisedDataset" 1749 1538 :config="chartConfig" 1750 1539 class="[direction:ltr]" 1751 1540 @zoomStart="setIsZoom" ··· 1766 1555 /> 1767 1556 1768 1557 <!-- Last value label for all other cases --> 1769 - <g 1770 - v-if=" 1771 - !pending && 1772 - (['daily', 'weekly'].includes(displayedGranularity) || 1773 - isEndDateOnPeriodEnd || 1774 - isZoomed) 1775 - " 1776 - v-html="drawLastDatapointLabel(svg)" 1777 - /> 1558 + <g v-if="!pending" v-html="drawLastDatapointLabel(svg)" /> 1778 1559 1779 1560 <!-- Inject legend during SVG print only --> 1780 1561 <g v-if="svg.isPrintingSvg" v-html="drawSvgPrintLegend(svg)" />