···3030const colorMode = useColorMode()
3131const resolvedMode = shallowRef<'light' | 'dark'>('light')
3232const rootEl = shallowRef<HTMLElement | null>(null)
3333+const isZoomed = shallowRef(false)
3434+3535+function setIsZoom({ isZoom }: { isZoom: boolean }) {
3636+ isZoomed.value = isZoom
3737+}
33383439const { width } = useElementSize(rootEl)
4040+4141+const compactNumberFormatter = useCompactNumberFormatter()
35423643onMounted(async () => {
3744 rootEl.value = document.documentElement
···307314308315const selectedGranularity = shallowRef<ChartTimeGranularity>('weekly')
309316const displayedGranularity = shallowRef<ChartTimeGranularity>('weekly')
317317+318318+const isEndDateOnPeriodEnd = computed(() => {
319319+ const g = selectedGranularity.value
320320+ if (g !== 'monthly' && g !== 'yearly') return false
321321+322322+ const iso = String(endDate.value ?? '').slice(0, 10)
323323+ if (!/^\d{4}-\d{2}-\d{2}$/.test(iso)) return false
324324+325325+ const [year, month, day] = iso.split('-').map(Number)
326326+ if (!year || !month || !day) return false
327327+328328+ // Monthly: endDate is the last day of its month (UTC)
329329+ if (g === 'monthly') {
330330+ const lastDayOfMonth = new Date(Date.UTC(year, month, 0)).getUTCDate()
331331+ return day === lastDayOfMonth
332332+ }
333333+334334+ // Yearly: endDate is the last day of the year (UTC)
335335+ return month === 12 && day === 31
336336+})
337337+338338+const isEstimationGranularity = computed(
339339+ () => displayedGranularity.value === 'monthly' || displayedGranularity.value === 'yearly',
340340+)
341341+const shouldRenderEstimationOverlay = computed(
342342+ () => !pending.value && isEstimationGranularity.value,
343343+)
310344311345const startDate = shallowRef<string>('') // YYYY-MM-DD
312346const endDate = shallowRef<string>('') // YYYY-MM-DD
···750784 return { dataset, dates }
751785})
752786787787+/**
788788+ * Maximum estimated value across all series when the chart is
789789+ * displaying a partially completed time bucket (monthly or yearly).
790790+ *
791791+ * Used to determine whether the Y-axis upper bound must be extended to accommodate extrapolated values.
792792+ * It does not mutate chart state or rendering directly.
793793+ *
794794+ * Behavior:
795795+ * - Returns `0` when:
796796+ * - the chart is loading (`pending === true`)
797797+ * - the current granularity is not `monthly` or `yearly`
798798+ * - the dataset is empty or has fewer than two points
799799+ * - the last bucket is fully completed
800800+ *
801801+ * - For partially completed buckets:
802802+ * - Computes the bucket completion ratio using UTC boundaries
803803+ * - Linearly extrapolates the last datapoint of each series
804804+ * - Returns the maximum extrapolated value across all series
805805+ *
806806+ * The reference time used for completion is:
807807+ * - the end of `endDate` (UTC) when provided, or
808808+ * - the current time (`Date.now()`) otherwise
809809+ *
810810+ * @returns The maximum extrapolated value across all series, or `0` when
811811+ * estimation is not applicable.
812812+ */
813813+const estimatedMaxFromData = computed<number>(() => {
814814+ if (pending.value) return 0
815815+ if (!isEstimationGranularity.value) return 0
816816+817817+ const dataset = chartData.value.dataset
818818+ const dates = chartData.value.dates
819819+ if (!dataset?.length || dates.length < 2) return 0
820820+821821+ const lastBucketTimestampMs = dates[dates.length - 1] ?? 0
822822+ const endDateMs = endDate.value ? endDateOnlyToUtcMs(endDate.value) : null
823823+ const referenceMs = endDateMs ?? Date.now()
824824+825825+ const completionRatio = getCompletionRatioForBucket({
826826+ bucketTimestampMs: lastBucketTimestampMs,
827827+ granularity: displayedGranularity.value as 'monthly' | 'yearly',
828828+ referenceMs,
829829+ })
830830+831831+ if (!(completionRatio > 0 && completionRatio < 1)) return 0
832832+833833+ let maxEstimated = 0
834834+835835+ for (const serie of dataset) {
836836+ const values = Array.isArray((serie as any).series) ? ((serie as any).series as number[]) : []
837837+ if (values.length < 2) continue
838838+839839+ const lastValue = Number(values[values.length - 1])
840840+ if (!Number.isFinite(lastValue) || lastValue <= 0) continue
841841+842842+ const estimated = lastValue / completionRatio
843843+ if (Number.isFinite(estimated) && estimated > maxEstimated) maxEstimated = estimated
844844+ }
845845+846846+ return maxEstimated
847847+})
848848+849849+const yAxisScaleMax = computed<number | undefined>(() => {
850850+ if (!isEstimationGranularity.value || pending.value) return undefined
851851+852852+ const datasetMax = getDatasetMaxValue(chartData.value.dataset)
853853+ const estimatedMax = estimatedMaxFromData.value
854854+ const candidateMax = Math.max(datasetMax, estimatedMax)
855855+856856+ const niceMax = candidateMax > 0 ? niceMaxScale(candidateMax) : 0
857857+ return niceMax > datasetMax ? niceMax : undefined
858858+})
859859+753860const loadFile = (link: string, filename: string) => {
754861 const a = document.createElement('a')
755862 a.href = link
···798905 return granularityLabels.value[granularity]
799906}
800907801801-const compactNumberFormatter = useCompactNumberFormatter()
908908+function clampRatio(value: number): number {
909909+ if (value < 0) return 0
910910+ if (value > 1) return 1
911911+ return value
912912+}
913913+914914+/**
915915+ * Convert a `YYYY-MM-DD` date to UTC timestamp representing the end of that day.
916916+ * The returned timestamp corresponds to `23:59:59.999` in UTC
917917+ *
918918+ * @param endDateOnly - ISO-like date string (`YYYY-MM-DD`)
919919+ * @returns The UTC timestamp in milliseconds for the end of the given day,
920920+ * or `null` if the input is invalid.
921921+ */
922922+function endDateOnlyToUtcMs(endDateOnly: string): number | null {
923923+ if (!/^\d{4}-\d{2}-\d{2}$/.test(endDateOnly)) return null
924924+ const [y, m, d] = endDateOnly.split('-').map(Number)
925925+ if (!y || !m || !d) return null
926926+ return Date.UTC(y, m - 1, d, 23, 59, 59, 999)
927927+}
928928+929929+/**
930930+ * Computes the UTC timestamp corresponding to the start of the time bucket
931931+ * that contains the given timestamp.
932932+ *
933933+ * This function is used to derive period boundaries when computing completion
934934+ * ratios or extrapolating values for partially completed periods.
935935+ *
936936+ * Bucket boundaries are defined in UTC:
937937+ * - **monthly** : first day of the month at `00:00:00.000` UTC
938938+ * - **yearly** : January 1st of the year at `00:00:00.000` UTC
939939+ *
940940+ * @param timestampMs - Reference timestamp in milliseconds
941941+ * @param granularity - Bucket granularity (`monthly` or `yearly`)
942942+ * @returns The UTC timestamp representing the start of the corresponding
943943+ * time bucket.
944944+ */
945945+function getBucketStartUtc(timestampMs: number, granularity: 'monthly' | 'yearly'): number {
946946+ const date = new Date(timestampMs)
947947+ if (granularity === 'yearly') return Date.UTC(date.getUTCFullYear(), 0, 1, 0, 0, 0, 0)
948948+ return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1, 0, 0, 0, 0)
949949+}
950950+951951+/**
952952+ * Computes the UTC timestamp corresponding to the end of the time
953953+ * bucket that contains the given timestamp. This end timestamp is paired with `getBucketStartUtc` to define
954954+ * a half-open interval `[start, end)` when computing elapsed time or completion
955955+ * ratios within a period.
956956+ *
957957+ * Bucket boundaries are defined in UTC and are **exclusive**:
958958+ * - **monthly** : first day of the following month at `00:00:00.000` UTC
959959+ * - **yearly** : January 1st of the following year at `00:00:00.000` UTC
960960+ *
961961+ * @param timestampMs - Reference timestamp in milliseconds
962962+ * @param granularity - Bucket granularity (`monthly` or `yearly`)
963963+ * @returns The UTC timestamp (in milliseconds) representing the exclusive end
964964+ * of the corresponding time bucket.
965965+ */
966966+function getBucketEndUtc(timestampMs: number, granularity: 'monthly' | 'yearly'): number {
967967+ const date = new Date(timestampMs)
968968+ if (granularity === 'yearly') return Date.UTC(date.getUTCFullYear() + 1, 0, 1, 0, 0, 0, 0)
969969+ return Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + 1, 1, 0, 0, 0, 0)
970970+}
971971+972972+/**
973973+ * Computes the completion ratio of a time bucket relative to a reference time.
974974+ *
975975+ * The ratio represents how much of the bucket’s duration has elapsed at
976976+ * `referenceMs`, expressed as a normalized value in the range `[0, 1]`.
977977+ *
978978+ * The bucket is defined by the calendar period (monthly or yearly) that
979979+ * contains `bucketTimestampMs`, using UTC boundaries:
980980+ * - start: `getBucketStartUtc(...)`
981981+ * - end: `getBucketEndUtc(...)`
982982+ *
983983+ * The returned value is clamped to `[0, 1]`:
984984+ * - `0`: reference time is at or before the start of the bucket
985985+ * - `1`: reference time is at or after the end of the bucket
986986+ *
987987+ * This function is used to detect partially completed periods and to
988988+ * extrapolate full period values from partial data.
989989+ *
990990+ * @param params.bucketTimestampMs - Timestamp belonging to the bucket
991991+ * @param params.granularity - Bucket granularity (`monthly` or `yearly`)
992992+ * @param params.referenceMs - Reference timestamp used to measure progress
993993+ * @returns A normalized completion ratio in the range `[0, 1]`.
994994+ */
995995+function getCompletionRatioForBucket(params: {
996996+ bucketTimestampMs: number
997997+ granularity: 'monthly' | 'yearly'
998998+ referenceMs: number
999999+}): number {
10001000+ const start = getBucketStartUtc(params.bucketTimestampMs, params.granularity)
10011001+ const end = getBucketEndUtc(params.bucketTimestampMs, params.granularity)
10021002+ const total = end - start
10031003+ if (total <= 0) return 1
10041004+ return clampRatio((params.referenceMs - start) / total)
10051005+}
10061006+10071007+/**
10081008+ * Returns a "nice" rounded upper bound for a positive value, suitable for
10091009+ * chart axis scaling.
10101010+ *
10111011+ * The value is converted to a power-of-ten range and then rounded up to the
10121012+ * next monotonic step within that decade (1, 1.25, 1.5, 2, 2.5, 3, 4, 5, 6, 8, 10).
10131013+ *
10141014+ * VueUiXy computes its own nice scale from the dataset.
10151015+ * However, when injecting an estimation for partial datapoints, the scale must be forced to avoid
10161016+ * overflowing the estimation if it were to become the max value. This scale is fed into the `scaleMax`
10171017+ * config attribute of VueUiXy.
10181018+ *
10191019+ * Examples:
10201020+ * - `niceMaxScale(2_340)` returns `2_500`
10211021+ * - `niceMaxScale(7_100)` returns `8_000`
10221022+ * - `niceMaxScale(12)` returns `12.5`
10231023+ *
10241024+ * @param value - Candidate maximum value
10251025+ * @returns A nice maximum >= `value`, or `0` when `value` is not finite or <= 0.
10261026+ */
10271027+function niceMaxScale(value: number): number {
10281028+ const v = Number(value)
10291029+ if (!Number.isFinite(v) || v <= 0) return 0
10301030+10311031+ const exponent = Math.floor(Math.log10(v))
10321032+ const base = 10 ** exponent
10331033+ const fraction = v / base
10341034+10351035+ // Monotonic scale steps
10361036+ if (fraction <= 1) return 1 * base
10371037+ if (fraction <= 1.25) return 1.25 * base
10381038+ if (fraction <= 1.5) return 1.5 * base
10391039+ if (fraction <= 2) return 2 * base
10401040+ if (fraction <= 2.5) return 2.5 * base
10411041+ if (fraction <= 3) return 3 * base
10421042+ if (fraction <= 4) return 4 * base
10431043+ if (fraction <= 5) return 5 * base
10441044+ if (fraction <= 6) return 6 * base
10451045+ if (fraction <= 8) return 8 * base
10461046+ return 10 * base
10471047+}
10481048+10491049+/**
10501050+ * Extrapolates the last datapoint of a series when it belongs to a partially
10511051+ * completed time bucket (monthly or yearly).
10521052+ *
10531053+ * The extrapolation assumes that the observed value of the last datapoint
10541054+ * grows linearly with time within its bucket. The value is scaled by the
10551055+ * inverse of the bucket completion ratio, and the corresponding y
10561056+ * coordinate is computed by projecting along the segment defined by the
10571057+ * previous and last datapoints.
10581058+ *
10591059+ * Extrapolation is performed only when:
10601060+ * - the granularity is `monthly` or `yearly`
10611061+ * - the bucket completion ratio is strictly between `0` and `1`
10621062+ *
10631063+ * In all other cases, the original `lastPoint` is returned unchanged.
10641064+ *
10651065+ * The reference time used to compute the completion ratio is:
10661066+ * - the end of `endDateOnly` (UTC) when provided, or
10671067+ * - the current time (`Date.now()`) otherwise
10681068+ *
10691069+ * @param params.previousPoint - Datapoint immediately preceding the last one
10701070+ * @param params.lastPoint - Last observed datapoint (potentially incomplete)
10711071+ * @param params.lastBucketTimestampMs - Timestamp identifying the bucket of the last datapoint
10721072+ * @param params.granularity - Chart granularity
10731073+ * @param params.endDateOnly - Optional `YYYY-MM-DD` end date used as a fixed reference time
10741074+ * @returns A new datapoint representing the extrapolated estimate, or the
10751075+ * original `lastPoint` when extrapolation is not applicable.
10761076+ */
10771077+function extrapolateIncompleteLastPoint(params: {
10781078+ previousPoint: { x: number; y: number; value: number }
10791079+ lastPoint: { x: number; y: number; value: number; comment?: string }
10801080+ lastBucketTimestampMs: number
10811081+ granularity: ChartTimeGranularity
10821082+ endDateOnly?: string
10831083+}) {
10841084+ if (params.granularity !== 'monthly' && params.granularity !== 'yearly')
10851085+ return { ...params.lastPoint }
10861086+10871087+ const endDateMs = params.endDateOnly ? endDateOnlyToUtcMs(params.endDateOnly) : null
10881088+ const referenceMs = endDateMs ?? Date.now()
10891089+10901090+ const completionRatio = getCompletionRatioForBucket({
10911091+ bucketTimestampMs: params.lastBucketTimestampMs,
10921092+ granularity: params.granularity,
10931093+ referenceMs,
10941094+ })
10951095+10961096+ if (!(completionRatio > 0 && completionRatio < 1)) return { ...params.lastPoint }
10971097+10981098+ const extrapolatedValue = params.lastPoint.value / completionRatio
10991099+ if (!Number.isFinite(extrapolatedValue)) return { ...params.lastPoint }
11001100+11011101+ const valueDelta = params.lastPoint.value - params.previousPoint.value
11021102+ const yDelta = params.lastPoint.y - params.previousPoint.y
11031103+11041104+ if (valueDelta === 0)
11051105+ return { ...params.lastPoint, value: extrapolatedValue, comment: 'extrapolated' }
11061106+11071107+ const valueToYPixelRatio = yDelta / valueDelta
11081108+ const extrapolatedY =
11091109+ params.previousPoint.y + (extrapolatedValue - params.previousPoint.value) * valueToYPixelRatio
11101110+11111111+ return {
11121112+ x: params.lastPoint.x,
11131113+ y: extrapolatedY,
11141114+ value: extrapolatedValue,
11151115+ comment: 'extrapolated',
11161116+ }
11171117+}
11181118+11191119+/**
11201120+ * Compute the max value across all series in a `VueUiXy` dataset.
11211121+ *
11221122+ * @param dataset - Array of `VueUiXyDatasetItem` objects, or `null`
11231123+ * @returns The maximum finite value found across all series, or `0` when
11241124+ * the dataset is empty or absent.
11251125+ */
11261126+function getDatasetMaxValue(dataset: VueUiXyDatasetItem[] | null): number {
11271127+ if (!dataset?.length) return 0
11281128+ let max = 0
11291129+ for (const serie of dataset) {
11301130+ const values = Array.isArray((serie as any).series) ? ((serie as any).series as number[]) : []
11311131+ for (const v of values) {
11321132+ const n = Number(v)
11331133+ if (Number.isFinite(n) && n > max) max = n
11341134+ }
11351135+ }
11361136+ return max
11371137+}
11381138+11391139+/**
11401140+ * Build and return svg markup for estimation overlays on the chart.
11411141+ *
11421142+ * This function is used in the `#svg` slot of `VueUiXy` to visually indicate
11431143+ * estimated values for partially completed monthly or yearly periods.
11441144+ *
11451145+ * For each series:
11461146+ * - extrapolates the last datapoint when it belongs to an incomplete time bucket
11471147+ * - draws a dashed line from the previous datapoint to the extrapolated position
11481148+ * - masks the original line segment to avoid visual overlap
11491149+ * - renders marker circles at relevant points
11501150+ * - displays a formatted label for the estimated value
11511151+ *
11521152+ * While computing estimations, the function also evaluates whether the Y-axis
11531153+ * scale needs to be extended to accommodate estimated values. When required,
11541154+ * it commits a deferred `scaleMax` update using `commitYAxisScaleMaxLater`.
11551155+ *
11561156+ * The function returns an empty string when:
11571157+ * - estimation overlays are disabled
11581158+ * - no valid series or datapoints are available
11591159+ *
11601160+ * @param svg - svg context object provided by `VueUiXy` via the `#svg` slot
11611161+ * @returns A string containing SVG elements to be injected, or an empty string
11621162+ * when no estimation overlay should be rendered.
11631163+ */
11641164+function drawEstimationLine(svg: Record<string, any>) {
11651165+ if (!shouldRenderEstimationOverlay.value) return ''
11661166+11671167+ const data = Array.isArray(svg?.data) ? svg.data : []
11681168+ if (!data.length) return ''
11691169+11701170+ // Collect per-series estimates and a global max candidate for the y-axis
11711171+ const lines: string[] = []
11721172+11731173+ // Use the last bucket timestamp once (shared x-axis dates)
11741174+ const lastBucketTimestampMs = chartData.value?.dates?.at(-1) ?? 0
11751175+11761176+ for (const serie of data) {
11771177+ const plots = serie?.plots
11781178+ if (!Array.isArray(plots) || plots.length < 2) continue
11791179+11801180+ const previousPoint = plots.at(-2)
11811181+ const lastPoint = plots.at(-1)
11821182+ if (!previousPoint || !lastPoint) continue
11831183+11841184+ const estimationPoint = extrapolateIncompleteLastPoint({
11851185+ previousPoint,
11861186+ lastPoint,
11871187+ lastBucketTimestampMs,
11881188+ granularity: displayedGranularity.value,
11891189+ endDateOnly: endDate.value,
11901190+ })
11911191+11921192+ const stroke = String(serie?.color ?? colors.value.fg)
11931193+11941194+ /**
11951195+ * The following svg elements are injected in the #svg slot of VueUiXy:
11961196+ * - a dashed line connecting the last datapoint to its ancestor
11971197+ * - a line overlay covering the path segment of 'real data' between last datapoint and its ancestor
11981198+ * - circles on the estimation coordinates, and another on the ancestor to mitigate the line overlay
11991199+ * - the formatted data label
12001200+ */
12011201+12021202+ lines.push(`
12031203+ <line
12041204+ x1="${previousPoint.x}"
12051205+ y1="${previousPoint.y}"
12061206+ x2="${lastPoint.x}"
12071207+ y2="${estimationPoint.y}"
12081208+ stroke="${stroke}"
12091209+ stroke-width="3"
12101210+ stroke-dasharray="4 8"
12111211+ stroke-linecap="round"
12121212+ />
12131213+ <line
12141214+ x1="${previousPoint.x}"
12151215+ y1="${previousPoint.y}"
12161216+ x2="${lastPoint.x}"
12171217+ y2="${lastPoint.y}"
12181218+ stroke="${colors.value.bg}"
12191219+ stroke-width="3"
12201220+ opacity="0.7"
12211221+ />
12221222+ <circle
12231223+ cx="${lastPoint.x}"
12241224+ cy="${lastPoint.y}"
12251225+ r="4"
12261226+ fill="${colors.value.bg}"
12271227+ opacity="0.7"
12281228+ />
12291229+ <circle
12301230+ cx="${lastPoint.x}"
12311231+ cy="${estimationPoint.y}"
12321232+ r="4"
12331233+ fill="${stroke}"
12341234+ stroke="${colors.value.bg}"
12351235+ stroke-width="2"
12361236+ />
12371237+ <circle
12381238+ cx="${previousPoint.x}"
12391239+ cy="${previousPoint.y}"
12401240+ r="4"
12411241+ fill="${stroke}"
12421242+ stroke="${colors.value.bg}"
12431243+ stroke-width="2"
12441244+ />
12451245+ <text
12461246+ text-anchor="start"
12471247+ dominant-baseline="middle"
12481248+ x="${lastPoint.x + 12}"
12491249+ y="${estimationPoint.y}"
12501250+ font-size="24"
12511251+ fill="${colors.value.fg}"
12521252+ stroke="${colors.value.bg}"
12531253+ stroke-width="1"
12541254+ paint-order="stroke fill"
12551255+ >
12561256+ ${compactNumberFormatter.value.format(Number.isFinite(estimationPoint.value) ? estimationPoint.value : 0)}
12571257+ </text>
12581258+ `)
12591259+ }
12601260+12611261+ if (!lines.length) return ''
12621262+12631263+ return lines.join('\n')
12641264+}
12651265+12661266+/**
12671267+ * Build and return svg text label for the last datapoint of each series.
12681268+ *
12691269+ * This function is used in the `#svg` slot of `VueUiXy` to render a value label
12701270+ * next to the final datapoint of each series when the data represents fully
12711271+ * completed periods (for example, daily or weekly granularities).
12721272+ *
12731273+ * For each series:
12741274+ * - retrieves the last plotted point
12751275+ * - renders a text label slightly offset to the right of the point
12761276+ * - formats the value using the compact number formatter
12771277+ *
12781278+ * Return an empty string when no series data is available.
12791279+ *
12801280+ * @param svg - SVG context object provided by `VueUiXy` via the `#svg` slot
12811281+ * @returns A string containing SVG `<text>` elements, or an empty string when
12821282+ * no labels should be rendered.
12831283+ */
12841284+function drawLastDatapointLabel(svg: Record<string, any>) {
12851285+ const data = Array.isArray(svg?.data) ? svg.data : []
12861286+ if (!data.length) return ''
12871287+12881288+ const dataLabels: string[] = []
12891289+12901290+ for (const serie of data) {
12911291+ const lastPlot = serie.plots.at(-1)
12921292+12931293+ dataLabels.push(`
12941294+ <text
12951295+ text-anchor="start"
12961296+ dominant-baseline="middle"
12971297+ x="${lastPlot.x + 12}"
12981298+ y="${lastPlot.y}"
12991299+ font-size="24"
13001300+ fill="${colors.value.fg}"
13011301+ stroke="${colors.value.bg}"
13021302+ stroke-width="1"
13031303+ paint-order="stroke fill"
13041304+ >
13051305+ ${compactNumberFormatter.value.format(Number.isFinite(lastPlot.value) ? lastPlot.value : 0)}
13061306+ </text>
13071307+ `)
13081308+ }
13091309+13101310+ return dataLabels.join('\n')
13111311+}
80213128031313// VueUiXy chart component configuration
8041314const chartConfig = computed(() => {
···8061316 theme: isDarkMode.value ? 'dark' : 'default',
8071317 chart: {
8081318 height: isMobile.value ? 950 : 600,
809809- padding: { bottom: 36 },
13191319+ backgroundColor: colors.value.bg,
13201320+ padding: { bottom: 36, right: 100 }, // padding right is set to leave space of last datapoint label(s)
8101321 userOptions: {
8111322 buttons: { pdf: false, labels: false, fullscreen: false, table: false, tooltip: false },
8121323 buttonTitles: {
···8431354 },
8441355 },
8451356 },
846846- backgroundColor: colors.value.bg,
8471357 grid: {
8481358 stroke: colors.value.border,
8491359 labels: {
8501360 fontSize: isMobile.value ? 24 : 16,
13611361+ color: pending.value ? colors.value.border : colors.value.fgSubtle,
8511362 axis: {
8521363 yLabel: $t('package.downloads.y_axis_label', {
8531364 granularity: getGranularityLabel(selectedGranularity.value),
···8671378 },
8681379 },
8691380 yAxis: {
870870- formatter: compactNumberFormatter.value.format,
871871- useNiceScale: true,
13811381+ formatter: ({ value }: { value: number }) => {
13821382+ return compactNumberFormatter.value.format(Number.isFinite(value) ? value : 0)
13831383+ },
13841384+ useNiceScale: !isEstimationGranularity.value || pending.value, // daily/weekly -> true, monthly/yearly -> false
13851385+ scaleMax: yAxisScaleMax.value,
8721386 gap: 24, // vertical gap between individual series in stacked mode
8731387 },
8741388 },
···8961410 const hasMultipleItems = items.length > 1
89714118981412 const rows = items
899899- .map((d: any) => {
14131413+ .map((d: Record<string, any>) => {
9001414 const label = String(d?.name ?? '').trim()
9011415 const raw = Number(d?.value ?? 0)
9021416 const v = compactNumberFormatter.value.format(Number.isFinite(raw) ? raw : 0)
···1046156010471561 <div role="region" aria-labelledby="download-analytics-title">
10481562 <ClientOnly v-if="chartData.dataset">
10491049- <div>
10501050- <VueUiXy :dataset="chartData.dataset" :config="chartConfig" class="[direction:ltr]">
15631563+ <div :data-pending="pending">
15641564+ <VueUiXy
15651565+ :dataset="chartData.dataset"
15661566+ :config="chartConfig"
15671567+ class="[direction:ltr]"
15681568+ @zoomStart="setIsZoom"
15691569+ @zoomEnd="setIsZoom"
15701570+ @zoomReset="isZoomed = false"
15711571+ >
15721572+ <!-- Injecting custom svg elements -->
15731573+ <template #svg="{ svg }">
15741574+ <!-- Estimation lines for monthly & yearly granularities when the end date induces a downwards trend -->
15751575+ <g
15761576+ v-if="
15771577+ !pending &&
15781578+ ['monthly', 'yearly'].includes(displayedGranularity) &&
15791579+ !isEndDateOnPeriodEnd &&
15801580+ !isZoomed
15811581+ "
15821582+ v-html="drawEstimationLine(svg)"
15831583+ />
15841584+15851585+ <!-- Last value label for all other cases -->
15861586+ <g
15871587+ v-if="
15881588+ !pending &&
15891589+ (['daily', 'weekly'].includes(displayedGranularity) ||
15901590+ isEndDateOnPeriodEnd ||
15911591+ isZoomed)
15921592+ "
15931593+ v-html="drawLastDatapointLabel(svg)"
15941594+ />
15951595+15961596+ <!-- Overlay covering the chart area to hide line resizing when switching granularities recalculates VueUiXy scaleMax when estimation lines are necessary -->
15971597+ <rect
15981598+ v-if="pending"
15991599+ :x="svg.drawingArea.left"
16001600+ :y="svg.drawingArea.top - 12"
16011601+ :width="svg.drawingArea.width + 12"
16021602+ :height="svg.drawingArea.height + 24"
16031603+ :fill="colors.bg"
16041604+ />
16051605+ </template>
16061606+10511607 <!-- Subtle gradient applied for a unique series (chart modal) -->
10521608 <template #area-gradient="{ series: chartModalSeries, id: gradientId }">
10531609 <linearGradient :id="gradientId" x1="0" x2="0" y1="0" y2="1">
···10571613 </template>
1058161410591615 <!-- Custom legend for multiple series -->
10601060- <template v-if="isMultiPackageMode" #legend="{ legend }">
16161616+ <template
16171617+ v-if="isMultiPackageMode || ['monthly', 'yearly'].includes(displayedGranularity)"
16181618+ #legend="{ legend }"
16191619+ >
10611620 <div class="flex gap-4 flex-wrap justify-center">
10621062- <button
10631063- v-for="datapoint in legend"
10641064- :key="datapoint.name"
10651065- :aria-pressed="datapoint.isSegregated"
10661066- :aria-label="datapoint.name"
10671067- type="button"
16211621+ <template v-if="isMultiPackageMode">
16221622+ <button
16231623+ v-for="datapoint in legend"
16241624+ :key="datapoint.name"
16251625+ :aria-pressed="datapoint.isSegregated"
16261626+ :aria-label="datapoint.name"
16271627+ type="button"
16281628+ class="flex gap-1 place-items-center"
16291629+ @click="datapoint.segregate()"
16301630+ >
16311631+ <div class="h-3 w-3">
16321632+ <svg viewBox="0 0 2 2" class="w-full">
16331633+ <rect x="0" y="0" width="2" height="2" rx="0.3" :fill="datapoint.color" />
16341634+ </svg>
16351635+ </div>
16361636+ <span
16371637+ :style="{
16381638+ textDecoration: datapoint.isSegregated ? 'line-through' : undefined,
16391639+ }"
16401640+ >
16411641+ {{ datapoint.name }}
16421642+ </span>
16431643+ </button>
16441644+ </template>
16451645+16461646+ <!-- Estimation extra legend item -->
16471647+ <div
10681648 class="flex gap-1 place-items-center"
10691069- @click="datapoint.segregate()"
16491649+ v-if="['monthly', 'yearly'].includes(selectedGranularity)"
10701650 >
10711071- <div class="h-3 w-3">
10721072- <svg viewBox="0 0 2 2" class="w-full">
10731073- <rect x="0" y="0" width="2" height="2" rx="0.3" :fill="datapoint.color" />
10741074- </svg>
10751075- </div>
10761076- <span
10771077- :style="{
10781078- textDecoration: datapoint.isSegregated ? 'line-through' : undefined,
10791079- }"
10801080- >
10811081- {{ datapoint.name }}
10821082- </span>
10831083- </button>
16511651+ <svg viewBox="0 0 20 2" width="20">
16521652+ <line
16531653+ x1="0"
16541654+ y1="1"
16551655+ x2="20"
16561656+ y2="1"
16571657+ :stroke="colors.fg"
16581658+ stroke-dasharray="4"
16591659+ stroke-linecap="round"
16601660+ />
16611661+ </svg>
16621662+ <span class="text-fg-subtle">{{
16631663+ $t('package.downloads.legend_estimation')
16641664+ }}</span>
16651665+ </div>
10841666 </div>
10851667 </template>
10861668···12031785 top: -0.6rem !important;
12041786 left: calc(100% + 2rem) !important;
12051787 }
17881788+}
17891789+17901790+[data-pending='true'] .vue-data-ui-zoom {
17911791+ opacity: 0.1;
12061792}
12071793</style>