···848848 return { dataset, dates }
849849})
850850851851-const maxDatapoints = computed(() =>
852852- Math.max(0, ...(chartData.value.dataset ?? []).map(d => d.series.length)),
853853-)
854854-855855-/**
856856- * Maximum estimated value across all series when the chart is
857857- * displaying a partially completed time bucket (monthly or yearly).
858858- *
859859- * Used to determine whether the Y-axis upper bound must be extended to accommodate extrapolated values.
860860- * It does not mutate chart state or rendering directly.
861861- *
862862- * Behavior:
863863- * - Returns `0` when:
864864- * - the chart is loading (`pending === true`)
865865- * - the current granularity is not `monthly` or `yearly`
866866- * - the dataset is empty or has fewer than two points
867867- * - the last bucket is fully completed
868868- *
869869- * - For partially completed buckets:
870870- * - Computes the bucket completion ratio using UTC boundaries
871871- * - Linearly extrapolates the last datapoint of each series
872872- * - Returns the maximum extrapolated value across all series
873873- *
874874- * The reference time used for completion is:
875875- * - the end of `endDate` (UTC) when provided, or
876876- * - the current time (`Date.now()`) otherwise
877877- *
878878- * @returns The maximum extrapolated value across all series, or `0` when
879879- * estimation is not applicable.
880880- */
881881-const estimatedMaxFromData = computed<number>(() => {
882882- if (pending.value) return 0
883883- if (!isEstimationGranularity.value) return 0
884884-885885- const dataset = chartData.value.dataset
886886- const dates = chartData.value.dates
887887- if (!dataset?.length || dates.length < 2) return 0
888888-889889- const lastBucketTimestampMs = dates[dates.length - 1] ?? 0
890890- const endDateMs = endDate.value ? endDateOnlyToUtcMs(endDate.value) : null
891891- const referenceMs = endDateMs ?? Date.now()
892892-893893- const completionRatio = getCompletionRatioForBucket({
894894- bucketTimestampMs: lastBucketTimestampMs,
895895- granularity: displayedGranularity.value as 'monthly' | 'yearly',
896896- referenceMs,
851851+const normalisedDataset = computed(() => {
852852+ return chartData.value.dataset?.map(d => {
853853+ return {
854854+ ...d,
855855+ series: [...d.series.slice(0, -1), extrapolateLastValue(d.series.at(-1) ?? 0)],
856856+ }
897857 })
898898-899899- if (!(completionRatio > 0 && completionRatio < 1)) return 0
900900-901901- let maxEstimated = 0
902902-903903- for (const serie of dataset) {
904904- const values = Array.isArray((serie as any).series) ? ((serie as any).series as number[]) : []
905905- if (values.length < 2) continue
906906-907907- const lastValue = Number(values[values.length - 1])
908908- if (!Number.isFinite(lastValue) || lastValue <= 0) continue
909909-910910- const estimated = lastValue / completionRatio
911911- if (Number.isFinite(estimated) && estimated > maxEstimated) maxEstimated = estimated
912912- }
913913-914914- return maxEstimated
915858})
916859917917-const yAxisScaleMax = computed<number | undefined>(() => {
918918- if (!isEstimationGranularity.value || pending.value) return undefined
919919-920920- const datasetMax = getDatasetMaxValue(chartData.value.dataset)
921921- const estimatedMax = estimatedMaxFromData.value
922922- const candidateMax = Math.max(datasetMax, estimatedMax)
923923-924924- const niceMax = candidateMax > 0 ? niceMaxScale(candidateMax) : 0
925925- return niceMax > datasetMax ? niceMax : undefined
926926-})
860860+const maxDatapoints = computed(() =>
861861+ Math.max(0, ...(chartData.value.dataset ?? []).map(d => d.series.length)),
862862+)
927863928864const loadFile = (link: string, filename: string) => {
929865 const a = document.createElement('a')
···10731009}
1074101010751011/**
10761076- * Returns a "nice" rounded upper bound for a positive value, suitable for
10771077- * chart axis scaling.
10121012+ * Extrapolate the last observed value of a time series when the last bucket
10131013+ * (month or year) is only partially complete.
10781014 *
10791079- * The value is converted to a power-of-ten range and then rounded up to the
10801080- * next monotonic step within that decade (1, 1.25, 1.5, 2, 2.5, 3, 4, 5, 6, 8, 10).
10151015+ * This is used to replace the final value in each `VueUiXy` series
10161016+ * before rendering, so the chart can display an estimated full-period value
10171017+ * for the current month or year.
10811018 *
10821082- * VueUiXy computes its own nice scale from the dataset.
10831083- * However, when injecting an estimation for partial datapoints, the scale must be forced to avoid
10841084- * overflowing the estimation if it were to become the max value. This scale is fed into the `scaleMax`
10851085- * config attribute of VueUiXy.
10191019+ * Notes:
10201020+ * - This function assumes `lastValue` is the value corresponding to the last
10211021+ * date in `chartData.value.dates`
10861022 *
10871087- * Examples:
10881088- * - `niceMaxScale(2_340)` returns `2_500`
10891089- * - `niceMaxScale(7_100)` returns `8_000`
10901090- * - `niceMaxScale(12)` returns `12.5`
10911091- *
10921092- * @param value - Candidate maximum value
10931093- * @returns A nice maximum >= `value`, or `0` when `value` is not finite or <= 0.
10231023+ * @param lastValue - The last observed numeric value for a series.
10241024+ * @returns The extrapolated value for partially completed monthly or yearly granularities,
10251025+ * or the original `lastValue` when no extrapolation should be applied.
10941026 */
10951095-function niceMaxScale(value: number): number {
10961096- const v = Number(value)
10971097- if (!Number.isFinite(v) || v <= 0) return 0
10271027+function extrapolateLastValue(lastValue: number) {
10281028+ if (displayedGranularity.value !== 'monthly' && displayedGranularity.value !== 'yearly')
10291029+ return lastValue
1098103010991099- const exponent = Math.floor(Math.log10(v))
11001100- const base = 10 ** exponent
11011101- const fraction = v / base
11021102-11031103- // Monotonic scale steps
11041104- if (fraction <= 1) return 1 * base
11051105- if (fraction <= 1.25) return 1.25 * base
11061106- if (fraction <= 1.5) return 1.5 * base
11071107- if (fraction <= 2) return 2 * base
11081108- if (fraction <= 2.5) return 2.5 * base
11091109- if (fraction <= 3) return 3 * base
11101110- if (fraction <= 4) return 4 * base
11111111- if (fraction <= 5) return 5 * base
11121112- if (fraction <= 6) return 6 * base
11131113- if (fraction <= 8) return 8 * base
11141114- return 10 * base
11151115-}
11161116-11171117-/**
11181118- * Extrapolates the last datapoint of a series when it belongs to a partially
11191119- * completed time bucket (monthly or yearly).
11201120- *
11211121- * The extrapolation assumes that the observed value of the last datapoint
11221122- * grows linearly with time within its bucket. The value is scaled by the
11231123- * inverse of the bucket completion ratio, and the corresponding y
11241124- * coordinate is computed by projecting along the segment defined by the
11251125- * previous and last datapoints.
11261126- *
11271127- * Extrapolation is performed only when:
11281128- * - the granularity is `monthly` or `yearly`
11291129- * - the bucket completion ratio is strictly between `0` and `1`
11301130- *
11311131- * In all other cases, the original `lastPoint` is returned unchanged.
11321132- *
11331133- * The reference time used to compute the completion ratio is:
11341134- * - the end of `endDateOnly` (UTC) when provided, or
11351135- * - the current time (`Date.now()`) otherwise
11361136- *
11371137- * @param params.previousPoint - Datapoint immediately preceding the last one
11381138- * @param params.lastPoint - Last observed datapoint (potentially incomplete)
11391139- * @param params.lastBucketTimestampMs - Timestamp identifying the bucket of the last datapoint
11401140- * @param params.granularity - Chart granularity
11411141- * @param params.endDateOnly - Optional `YYYY-MM-DD` end date used as a fixed reference time
11421142- * @returns A new datapoint representing the extrapolated estimate, or the
11431143- * original `lastPoint` when extrapolation is not applicable.
11441144- */
11451145-function extrapolateIncompleteLastPoint(params: {
11461146- previousPoint: { x: number; y: number; value: number }
11471147- lastPoint: { x: number; y: number; value: number; comment?: string }
11481148- lastBucketTimestampMs: number
11491149- granularity: ChartTimeGranularity
11501150- endDateOnly?: string
11511151-}) {
11521152- if (params.granularity !== 'monthly' && params.granularity !== 'yearly')
11531153- return { ...params.lastPoint }
11541154-11551155- const endDateMs = params.endDateOnly ? endDateOnlyToUtcMs(params.endDateOnly) : null
10311031+ const endDateMs = endDate.value ? endDateOnlyToUtcMs(endDate.value) : null
11561032 const referenceMs = endDateMs ?? Date.now()
1157103311581034 const completionRatio = getCompletionRatioForBucket({
11591159- bucketTimestampMs: params.lastBucketTimestampMs,
11601160- granularity: params.granularity,
10351035+ bucketTimestampMs: chartData.value.dates.at(-1) ?? 0,
10361036+ granularity: displayedGranularity.value,
11611037 referenceMs,
11621038 })
1163103911641164- if (!(completionRatio > 0 && completionRatio < 1)) return { ...params.lastPoint }
10401040+ if (!(completionRatio > 0 && completionRatio < 1)) return lastValue
1165104111661166- const extrapolatedValue = params.lastPoint.value / completionRatio
11671167- if (!Number.isFinite(extrapolatedValue)) return { ...params.lastPoint }
10421042+ const extrapolatedValue = lastValue / completionRatio
10431043+ if (!Number.isFinite(extrapolatedValue)) return lastValue
1168104411691169- const valueDelta = params.lastPoint.value - params.previousPoint.value
11701170- const yDelta = params.lastPoint.y - params.previousPoint.y
11711171-11721172- if (valueDelta === 0)
11731173- return { ...params.lastPoint, value: extrapolatedValue, comment: 'extrapolated' }
11741174-11751175- const valueToYPixelRatio = yDelta / valueDelta
11761176- const extrapolatedY =
11771177- params.previousPoint.y + (extrapolatedValue - params.previousPoint.value) * valueToYPixelRatio
11781178-11791179- return {
11801180- x: params.lastPoint.x,
11811181- y: extrapolatedY,
11821182- value: extrapolatedValue,
11831183- comment: 'extrapolated',
11841184- }
11851185-}
11861186-11871187-/**
11881188- * Compute the max value across all series in a `VueUiXy` dataset.
11891189- *
11901190- * @param dataset - Array of `VueUiXyDatasetItem` objects, or `null`
11911191- * @returns The maximum finite value found across all series, or `0` when
11921192- * the dataset is empty or absent.
11931193- */
11941194-function getDatasetMaxValue(dataset: VueUiXyDatasetItem[] | null): number {
11951195- if (!dataset?.length) return 0
11961196- let max = 0
11971197- for (const serie of dataset) {
11981198- const values = Array.isArray((serie as any).series) ? ((serie as any).series as number[]) : []
11991199- for (const v of values) {
12001200- const n = Number(v)
12011201- if (Number.isFinite(n) && n > max) max = n
12021202- }
12031203- }
12041204- return max
10451045+ return extrapolatedValue
12051046}
1206104712071048/**
12081049 * Build and return svg markup for estimation overlays on the chart.
12091050 *
12101210- * This function is used in the `#svg` slot of `VueUiXy` to visually indicate
12111211- * estimated values for partially completed monthly or yearly periods.
12121212- *
12131213- * For each series:
12141214- * - extrapolates the last datapoint when it belongs to an incomplete time bucket
12151215- * - draws a dashed line from the previous datapoint to the extrapolated position
12161216- * - masks the original line segment to avoid visual overlap
12171217- * - renders marker circles at relevant points
12181218- * - displays a formatted label for the estimated value
12191219- *
12201220- * While computing estimations, the function also evaluates whether the Y-axis
12211221- * scale needs to be extended to accommodate estimated values. When required,
12221222- * it commits a deferred `scaleMax` update using `commitYAxisScaleMaxLater`.
10511051+ * This function is used in the `#svg` slot of `VueUiXy` to draw a dashed line
10521052+ * between the last datapoint and its ancestor, for partial month or year.
12231053 *
12241054 * The function returns an empty string when:
12251055 * - estimation overlays are disabled
···12381068 // Collect per-series estimates and a global max candidate for the y-axis
12391069 const lines: string[] = []
1240107012411241- // Use the last bucket timestamp once (shared x-axis dates)
12421242- const lastBucketTimestampMs = chartData.value?.dates?.at(-1) ?? 0
12431243-12441071 for (const serie of data) {
12451072 const plots = serie?.plots
12461073 if (!Array.isArray(plots) || plots.length < 2) continue
···12491076 const lastPoint = plots.at(-1)
12501077 if (!previousPoint || !lastPoint) continue
1251107812521252- const estimationPoint = extrapolateIncompleteLastPoint({
12531253- previousPoint,
12541254- lastPoint,
12551255- lastBucketTimestampMs,
12561256- granularity: displayedGranularity.value,
12571257- endDateOnly: endDate.value,
12581258- })
12591259-12601079 const stroke = String(serie?.color ?? colors.value.fg)
1261108012621081 /**
12631082 * The following svg elements are injected in the #svg slot of VueUiXy:
10831083+ * - a line overlay covering the plain path bewteen the last datapoint and its ancestor
12641084 * - a dashed line connecting the last datapoint to its ancestor
12651265- * - a line overlay covering the path segment of 'real data' between last datapoint and its ancestor
12661266- * - circles on the estimation coordinates, and another on the ancestor to mitigate the line overlay
12671267- * - the formatted data label
10851085+ * - a circle for the last datapoint
12681086 */
1269108712701088 lines.push(`
12711271- <line
10891089+ <line
12721090 x1="${previousPoint.x}"
12731091 y1="${previousPoint.y}"
12741092 x2="${lastPoint.x}"
12751275- y2="${estimationPoint.y}"
12761276- stroke="${stroke}"
10931093+ y2="${lastPoint.y}"
10941094+ stroke="${colors.value.bg}"
12771095 stroke-width="3"
12781278- stroke-dasharray="4 8"
12791279- stroke-linecap="round"
10961096+ opacity="1"
12801097 />
12811281- <line
10981098+ <line
12821099 x1="${previousPoint.x}"
12831100 y1="${previousPoint.y}"
12841101 x2="${lastPoint.x}"
12851102 y2="${lastPoint.y}"
12861286- stroke="${colors.value.bg}"
11031103+ stroke="${stroke}"
12871104 stroke-width="3"
12881288- opacity="0.7"
11051105+ stroke-dasharray="4 8"
11061106+ stroke-linecap="round"
12891107 />
12901108 <circle
12911109 cx="${lastPoint.x}"
12921110 cy="${lastPoint.y}"
12931111 r="4"
12941294- fill="${colors.value.bg}"
12951295- opacity="0.7"
12961296- />
12971297- <circle
12981298- cx="${lastPoint.x}"
12991299- cy="${estimationPoint.y}"
13001300- r="4"
13011112 fill="${stroke}"
13021113 stroke="${colors.value.bg}"
13031114 stroke-width="2"
13041115 />
13051305- <circle
13061306- cx="${previousPoint.x}"
13071307- cy="${previousPoint.y}"
13081308- r="4"
13091309- fill="${stroke}"
13101310- stroke="${colors.value.bg}"
13111311- stroke-width="2"
13121312- />
13131313- <text
13141314- text-anchor="start"
13151315- dominant-baseline="middle"
13161316- x="${lastPoint.x + 12}"
13171317- y="${estimationPoint.y}"
13181318- font-size="24"
13191319- fill="${colors.value.fg}"
13201320- stroke="${colors.value.bg}"
13211321- stroke-width="1"
13221322- paint-order="stroke fill"
13231323- >
13241324- ${compactNumberFormatter.value.format(Number.isFinite(estimationPoint.value) ? estimationPoint.value : 0)}
13251325- </text>
13261116 `)
13271117 }
13281118···15561346 formatter: ({ value }: { value: number }) => {
15571347 return compactNumberFormatter.value.format(Number.isFinite(value) ? value : 0)
15581348 },
15591559- useNiceScale: !isEstimationGranularity.value || pending.value, // daily/weekly -> true, monthly/yearly -> false
15601560- scaleMax: yAxisScaleMax.value,
13491349+ useNiceScale: true, // daily/weekly -> true, monthly/yearly -> false
15611350 gap: 24, // vertical gap between individual series in stacked mode
15621351 },
15631352 },
···17451534 <ClientOnly v-if="chartData.dataset">
17461535 <div :data-pending="pending" :data-minimap-visible="maxDatapoints > 6">
17471536 <VueUiXy
17481748- :dataset="chartData.dataset"
15371537+ :dataset="normalisedDataset"
17491538 :config="chartConfig"
17501539 class="[direction:ltr]"
17511540 @zoomStart="setIsZoom"
···17661555 />
1767155617681557 <!-- Last value label for all other cases -->
17691769- <g
17701770- v-if="
17711771- !pending &&
17721772- (['daily', 'weekly'].includes(displayedGranularity) ||
17731773- isEndDateOnPeriodEnd ||
17741774- isZoomed)
17751775- "
17761776- v-html="drawLastDatapointLabel(svg)"
17771777- />
15581558+ <g v-if="!pending" v-html="drawLastDatapointLabel(svg)" />
1778155917791560 <!-- Inject legend during SVG print only -->
17801561 <g v-if="svg.isPrintingSvg" v-html="drawSvgPrintLegend(svg)" />