diff --git a/README.md b/README.md index a5d9678..20a99ec 100644 --- a/README.md +++ b/README.md @@ -272,7 +272,16 @@ The bundle command writes the single shipped module to `wwwroot/chartJsInterop.j ## Changelog -
v0.9.0 +
v0.9.1 + +>- Added `UpdateAnimation` support for smooth dataset changes. `SetDatasetsSmooth(..., updateAnimation: "addFromLeft")` can now pass validated Chart.js update modes to `chart.update(...)`, including custom transitions and `"none"`. [sample](https://github.com/ipax77/pax.BlazorChartJs/blob/main/src/pax.BlazorChartJs.samplelib/BubbleChartComp.razor) +>- Validated smooth update animation modes in the TypeScript interop. Built-in modes such as `default`, `active`, `hide`, `show`, `reset`, `resize`, and `none` are accepted, while custom modes must exist in chart transitions. +>- Fixed `SetDatasetsSmooth(..., updateOptions: true)` so callback-backed options such as `onClick` are restored as callable JavaScript functions after smooth options replacement, preserving Blazor event bridge callbacks. +>- Animation `onProgress` and `onComplete` callbacks now preserve existing native or custom JavaScript callbacks and also trigger the corresponding Blazor/C# animation events when enabled, matching the behavior of the other chart callbacks. + +
+ +
v0.9.0 >- **Breaking change:** font option properties that now support scriptable values use `IndexableOption` in those contexts. Target-typed `Font = new()` no longer binds there; use `Font = new Font { ... }` or a `ChartJsFunction` callback. >- Added `ChartJsFunction` to reference registered JavaScript callbacks from C# chart configuration without serializing raw JavaScript. diff --git a/src/pax.BlazorChartJs.samplelib/BubbleChartComp.razor b/src/pax.BlazorChartJs.samplelib/BubbleChartComp.razor index 958cbab..c22eba5 100644 --- a/src/pax.BlazorChartJs.samplelib/BubbleChartComp.razor +++ b/src/pax.BlazorChartJs.samplelib/BubbleChartComp.razor @@ -1,17 +1,25 @@ @using pax.BlazorChartJs +@using System.Text.Json.Serialization +

BubbleChartPage

- + + + + + +
- + +
@@ -26,91 +34,159 @@ @code { ChartComponent? chartComponent; ChartJsConfig chartJsConfig = null!; + private readonly Lock chartDataSync = new(); private string? labelClicked; - private Random random = new(); + private bool hasPendingNewFlags; protected override void OnInitialized() { chartJsConfig = new() + { + Type = ChartType.bubble, + Data = new ChartJsData() { - Type = ChartType.bubble, - Data = new ChartJsData() - { - Datasets = new List() + Datasets = new List() { new BubbleDataset() { + Id = "bubble-primary", Label = "Bubble Dataset", Data = new List() { - new BubbleDataPoint() - { - X = -10, - Y = 0, - R = 4 - }, - new BubbleDataPoint() - { - X = 0, - Y = 10, - R = 6 - }, - new BubbleDataPoint() - { - X = 10, - Y = 5, - R = 2 - }, - new BubbleDataPoint() - { - X = 0.5, - Y = 5.5, - R = 1, - }, - new BubbleDataPoint() - { - X = 7, - Y = 7, - R = 12 - }, + CreatePoint(-10, 0, 4), + CreatePoint(0, 10, 6), + CreatePoint(10, 5, 2), + CreatePoint(0.5, 5.5, 1), + CreatePoint(7, 7, 12), }, BackgroundColor = "rgb(255, 99, 132)" } } + }, + Options = new ChartJsOptions() + { + Responsive = true, + MaintainAspectRatio = true, + Animation = new Animation() + { + Duration = 600, + Easing = "easeOutQuart", + OnComplete = ChartJsFunction.FromName("clearBubbleNewFlags"), + OnCompleteEvent = true }, - Options = new ChartJsOptions() + Transitions = new Dictionary() { - Responsive = true, - MaintainAspectRatio = true, - OnClickEvent = true, - Scales = new ChartJsOptionsScales() + ["addFromLeft"] = new + { + Animations = new + { + X = new Animations() + { + From = ChartJsFunction.FromName("bubbleAddFromLeftX") + }, + Y = new Animations() + { + Duration = 0 + }, + Radius = new Animations() + { + From = ChartJsFunction.FromName("bubbleAddFromLeftRadius") + } + } + }, + ["addFromTop"] = new { - X = new LinearAxis() + Animations = new { - Type = "linear", - Position = "bottom", - SuggestedMin = -100, - SuggestedMax = 100 - }, - Y = new LinearAxis() + X = new Animations() + { + Duration = 0 + }, + Y = new Animations() + { + From = ChartJsFunction.FromName("bubbleAddFromTopY") + }, + Radius = new Animations() + { + From = ChartJsFunction.FromName("bubbleAddFromLeftRadius") + } + } + }, + ["addFromRight"] = new + { + Animations = new + { + X = new Animations() + { + From = ChartJsFunction.FromName("bubbleAddFromRightX") + }, + Y = new Animations() + { + Duration = 0 + }, + Radius = new Animations() + { + From = ChartJsFunction.FromName("bubbleAddFromLeftRadius") + } + } + }, + ["addFromBottom"] = new + { + Animations = new { - SuggestedMin = -100, - SuggestedMax = 100 + X = new Animations() + { + Duration = 0 + }, + Y = new Animations() + { + From = ChartJsFunction.FromName("bubbleAddFromBottomY") + }, + Radius = new Animations() + { + From = ChartJsFunction.FromName("bubbleAddFromLeftRadius") + } } } + }, + OnClickEvent = true, + Scales = new ChartJsOptionsScales() + { + X = new LinearAxis() + { + Type = "linear", + Position = "bottom", + SuggestedMin = -100, + SuggestedMax = 100 + }, + Y = new LinearAxis() + { + SuggestedMin = -100, + SuggestedMax = 100 + } } - }; + } + }; base.OnInitialized(); } - private BubbleDataPoint GetRandomPoint(int min = -100, int max = 100) + private SampleBubblePoint GetRandomPoint(int min = -100, int max = 100) + { + return CreatePoint( + Random.Shared.Next(min, max), + Random.Shared.Next(min, max), + Random.Shared.Next(1, 15)); + } + + private static SampleBubblePoint CreatePoint(double x, double y, double r, bool isNew = false) { return new() - { - X = random.Next(min, max), - Y = random.Next(min, max), - R = random.Next(1, 15) - }; + { + IsNew = isNew ? true : null, + X = x, + Y = y, + R = r + }; } @@ -119,26 +195,76 @@ chartJsConfig.ReinitializeChart(); } - private void LabelClicked(ChartJsEvent chartJsEvent) + private void ChartEventTriggered(ChartJsEvent chartJsEvent) { if (chartJsEvent is ChartJsLabelClickEvent labelClickEvent) { labelClicked = labelClickEvent.Label; } + + if (chartJsEvent is ChartJsAnimationCompleteEvent { Initial: false }) + { + ClearNewFlagsIfNeeded(); + } } - private void AddData() + private void AddDataFromLeft() { - Dictionary datas = new(); - for (int i = 0; i < chartJsConfig.Data.Datasets.Count; i++) + AddDataSmooth("addFromLeft"); + } + + private void AddDataFromTop() + { + AddDataSmooth("addFromTop"); + } + + private void AddDataFromRight() + { + AddDataSmooth("addFromRight"); + } + + private void AddDataFromBottom() + { + AddDataSmooth("addFromBottom"); + } + + private void AddDataDefault() + { + AddDataSmooth("default"); + } + + private void AddDataNoAnimation() + { + AddDataSmooth("none"); + } + + private void AddDataSmooth(string updateAnimation) + { + bool markNew = updateAnimation.StartsWith("addFrom", StringComparison.Ordinal); + + lock (chartDataSync) { - var bubbleDataset = chartJsConfig.Data.Datasets[i] as BubbleDataset; - if (bubbleDataset != null) + if (!markNew && hasPendingNewFlags) { - datas.Add(bubbleDataset, new AddDataObject(GetRandomPoint())); + ClearNewFlagsCore(); } + + bool addedNewPoint = false; + for (int i = 0; i < chartJsConfig.Data.Datasets.Count; i++) + { + if (chartJsConfig.Data.Datasets[i] is BubbleDataset bubbleDataset) + { + bubbleDataset.Data.Add(GetRandomPoint() with { IsNew = markNew ? true : null }); + addedNewPoint |= markNew; + } + } + + hasPendingNewFlags = addedNewPoint || HasNewFlags(); + + chartJsConfig.SetDatasetsSmooth( + datasets: chartJsConfig.Data.Datasets, + updateAnimation: updateAnimation); } - chartJsConfig.AddData(null, null, datas); } private void Randomize() @@ -162,6 +288,7 @@ datas.Add(bubbleDataset, new SetDataObject(data)); } } + hasPendingNewFlags = false; chartJsConfig.SetData(datas); } } @@ -174,7 +301,8 @@ dataCount = ((ChartJsDataset)chartJsConfig.Data.Datasets.First()).Data.Count; } - var dataset = ChartUtils.GetRandomDataset(chartJsConfig.Type == null ? ChartType.bar : chartJsConfig.Type.Value, chartJsConfig.Data.Datasets.Count + 1, dataCount); + var dataset = ChartUtils.GetRandomDataset(chartJsConfig.Type == null ? ChartType.bar : chartJsConfig.Type.Value, +chartJsConfig.Data.Datasets.Count + 1, dataCount); chartJsConfig.AddDataset(dataset); } @@ -183,11 +311,66 @@ if (chartJsConfig.Data.Datasets.Any()) { chartJsConfig.RemoveDataset(chartJsConfig.Data.Datasets.Last()); + hasPendingNewFlags = HasNewFlags(); } } private void RemoveLastDataFromDatasets() { chartJsConfig.RemoveData(); + hasPendingNewFlags = HasNewFlags(); + } + + private void ClearNewFlagsIfNeeded() + { + lock (chartDataSync) + { + if (!hasPendingNewFlags) + { + return; + } + + ClearNewFlagsCore(); + } + } + + private void ClearNewFlagsCore() + { + for (int i = 0; i < chartJsConfig.Data.Datasets.Count; i++) + { + var data = chartJsConfig.Data.Datasets[i].Data; + for (int j = 0; j < data.Count; j++) + { + if (data[j] is SampleBubblePoint { IsNew: true } point) + { + point.IsNew = null; + } + } + } + + hasPendingNewFlags = false; + } + + private bool HasNewFlags() + { + for (int i = 0; i < chartJsConfig.Data.Datasets.Count; i++) + { + var data = chartJsConfig.Data.Datasets[i].Data; + for (int j = 0; j < data.Count; j++) + { + if (data[j] is SampleBubblePoint { IsNew: true }) + { + return true; + } + } + } + + return false; + } + + private sealed record SampleBubblePoint : BubbleDataPoint + { + [JsonPropertyName("_new")] + public bool? IsNew { get; set; } } } diff --git a/src/pax.BlazorChartJs.samplelib/UpdateDatasetChartComp.razor b/src/pax.BlazorChartJs.samplelib/UpdateDatasetChartComp.razor index 80123a5..377b8c4 100644 --- a/src/pax.BlazorChartJs.samplelib/UpdateDatasetChartComp.razor +++ b/src/pax.BlazorChartJs.samplelib/UpdateDatasetChartComp.razor @@ -328,7 +328,8 @@ chartJsConfig.SetDatasetsSmooth( datasets: [addedDataset, updatedDataset], labels: ["Apr", "May", "Jun", "Jul"], - updateOptions: true); + updateOptions: true, + updateAnimation: "none"); } } diff --git a/src/pax.BlazorChartJs.samplelib/wwwroot/chartJsCallbacks.js b/src/pax.BlazorChartJs.samplelib/wwwroot/chartJsCallbacks.js index 0c5bdb5..f364a31 100644 --- a/src/pax.BlazorChartJs.samplelib/wwwroot/chartJsCallbacks.js +++ b/src/pax.BlazorChartJs.samplelib/wwwroot/chartJsCallbacks.js @@ -94,6 +94,30 @@ function getCustomTooltipPoint(context) { return context?.dataset?.tooltipPoints?.[context.dataIndex]; } +function isNewBubblePoint(context) { + return context?.raw?._new === true; +} + +function clearBubbleNewFlagsCore(context) { + const datasets = context?.chart?.data?.datasets; + if (!Array.isArray(datasets)) { + return; + } + + for (const dataset of datasets) { + const data = dataset?.data; + if (!Array.isArray(data)) { + continue; + } + + for (const point of data) { + if (point && typeof point === 'object') { + delete point._new; + } + } + } +} + function getOrCreateExternalTooltip(chart) { let tooltipElement = externalTooltipCache.get(chart); if (tooltipElement) { @@ -286,6 +310,21 @@ const callbacks = Object.assign(Object.create(null), { label: item?.text ?? null }; }, + chartEventBridgeAnimationProgress(context) { + window.chartJsNativeAnimationProgressCount = (window.chartJsNativeAnimationProgressCount ?? 0) + 1; + window.chartJsNativeAnimationProgressArgs = { + chartId: context?.chart?.canvas?.id ?? null, + currentStep: context?.currentStep ?? null, + numSteps: context?.numSteps ?? null + }; + }, + chartEventBridgeAnimationComplete(context) { + window.chartJsNativeAnimationCompleteCount = (window.chartJsNativeAnimationCompleteCount ?? 0) + 1; + window.chartJsNativeAnimationCompleteArgs = { + chartId: context?.chart?.canvas?.id ?? null, + initial: context?.initial ?? null + }; + }, showLegendItem() { return true; }, @@ -625,6 +664,34 @@ const callbacks = Object.assign(Object.create(null), { return context.chart.width < 480 ? latestLabelPaddingSmall : latestLabelPaddingLarge; + }, + bubbleAddFromLeftX(context) { + return isNewBubblePoint(context) + ? context.chart.chartArea.left - 50 + : context.element?.x; + }, + bubbleAddFromTopY(context) { + return isNewBubblePoint(context) + ? context.chart.chartArea.top - 50 + : context.element?.y; + }, + bubbleAddFromRightX(context) { + return isNewBubblePoint(context) + ? context.chart.chartArea.right + 50 + : context.element?.x; + }, + bubbleAddFromBottomY(context) { + return isNewBubblePoint(context) + ? context.chart.chartArea.bottom + 50 + : context.element?.y; + }, + bubbleAddFromLeftRadius(context) { + return isNewBubblePoint(context) + ? 0 + : context.raw?.r; + }, + clearBubbleNewFlags(context) { + clearBubbleNewFlagsCore(context); } }); diff --git a/src/pax.BlazorChartJs.wasmtest/Components/Layout/NavMenu.razor b/src/pax.BlazorChartJs.wasmtest/Components/Layout/NavMenu.razor index 64bca23..e266090 100644 --- a/src/pax.BlazorChartJs.wasmtest/Components/Layout/NavMenu.razor +++ b/src/pax.BlazorChartJs.wasmtest/Components/Layout/NavMenu.razor @@ -138,6 +138,11 @@ DisposeChart + diff --git a/src/pax.BlazorChartJs.wasmtest/Components/Pages/BubbleChartPage.razor b/src/pax.BlazorChartJs.wasmtest/Components/Pages/BubbleChartPage.razor new file mode 100644 index 0000000..617e63c --- /dev/null +++ b/src/pax.BlazorChartJs.wasmtest/Components/Pages/BubbleChartPage.razor @@ -0,0 +1,6 @@ +@page "/bubblechart" +@using pax.BlazorChartJs.samplelib + +BubbleChart + + \ No newline at end of file diff --git a/src/pax.BlazorChartJs/ChartJsConfig/ChartJsConfig.Datasets.cs b/src/pax.BlazorChartJs/ChartJsConfig/ChartJsConfig.Datasets.cs index 5041653..ca48fa1 100644 --- a/src/pax.BlazorChartJs/ChartJsConfig/ChartJsConfig.Datasets.cs +++ b/src/pax.BlazorChartJs/ChartJsConfig/ChartJsConfig.Datasets.cs @@ -261,7 +261,8 @@ public void SetDatasets() public void SetDatasetsSmooth( IList datasets, IList? labels = null, - bool updateOptions = false) + bool updateOptions = false, + string? updateAnimation = null) { ArgumentNullException.ThrowIfNull(datasets); @@ -323,7 +324,8 @@ public void SetDatasetsSmooth( DatasetsToUpdateSmooth = datasetsToUpdateSmooth, DatasetIdsToRemove = datasetIdsToRemove, Labels = labels, - UpdateOptions = updateOptions + UpdateOptions = updateOptions, + UpdateAnimation = updateAnimation }); } @@ -445,7 +447,8 @@ public void ApplyDatasetChangesSmooth(DatasetsSmoothChangeSet changeSet) DatasetsToUpdateSmooth = effectiveDatasetsToUpdateSmooth, DatasetIdsToRemove = effectiveDatasetIdsToRemove, Labels = changeSet.Labels, - UpdateOptions = changeSet.UpdateOptions + UpdateOptions = changeSet.UpdateOptions, + UpdateAnimation = changeSet.UpdateAnimation }); } diff --git a/src/pax.BlazorChartJs/ChartJsConfig/ChartJsConfigEventArgs.cs b/src/pax.BlazorChartJs/ChartJsConfig/ChartJsConfigEventArgs.cs index 932f731..d903881 100644 --- a/src/pax.BlazorChartJs/ChartJsConfig/ChartJsConfigEventArgs.cs +++ b/src/pax.BlazorChartJs/ChartJsConfig/ChartJsConfigEventArgs.cs @@ -90,6 +90,15 @@ public class DatasetsSmoothChangeSet(IList desiredDatasetIds) /// Gets a value indicating whether the chart options should be updated as part of this change set. /// public bool UpdateOptions { get; init; } + + /// + /// Gets the Chart.js update mode to use for the consolidated chart update. + /// + /// + /// Set to a built-in mode such as none or to a custom transition name defined + /// in chart options. When , the default Chart.js update is used. + /// + public string? UpdateAnimation { get; init; } } public class DataAddEventArgs(string? label, diff --git a/src/pax.BlazorChartJs/ChartJsInterop.cs b/src/pax.BlazorChartJs/ChartJsInterop.cs index b02d58d..57f115a 100644 --- a/src/pax.BlazorChartJs/ChartJsInterop.cs +++ b/src/pax.BlazorChartJs/ChartJsInterop.cs @@ -17,7 +17,7 @@ public partial class ChartJsInterop(IJSRuntime jsRuntime, // ILogger logger, IOptions? options) : IAsyncDisposable { - private const string ChartJsInteropVersion = "0.9.0-preview2"; + private const string ChartJsInteropVersion = "0.9.1"; private const string ChartJsFunctionMarkerProperty = "\"__chartJsFunction\""; private readonly ChartJsSetupOptions? setupOptions = options?.Value; private readonly Lazy> moduleTask = new(() => jsRuntime.InvokeAsync( @@ -412,6 +412,7 @@ await module.InvokeVoidAsync( datasetIdsToRemove, changeSet.Labels, serializedOptions.Json, + changeSet.UpdateAnimation, serializedDatasetsToAdd.HasChartJsFunctions || serializedDatasetsToUpdateSmooth.HasChartJsFunctions || serializedOptions.HasChartJsFunctions) .ConfigureAwait(false); } diff --git a/src/pax.BlazorChartJs/TypeScript/chartDatasets.ts b/src/pax.BlazorChartJs/TypeScript/chartDatasets.ts index e06271a..b61b1a4 100644 --- a/src/pax.BlazorChartJs/TypeScript/chartDatasets.ts +++ b/src/pax.BlazorChartJs/TypeScript/chartDatasets.ts @@ -21,6 +21,7 @@ type DatasetListPayload = ChartJsDatasetPayload[] | string | null | undefined; type DatasetPayload = ChartJsDatasetPayload | string | null | undefined; type DatasetListOrCallbackFlag = DatasetListPayload | boolean; type DatasetOrCallbackFlag = DatasetPayload | boolean; +type ChartUpdateAnimation = string | null | undefined; type ResolvedDatasetListArguments = { setupOptions: ChartSetupOptionsPayload | null | undefined; @@ -35,6 +36,8 @@ type MutableInteropDatasetData = { splice(start: number, deleteCount: number, value: unknown): unknown[]; }; +const builtInUpdateAnimations = new Set(["default", "active", "hide", "show", "reset", "resize", "none"]); + function resolveDatasetListArguments( setupOptionsOrDatasets: ChartSetupOptionsPayload | DatasetListPayload, datasetsOrHasChartJsFunctions?: DatasetListOrCallbackFlag, @@ -283,6 +286,70 @@ function createDatasetMap(datasets: InteropChartDataset[]): Map)[propertyName]; + return typeof propertyValue === "string" ? propertyValue : undefined; +} + +function hasDatasetTypeTransition(chart: ChartInstance, updateAnimation: string): boolean { + const datasetOptions = chart.options?.datasets; + if (datasetOptions == undefined || typeof datasetOptions !== "object") { + return false; + } + + const checkedTypes = new Set(); + const chartType = getStringProperty(chart.config, "type"); + if (chartType != undefined) { + checkedTypes.add(chartType); + } + + for (let i = 0; i < chart.data.datasets.length; i++) { + const datasetType = getStringProperty(chart.data.datasets[i], "type"); + if (datasetType != undefined) { + checkedTypes.add(datasetType); + } + } + + for (const datasetType of checkedTypes) { + const typedDatasetOptions = (datasetOptions as Record)[datasetType]; + if (typedDatasetOptions != undefined + && typeof typedDatasetOptions === "object" + && hasOwnProperty((typedDatasetOptions as Record).transitions, updateAnimation)) { + return true; + } + } + + return false; +} + +function validateUpdateAnimation(chart: ChartInstance, updateAnimation: ChartUpdateAnimation): string | undefined { + if (updateAnimation == undefined) { + return undefined; + } + + if (typeof updateAnimation !== "string" || updateAnimation.length === 0) { + throw new Error("Dataset smooth update animation must be a non-empty string."); + } + + if (builtInUpdateAnimations.has(updateAnimation) + || hasOwnProperty(chart.options?.transitions, updateAnimation) + || hasDatasetTypeTransition(chart, updateAnimation)) { + return updateAnimation; + } + + throw new Error(`Dataset smooth update animation '${updateAnimation}' is not a built-in update mode and was not found in chart transitions.`); +} + export function setDatasetBinaryData( chartId: string, datasetId: string, @@ -537,7 +604,8 @@ function applyDatasetChangesSmoothCore( datasetIdsToRemove: string[], labels: string[] | null | undefined, options: ChartJsOptionsPayload | null | undefined, - beforeUpdate?: () => void) { + updateAnimation: ChartUpdateAnimation, + afterUpdate?: () => void) { if (!chart || !chart.data) { return; } @@ -580,8 +648,9 @@ function applyDatasetChangesSmoothCore( } chart.data.datasets = finalDatasets; - beforeUpdate?.(); - chart.update(); + const validatedUpdateAnimation = validateUpdateAnimation(chart, updateAnimation); + chart.update(validatedUpdateAnimation as UpdateMode | undefined); + afterUpdate?.(); } export async function applyDatasetChangesSmooth( @@ -593,13 +662,20 @@ export async function applyDatasetChangesSmooth( datasetIdsToRemove: string[], labels?: string[] | null, options?: ChartJsOptionsPayload | string | null, + updateAnimationOrHasChartJsFunctions?: ChartUpdateAnimation | boolean, hasChartJsFunctions?: boolean) { + const updateAnimation = typeof updateAnimationOrHasChartJsFunctions === "boolean" + ? undefined + : updateAnimationOrHasChartJsFunctions; + const resolvedHasChartJsFunctions = typeof updateAnimationOrHasChartJsFunctions === "boolean" + ? updateAnimationOrHasChartJsFunctions + : hasChartJsFunctions; const resolvedDatasetsToAdd = parseArrayPayload(datasetsToAdd) ?? []; const resolvedDatasetsToUpdateSmooth = parseArrayPayload(datasetsToUpdateSmooth) ?? []; const resolvedDatasetIdsToRemove = datasetIdsToRemove ?? []; const resolvedOptions = parsePayload(options); - if (hasChartJsFunctions === true) { + if (resolvedHasChartJsFunctions === true) { if (resolvedDatasetsToAdd.length > 0) { await resolveChartJsFunctions(setupOptions, { data: { datasets: resolvedDatasetsToAdd } }, true); } @@ -626,6 +702,7 @@ export async function applyDatasetChangesSmooth( resolvedDatasetIdsToRemove, labels, resolvedOptions, + updateAnimation, () => { if (resolvedOptions != undefined) { registerEvents(resolvedOptions, chartId, chart); diff --git a/src/pax.BlazorChartJs/TypeScript/chartEvents.ts b/src/pax.BlazorChartJs/TypeScript/chartEvents.ts index 283addb..8df2c35 100644 --- a/src/pax.BlazorChartJs/TypeScript/chartEvents.ts +++ b/src/pax.BlazorChartJs/TypeScript/chartEvents.ts @@ -170,7 +170,12 @@ export function registerEvents(dotnetConfigOptions: ChartEventBridgeOptions, cha if (dotnetConfigOptions.animation?.onProgressEvent == true) { const animation = chart.options.animation; if (animation && typeof animation === "object") { + const nativeOnProgress = typeof animation.onProgress === "function" + ? animation.onProgress + : undefined; + animation.onProgress = (context: AnimationEvent) => { + nativeOnProgress?.call(chart as unknown as ChartJsChart, context); triggerEvent(chartId, "progress", "animation", { CurrentStep: context.currentStep, NumSteps: context.numSteps @@ -182,7 +187,12 @@ export function registerEvents(dotnetConfigOptions: ChartEventBridgeOptions, cha if (dotnetConfigOptions.animation?.onCompleteEvent == true) { const animation = chart.options.animation; if (animation && typeof animation === "object") { + const nativeOnComplete = typeof animation.onComplete === "function" + ? animation.onComplete + : undefined; + animation.onComplete = (context: AnimationEvent) => { + nativeOnComplete?.call(chart as unknown as ChartJsChart, context); triggerEvent(chartId, "complete", "animation", { Initial: context.initial }); diff --git a/src/pax.BlazorChartJs/TypeScript/version.ts b/src/pax.BlazorChartJs/TypeScript/version.ts index 2dd911c..4cb7369 100644 --- a/src/pax.BlazorChartJs/TypeScript/version.ts +++ b/src/pax.BlazorChartJs/TypeScript/version.ts @@ -1 +1 @@ -export const chartJsInteropVersion = "0.9.0-preview2"; +export const chartJsInteropVersion = "0.9.1"; diff --git a/src/pax.BlazorChartJs/pax.BlazorChartJs.csproj b/src/pax.BlazorChartJs/pax.BlazorChartJs.csproj index 0d5f0e4..fabdea6 100644 --- a/src/pax.BlazorChartJs/pax.BlazorChartJs.csproj +++ b/src/pax.BlazorChartJs/pax.BlazorChartJs.csproj @@ -9,12 +9,12 @@ README.md https://github.com/ipax77/pax.BlazorChartJs blazor;dotnet;wrapper;chartjs - 0.9.0.0 + 0.9.1.0 Philipp Hetzner Philipp Hetzner - 0.9.0 - 0.9.0.0 - 0.9.0 + 0.9.1 + 0.9.1.0 + 0.9.1 MIT true true diff --git a/src/pax.BlazorChartJs/wwwroot/chartJsInterop.js b/src/pax.BlazorChartJs/wwwroot/chartJsInterop.js index 48a4df3..49221e7 100644 --- a/src/pax.BlazorChartJs/wwwroot/chartJsInterop.js +++ b/src/pax.BlazorChartJs/wwwroot/chartJsInterop.js @@ -143,7 +143,7 @@ function isChartJsFunctionMarker(value, path) { } // TypeScript/version.ts -var chartJsInteropVersion = "0.9.0-preview2"; +var chartJsInteropVersion = "0.9.1"; // TypeScript/chartEvents.ts async function triggerEvent(chartId, event, source, data) { @@ -237,7 +237,9 @@ function registerEvents(dotnetConfigOptions, chartId, chart) { if (dotnetConfigOptions.animation?.onProgressEvent == true) { const animation = chart.options.animation; if (animation && typeof animation === "object") { + const nativeOnProgress = typeof animation.onProgress === "function" ? animation.onProgress : void 0; animation.onProgress = (context) => { + nativeOnProgress?.call(chart, context); triggerEvent(chartId, "progress", "animation", { CurrentStep: context.currentStep, NumSteps: context.numSteps @@ -248,7 +250,9 @@ function registerEvents(dotnetConfigOptions, chartId, chart) { if (dotnetConfigOptions.animation?.onCompleteEvent == true) { const animation = chart.options.animation; if (animation && typeof animation === "object") { + const nativeOnComplete = typeof animation.onComplete === "function" ? animation.onComplete : void 0; animation.onComplete = (context) => { + nativeOnComplete?.call(chart, context); triggerEvent(chartId, "complete", "animation", { Initial: context.initial }); @@ -770,6 +774,7 @@ function decodeBinaryInt32Y(bytes, payload) { } // TypeScript/chartDatasets.ts +var builtInUpdateAnimations = /* @__PURE__ */ new Set(["default", "active", "hide", "show", "reset", "resize", "none"]); function resolveDatasetListArguments(setupOptionsOrDatasets, datasetsOrHasChartJsFunctions, hasChartJsFunctions) { if (Array.isArray(setupOptionsOrDatasets) || typeof setupOptionsOrDatasets === "string") { return { @@ -961,6 +966,52 @@ function createDatasetMap(datasets) { } return datasetsById; } +function hasOwnProperty(value, propertyName) { + return value != void 0 && typeof value === "object" && Object.prototype.hasOwnProperty.call(value, propertyName); +} +function getStringProperty(value, propertyName) { + if (value == void 0 || typeof value !== "object") { + return void 0; + } + const propertyValue = value[propertyName]; + return typeof propertyValue === "string" ? propertyValue : void 0; +} +function hasDatasetTypeTransition(chart, updateAnimation) { + const datasetOptions = chart.options?.datasets; + if (datasetOptions == void 0 || typeof datasetOptions !== "object") { + return false; + } + const checkedTypes = /* @__PURE__ */ new Set(); + const chartType = getStringProperty(chart.config, "type"); + if (chartType != void 0) { + checkedTypes.add(chartType); + } + for (let i = 0; i < chart.data.datasets.length; i++) { + const datasetType = getStringProperty(chart.data.datasets[i], "type"); + if (datasetType != void 0) { + checkedTypes.add(datasetType); + } + } + for (const datasetType of checkedTypes) { + const typedDatasetOptions = datasetOptions[datasetType]; + if (typedDatasetOptions != void 0 && typeof typedDatasetOptions === "object" && hasOwnProperty(typedDatasetOptions.transitions, updateAnimation)) { + return true; + } + } + return false; +} +function validateUpdateAnimation(chart, updateAnimation) { + if (updateAnimation == void 0) { + return void 0; + } + if (typeof updateAnimation !== "string" || updateAnimation.length === 0) { + throw new Error("Dataset smooth update animation must be a non-empty string."); + } + if (builtInUpdateAnimations.has(updateAnimation) || hasOwnProperty(chart.options?.transitions, updateAnimation) || hasDatasetTypeTransition(chart, updateAnimation)) { + return updateAnimation; + } + throw new Error(`Dataset smooth update animation '${updateAnimation}' is not a built-in update mode and was not found in chart transitions.`); +} function setDatasetBinaryData(chartId, datasetId, bytes, pointCount, format, xOffset = 0, yOffset = 0, byteStride, updateMode = "none") { setDatasetsBinaryData( chartId, @@ -1142,7 +1193,7 @@ async function setDatasets(chartId, setupOptionsOrDatasets, datasetsOrHasChartJs } setDatasetsCore(chart, resolvedArguments.datasets); } -function applyDatasetChangesSmoothCore(chart, desiredDatasetIds, datasetsToAdd, datasetsToUpdateSmooth, datasetIdsToRemove, labels, options, beforeUpdate) { +function applyDatasetChangesSmoothCore(chart, desiredDatasetIds, datasetsToAdd, datasetsToUpdateSmooth, datasetIdsToRemove, labels, options, updateAnimation, afterUpdate) { if (!chart || !chart.data) { return; } @@ -1178,15 +1229,18 @@ function applyDatasetChangesSmoothCore(chart, desiredDatasetIds, datasetsToAdd, } } chart.data.datasets = finalDatasets; - beforeUpdate?.(); - chart.update(); + const validatedUpdateAnimation = validateUpdateAnimation(chart, updateAnimation); + chart.update(validatedUpdateAnimation); + afterUpdate?.(); } -async function applyDatasetChangesSmooth(chartId, setupOptions, desiredDatasetIds, datasetsToAdd, datasetsToUpdateSmooth, datasetIdsToRemove, labels, options, hasChartJsFunctions) { +async function applyDatasetChangesSmooth(chartId, setupOptions, desiredDatasetIds, datasetsToAdd, datasetsToUpdateSmooth, datasetIdsToRemove, labels, options, updateAnimationOrHasChartJsFunctions, hasChartJsFunctions) { + const updateAnimation = typeof updateAnimationOrHasChartJsFunctions === "boolean" ? void 0 : updateAnimationOrHasChartJsFunctions; + const resolvedHasChartJsFunctions = typeof updateAnimationOrHasChartJsFunctions === "boolean" ? updateAnimationOrHasChartJsFunctions : hasChartJsFunctions; const resolvedDatasetsToAdd = parseArrayPayload(datasetsToAdd) ?? []; const resolvedDatasetsToUpdateSmooth = parseArrayPayload(datasetsToUpdateSmooth) ?? []; const resolvedDatasetIdsToRemove = datasetIdsToRemove ?? []; const resolvedOptions = parsePayload(options); - if (hasChartJsFunctions === true) { + if (resolvedHasChartJsFunctions === true) { if (resolvedDatasetsToAdd.length > 0) { await resolveChartJsFunctions(setupOptions, { data: { datasets: resolvedDatasetsToAdd } }, true); } @@ -1209,6 +1263,7 @@ async function applyDatasetChangesSmooth(chartId, setupOptions, desiredDatasetId resolvedDatasetIdsToRemove, labels, resolvedOptions, + updateAnimation, () => { if (resolvedOptions != void 0) { registerEvents(resolvedOptions, chartId, chart); diff --git a/tests/pax.BlazorChartJs.pwtests/BarChartTests.cs b/tests/pax.BlazorChartJs.pwtests/BarChartTests.cs index c3a2f6c..e2dbd11 100644 --- a/tests/pax.BlazorChartJs.pwtests/BarChartTests.cs +++ b/tests/pax.BlazorChartJs.pwtests/BarChartTests.cs @@ -139,7 +139,7 @@ await Expect(Page).ToHaveTitleAsync(new Regex("BarChart"), var snapshot = await Page.EvaluateAsync( """ async (chartId) => { - const chartInterop = await import('./_content/pax.BlazorChartJs/chartJsInterop.js?v=0.9.0-preview2'); + const chartInterop = await import('./_content/pax.BlazorChartJs/chartJsInterop.js?v=0.9.1'); const chart = Chart.getChart(chartId); chart.stop(); chart.resize(); diff --git a/tests/pax.BlazorChartJs.pwtests/ChartEventsTests.cs b/tests/pax.BlazorChartJs.pwtests/ChartEventsTests.cs index edeeceb..4434f90 100644 --- a/tests/pax.BlazorChartJs.pwtests/ChartEventsTests.cs +++ b/tests/pax.BlazorChartJs.pwtests/ChartEventsTests.cs @@ -139,6 +139,84 @@ await canvas.ClickAsync(new Microsoft.Playwright.LocatorClickOptions() Assert.That(snapshot, Is.EqualTo($"1|{canvasId}|click")); } + [Test] + public async Task SmoothOptionsReplacementPreservesNativeClickCallbackWhenClickEventBridgeIsEnabled() + { + var canvasId = await OpenEventsChartAsync(); + + await Page.EvaluateAsync( + """ + async (chartId) => { + const chartInterop = await import('./_content/pax.BlazorChartJs/chartJsInterop.js?v=0.9.1'); + const callbacksUrl = new URL('./_content/pax.BlazorChartJs.samplelib/chartJsCallbacks.js', document.baseURI).href; + const chart = Chart.getChart(chartId); + chart.data.datasets[0].id ??= 'smooth-event-primary'; + chart.__smoothOptionsUpdateCount = 0; + const originalUpdate = chart.update.bind(chart); + chart.update = (...args) => { + chart.__smoothOptionsUpdateCount++; + return originalUpdate(...args); + }; + + window.chartJsNativeClickCount = 0; + window.chartJsNativeClickArgs = null; + + await chartInterop.applyDatasetChangesSmooth( + chartId, + { chartJsCallbacksModuleLocation: callbacksUrl }, + chart.data.datasets.map(dataset => dataset.id), + [], + [], + [], + null, + { + responsive: true, + maintainAspectRatio: true, + onClick: { __chartJsFunction: 'chartEventBridgeClick' }, + onClickEvent: true + }, + 'none', + true); + } + """, + canvasId); + + var functionSnapshot = await Page.EvaluateAsync( + """ + (chartId) => { + const chart = Chart.getChart(chartId); + return [ + typeof chart.options.onClick, + chart.__smoothOptionsUpdateCount + ].join('|'); + } + """, + canvasId); + + Assert.That(functionSnapshot, Is.EqualTo("function|1")); + + var canvas = Page.Locator("canvas").First; + await canvas.ClickAsync(new Microsoft.Playwright.LocatorClickOptions() + { + Position = new Microsoft.Playwright.Position() { X = 100, Y = 100 } + }); + + var clickText = await WaitForLatestEventTextAsync(new Regex(@"ChartJsLabelClickEvent")); + var callbackSnapshot = await Page.EvaluateAsync( + """ + (chartId) => [ + window.chartJsNativeClickCount ?? 0, + window.chartJsNativeClickArgs?.chartId ?? '', + window.chartJsNativeClickArgs?.eventType ?? '', + Chart.getChart(chartId).__smoothOptionsUpdateCount + ].join('|') + """, + canvasId); + + Assert.That(clickText, Does.Contain("ChartJsLabelClickEvent")); + Assert.That(callbackSnapshot, Is.EqualTo($"1|{canvasId}|click|1")); + } + [Test] public async Task NativeResizeCallbackIsPreservedWhenResizeEventBridgeIsEnabled() { @@ -175,7 +253,7 @@ public async Task NativeLegendCallbacksArePreservedWhenLegendEventBridgeIsEnable await Page.EvaluateAsync( """ async (chartId) => { - const chartInterop = await import('./_content/pax.BlazorChartJs/chartJsInterop.js?v=0.9.0-preview2'); + const chartInterop = await import('./_content/pax.BlazorChartJs/chartJsInterop.js?v=0.9.1'); const callbacksUrl = new URL('./_content/pax.BlazorChartJs.samplelib/chartJsCallbacks.js', document.baseURI).href; await chartInterop.updateChartOptions( chartId, @@ -229,6 +307,58 @@ await chartInterop.updateChartOptions( Assert.That(snapshot, Is.EqualTo($"1|1|1|{canvasId}|{canvasId}|{canvasId}")); } + [Test] + public async Task NativeAnimationCallbacksArePreservedWhenAnimationEventBridgeIsEnabled() + { + var canvasId = await OpenEventsChartAsync(); + + await Page.EvaluateAsync( + """ + async (chartId) => { + const chartInterop = await import('./_content/pax.BlazorChartJs/chartJsInterop.js?v=0.9.1'); + const callbacksUrl = new URL('./_content/pax.BlazorChartJs.samplelib/chartJsCallbacks.js', document.baseURI).href; + await chartInterop.updateChartOptions( + chartId, + { chartJsCallbacksModuleLocation: callbacksUrl }, + { + responsive: true, + maintainAspectRatio: true, + animation: { + duration: 0, + onProgress: { __chartJsFunction: 'chartEventBridgeAnimationProgress' }, + onComplete: { __chartJsFunction: 'chartEventBridgeAnimationComplete' }, + onProgressEvent: true, + onCompleteEvent: true + } + }, + true); + + window.chartJsNativeAnimationProgressCount = 0; + window.chartJsNativeAnimationCompleteCount = 0; + + const chart = Chart.getChart(chartId); + chart.options.animation.onProgress({ chart, currentStep: 1, numSteps: 2 }); + chart.options.animation.onComplete({ chart, initial: false }); + } + """, + canvasId); + + var animationText = await WaitForLatestEventTextAsync(new Regex(@"ChartJsAnimationCompleteEvent")); + var snapshot = await Page.EvaluateAsync( + """ + (chartId) => [ + (window.chartJsNativeAnimationProgressCount ?? 0) > 0, + window.chartJsNativeAnimationCompleteCount ?? 0, + window.chartJsNativeAnimationProgressArgs?.chartId ?? '', + window.chartJsNativeAnimationCompleteArgs?.chartId ?? '' + ].join('|') + """, + canvasId); + + Assert.That(animationText, Does.Contain("ChartJsAnimationCompleteEvent")); + Assert.That(snapshot, Is.EqualTo($"true|1|{canvasId}|{canvasId}")); + } + [Test] public async Task HoverEventTest() { @@ -465,7 +595,7 @@ private async Task ConfigureNativeCallbackOptionAsync( await Page.EvaluateAsync( """ async ([chartId, optionName, callbackName, eventFlagName, responsive]) => { - const chartInterop = await import('./_content/pax.BlazorChartJs/chartJsInterop.js?v=0.9.0-preview2'); + const chartInterop = await import('./_content/pax.BlazorChartJs/chartJsInterop.js?v=0.9.1'); const callbacksUrl = new URL('./_content/pax.BlazorChartJs.samplelib/chartJsCallbacks.js', document.baseURI).href; const options = { responsive, diff --git a/tests/pax.BlazorChartJs.pwtests/ChartOptionsTests.cs b/tests/pax.BlazorChartJs.pwtests/ChartOptionsTests.cs index 4f4801f..416fd67 100644 --- a/tests/pax.BlazorChartJs.pwtests/ChartOptionsTests.cs +++ b/tests/pax.BlazorChartJs.pwtests/ChartOptionsTests.cs @@ -123,7 +123,7 @@ public async Task TickCallbackResolvesRegisteredCallbackForCustomScaleId() var formattedValue = await Page.EvaluateAsync( @"async (chartId) => { - const chartInterop = await import('./_content/pax.BlazorChartJs/chartJsInterop.js?v=0.9.0-preview2'); + const chartInterop = await import('./_content/pax.BlazorChartJs/chartJsInterop.js?v=0.9.1'); const callbacksUrl = new URL('./_content/pax.BlazorChartJs.samplelib/chartJsCallbacks.js', document.baseURI).href; await chartInterop.updateChartOptions( @@ -157,7 +157,7 @@ public async Task TooltipCallbackResolvesRegisteredCallbackFromGenericPath() var formattedValue = await Page.EvaluateAsync( @"async (chartId) => { - const chartInterop = await import('./_content/pax.BlazorChartJs/chartJsInterop.js?v=0.9.0-preview2'); + const chartInterop = await import('./_content/pax.BlazorChartJs/chartJsInterop.js?v=0.9.1'); const callbacksUrl = new URL('./_content/pax.BlazorChartJs.samplelib/chartJsCallbacks.js', document.baseURI).href; await chartInterop.updateChartOptions( @@ -198,7 +198,7 @@ await Page.WaitForFunctionAsync( var markerName = await Page.EvaluateAsync( @"async (chartId) => { - const chartInterop = await import('./_content/pax.BlazorChartJs/chartJsInterop.js?v=0.9.0-preview2'); + const chartInterop = await import('./_content/pax.BlazorChartJs/chartJsInterop.js?v=0.9.1'); const callbacksUrl = new URL('./_content/pax.BlazorChartJs.samplelib/chartJsCallbacks.js', document.baseURI).href; await chartInterop.updateChartOptions( @@ -230,7 +230,7 @@ public async Task CallbackMarkerWithExtraPropertiesFailsClosedWithPath() var errorMessage = await Page.EvaluateAsync( @"async (chartId) => { - const chartInterop = await import('./_content/pax.BlazorChartJs/chartJsInterop.js?v=0.9.0-preview2'); + const chartInterop = await import('./_content/pax.BlazorChartJs/chartJsInterop.js?v=0.9.1'); const callbacksUrl = new URL('./_content/pax.BlazorChartJs.samplelib/chartJsCallbacks.js', document.baseURI).href; try { @@ -269,7 +269,7 @@ public async Task CallbackMarkerWithoutConfiguredModuleFailsClosedWithPath() var errorMessage = await Page.EvaluateAsync( @"async (chartId) => { - const chartInterop = await import('./_content/pax.BlazorChartJs/chartJsInterop.js?v=0.9.0-preview2'); + const chartInterop = await import('./_content/pax.BlazorChartJs/chartJsInterop.js?v=0.9.1'); try { await chartInterop.updateChartOptions( diff --git a/tests/pax.BlazorChartJs.pwtests/DataLabelsFormatterTests.cs b/tests/pax.BlazorChartJs.pwtests/DataLabelsFormatterTests.cs index e564fbe..4e0f0e4 100644 --- a/tests/pax.BlazorChartJs.pwtests/DataLabelsFormatterTests.cs +++ b/tests/pax.BlazorChartJs.pwtests/DataLabelsFormatterTests.cs @@ -56,7 +56,7 @@ public async Task MissingDataLabelsFormatterCallbackFailsClosed() var errorMessage = await Page.EvaluateAsync( @"async (chartId) => { - const chartInterop = await import('./_content/pax.BlazorChartJs/chartJsInterop.js?v=0.9.0-preview2'); + const chartInterop = await import('./_content/pax.BlazorChartJs/chartJsInterop.js?v=0.9.1'); const callbacksUrl = new URL('./_content/pax.BlazorChartJs.samplelib/chartJsCallbacks.js', document.baseURI).href; try { @@ -82,7 +82,7 @@ public async Task InvalidDataLabelsFormatterCallbackNameFailsClosed() var errorMessage = await Page.EvaluateAsync( @"async (chartId) => { - const chartInterop = await import('./_content/pax.BlazorChartJs/chartJsInterop.js?v=0.9.0-preview2'); + const chartInterop = await import('./_content/pax.BlazorChartJs/chartJsInterop.js?v=0.9.1'); const callbacksUrl = new URL('./_content/pax.BlazorChartJs.samplelib/chartJsCallbacks.js', document.baseURI).href; try { diff --git a/tests/pax.BlazorChartJs.pwtests/DisposeTests.cs b/tests/pax.BlazorChartJs.pwtests/DisposeTests.cs index d2fc992..be623d0 100644 --- a/tests/pax.BlazorChartJs.pwtests/DisposeTests.cs +++ b/tests/pax.BlazorChartJs.pwtests/DisposeTests.cs @@ -76,7 +76,7 @@ public async Task DatasetInteropAfterDisposeIsIgnored() await Page.EvaluateAsync( @"async (chartId) => { - const chartInterop = await import('./_content/pax.BlazorChartJs/chartJsInterop.js?v=0.9.0-preview2'); + const chartInterop = await import('./_content/pax.BlazorChartJs/chartJsInterop.js?v=0.9.1'); const dataset = { id: 'disposed-test', label: 'Disposed Test', data: [1, 2, 3] }; chartInterop.addDatasets(chartId, [dataset]); chartInterop.updateDatasets(chartId, [dataset]); diff --git a/tests/pax.BlazorChartJs.pwtests/LineChartTests.cs b/tests/pax.BlazorChartJs.pwtests/LineChartTests.cs index b820b25..6ff597b 100644 --- a/tests/pax.BlazorChartJs.pwtests/LineChartTests.cs +++ b/tests/pax.BlazorChartJs.pwtests/LineChartTests.cs @@ -141,7 +141,7 @@ await Expect(Page).ToHaveTitleAsync(new Regex("LineChart"), var snapshot = await Page.EvaluateAsync( @"async (chartId) => { - const chartInterop = await import('./_content/pax.BlazorChartJs/chartJsInterop.js?v=0.9.0-preview2'); + const chartInterop = await import('./_content/pax.BlazorChartJs/chartJsInterop.js?v=0.9.1'); const chart = Chart.getChart(chartId); let updateCount = 0; const originalUpdate = chart.update.bind(chart); diff --git a/tests/pax.BlazorChartJs.pwtests/UpdateDatasetTests.cs b/tests/pax.BlazorChartJs.pwtests/UpdateDatasetTests.cs index e2104d7..4d5b34e 100644 --- a/tests/pax.BlazorChartJs.pwtests/UpdateDatasetTests.cs +++ b/tests/pax.BlazorChartJs.pwtests/UpdateDatasetTests.cs @@ -198,9 +198,11 @@ await Page.EvaluateAsync( @"(chartId) => { const chart = Chart.getChart(chartId); chart.__setSmoothUpdateCount = 0; + chart.__setSmoothUpdateArgs = []; const originalUpdate = chart.update.bind(chart); chart.update = (...args) => { chart.__setSmoothUpdateCount++; + chart.__setSmoothUpdateArgs.push(args); return originalUpdate(...args); }; }", @@ -218,7 +220,8 @@ await Page.WaitForFunctionAsync( && chart.data.datasets[1].id === 'upsert-primary' && chart.data.datasets[1].borderWidth === 6 && chart.options.responsive === false - && chart.__setSmoothUpdateCount === 1; + && chart.__setSmoothUpdateCount === 1 + && chart.__setSmoothUpdateArgs[0][0] === 'none'; }", canvasId, new PageWaitForFunctionOptions { Timeout = (float)Startup.WasmLoadDelay.TotalMilliseconds }); @@ -234,12 +237,166 @@ await Page.WaitForFunctionAsync( chart.data.datasets[1].barThickness, chart.options.responsive, chart.options.maintainAspectRatio, - chart.__setSmoothUpdateCount + chart.__setSmoothUpdateCount, + chart.__setSmoothUpdateArgs[0][0] ].join('|'); }", canvasId); - Assert.That(snapshot, Is.EqualTo("upsert-added,upsert-primary|Apr,May,Jun,Jul|Dataset 1 Set Smooth|6|19|False|False|1").IgnoreCase); + Assert.That(snapshot, Is.EqualTo("upsert-added,upsert-primary|Apr,May,Jun,Jul|Dataset 1 Set Smooth|6|19|False|False|1|none").IgnoreCase); + } + + [Test] + public async Task BubbleSetDatasetsSmoothUsesCustomAndNoneUpdateAnimations() + { + await Page.GotoAsync(Startup.GetSampleBaseUrl() + "/bubblechart"); + + await Expect(Page).ToHaveTitleAsync(new Regex("BubbleChart"), + new PageAssertionsToHaveTitleOptions() { Timeout = (float)Startup.WasmLoadDelay.TotalMilliseconds }); + + var canvasId = await Page.Locator("canvas").GetAttributeAsync("id"); + Assert.That(Guid.TryParse(canvasId, out _), Is.True); + + await Page.WaitForFunctionAsync( + @"(chartId) => typeof Chart !== 'undefined' && Chart.getChart(chartId) != undefined", + canvasId, + new PageWaitForFunctionOptions { Timeout = (float)Startup.WasmLoadDelay.TotalMilliseconds }); + + await Task.Delay(Startup.ChartJsLoadDelay); + + await Page.EvaluateAsync( + @"(chartId) => { + const chart = Chart.getChart(chartId); + chart.__bubbleSmoothUpdateCount = 0; + chart.__bubbleSmoothUpdateArgs = []; + const originalUpdate = chart.update.bind(chart); + chart.update = (...args) => { + chart.__bubbleSmoothUpdateCount++; + chart.__bubbleSmoothUpdateArgs.push(args); + return originalUpdate(...args); + }; + }", + canvasId); + + var addData = Page.GetByText("Add Data From Left", new PageGetByTextOptions() { Exact = true }); + await Expect(addData).ToHaveAttributeAsync("type", "button"); + await addData.ClickAsync(); + + await Page.WaitForFunctionAsync( + @"(chartId) => { + const chart = Chart.getChart(chartId); + return chart?.data?.datasets?.[0]?.data?.length === 6 + && chart.__bubbleSmoothUpdateCount === 1 + && chart.__bubbleSmoothUpdateArgs[0][0] === 'addFromLeft'; + }", + canvasId, + new PageWaitForFunctionOptions { Timeout = (float)Startup.WasmLoadDelay.TotalMilliseconds }); + + await Page.WaitForFunctionAsync( + @"(chartId) => { + const chart = Chart.getChart(chartId); + return chart?.data?.datasets?.[0]?.data?.every(point => point._new !== true) === true; + }", + canvasId, + new PageWaitForFunctionOptions { Timeout = (float)Startup.WasmLoadDelay.TotalMilliseconds }); + + var addDataNoAnimation = Page.GetByText("Add Data No Animation", new PageGetByTextOptions() { Exact = true }); + await Expect(addDataNoAnimation).ToHaveAttributeAsync("type", "button"); + await addDataNoAnimation.ClickAsync(); + + await Page.WaitForFunctionAsync( + @"(chartId) => { + const chart = Chart.getChart(chartId); + return chart?.data?.datasets?.[0]?.data?.length === 7 + && chart.__bubbleSmoothUpdateCount === 2 + && chart.__bubbleSmoothUpdateArgs[1][0] === 'none'; + }", + canvasId, + new PageWaitForFunctionOptions { Timeout = (float)Startup.WasmLoadDelay.TotalMilliseconds }); + + var snapshot = await Page.EvaluateAsync( + @"(chartId) => { + const chart = Chart.getChart(chartId); + return [ + chart.data.datasets[0].id, + chart.data.datasets[0].data.length, + chart.data.datasets[0].data.filter(point => point._new === true).length, + chart.__bubbleSmoothUpdateCount, + chart.__bubbleSmoothUpdateArgs.map(args => args[0]).join(',') + ].join('|'); + }", + canvasId); + + Assert.That(snapshot, Is.EqualTo("bubble-primary|7|0|2|addFromLeft,none")); + + await Page.EvaluateAsync( + @"(chartId) => { + const chart = Chart.getChart(chartId); + chart.options.animation.duration = 5000; + }", + canvasId); + + await addData.ClickAsync(); + + await Page.WaitForFunctionAsync( + @"(chartId) => { + const chart = Chart.getChart(chartId); + return chart?.data?.datasets?.[0]?.data?.length === 8 + && chart.__bubbleSmoothUpdateCount === 3 + && chart.__bubbleSmoothUpdateArgs[2][0] === 'addFromLeft' + && chart.data.datasets[0].data.some(point => point._new === true); + }", + canvasId, + new PageWaitForFunctionOptions { Timeout = (float)Startup.WasmLoadDelay.TotalMilliseconds }); + + var removeData = Page.GetByText("Remove Data", new PageGetByTextOptions() { Exact = true }); + await Expect(removeData).ToHaveAttributeAsync("type", "button"); + await removeData.ClickAsync(); + + await Page.WaitForFunctionAsync( + @"(chartId) => { + const chart = Chart.getChart(chartId); + return chart?.data?.datasets?.[0]?.data?.length === 7 + && chart.data.datasets[0].data.every(point => point._new !== true); + }", + canvasId, + new PageWaitForFunctionOptions { Timeout = (float)Startup.WasmLoadDelay.TotalMilliseconds }); + } + + [Test] + public async Task SetDatasetsSmoothRejectsUnknownCustomUpdateAnimation() + { + var canvasId = await OpenUpdateChartAndTrackUpdates("__invalidAnimationUpdateCount"); + + var errorMessage = await Page.EvaluateAsync( + @"async (chartId) => { + const interop = await import('/_content/pax.BlazorChartJs/chartJsInterop.js'); + try { + await interop.applyDatasetChangesSmooth( + chartId, + null, + ['upsert-primary', 'upsert-remove'], + [], + [], + [], + null, + null, + 'missingTransition', + false); + } catch (error) { + return String(error?.message ?? error); + } + + return ''; + }", + canvasId); + + var updateCount = await Page.EvaluateAsync( + @"(chartId) => Chart.getChart(chartId).__invalidAnimationUpdateCount", + canvasId); + + Assert.That(errorMessage, Does.Contain("missingTransition")); + Assert.That(updateCount, Is.EqualTo(0)); } [Test]