Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Passing Function To ChartJS #313

Closed
andrewwilkin opened this issue Oct 13, 2019 · 27 comments
Closed

Passing Function To ChartJS #313

andrewwilkin opened this issue Oct 13, 2019 · 27 comments
Labels
Status: Help Wanted Extra attention is needed Type: Feature Request ⛱ New feature or request
Milestone

Comments

@andrewwilkin
Copy link
Contributor

ChartJS uses functions in the chart config, such as changing the sort order on tool tips for instance, e.g.

tooltips: {
  mode: 'label',
  itemSort: function(a, b) {
    return b.datasetIndex - a.datasetIndex
  },

},

The challenge I'm having is working out how to pass this from C# to JavaScript given the serialization and deserialization that is going on and am left scratching my head to work out if it is even possible.

Tried adding some logic in the JSON.Parse in the JavaScript and parsing like this:

        if (optionsJsonString) {
            console.log(optionsJsonString);
            options = JSON.parse(optionsJsonString, (key, value) => {
                if (typeof value !== 'string')
                    return value;

                if (!value.match(/function/))
                    return value;

                var fn = eval(value);
                return fn;
            });
        }

But that does not seem to work either. Any ideas?

@stsrki
Copy link
Collaborator

stsrki commented Oct 13, 2019

I have also tried to come up with passing the functions to js a few months ago. As far as I know it's not possible to serialize or deserialize them.

I think the best way and maybe only way is to have a js function body saved as a string in blazor object and then do some magic on javascript side. But that would probably need to be done manually for every possible function supported by the chartjs. Something that you already tried yourself.

Now that I think of maybe there is a better way. Maybe register event handler in javascript and then pass it to through dotnetadapter to be executed on the blazor side. If would still require to manual map all the function.

Definitely is not an easy task.

@andrewwilkin
Copy link
Contributor Author

Yep, in the end went with this which works but not very dynamic...

        if (optionsJsonString) {
            console.log(optionsJsonString);
            options = JSON.parse(optionsJsonString, (key, value) => {
                if (typeof value !== 'string')
                    return value;

                if (!value.match(/Function/))
                    return value;

                var fn = window.blazoriseCharts[value];
                return fn;
            });

Then defining the function as something like:

    reverseSortFunction: (a, b) => {
        return b.datasetIndex - a.datasetIndex;
    }

Then the JSON parse picks that it has Function in the name and looks for it and wires it together. Feels uber hacky, don't like the idea of having to go back and forth over the interop just to wirse things together.

@andrewwilkin
Copy link
Contributor Author

Painful when it comes to formatting labels and tool tips mind, especially dealing with the fact that the chart could be for say different currencies that require different ways of formatting (thousands separator and symbol for instance).

@andrewwilkin
Copy link
Contributor Author

This just doesn't work either :(

    formatText: (value) => {
        return numeral(value).format(window.blazoriseCharts["textFormat"]);
    }

Whilst the value changes, the function doesn't - gets locked in with the creation of the function and does not re-evaluate what the variable has. Can't seem to find a way round that.

@stsrki
Copy link
Collaborator

stsrki commented Oct 13, 2019

I would rather go the other way instead of messing with javascript functions. Just hook the event handlers and pass them to Blazor. Do this every time data or options are initialized or updated...

FYI, this is just some pseudo-code to give you an idea. I'm not sure if it's even possible

if ( chart.options.tooltips ) {
    chart.options.tooltips.ItemSort:(a, b) => {
        dotnetAdapter.invokeMethodAsync("TooltipsItemSortHandler", a, b);
    }
}

Then on the Blazor side:

public Task TooltipsItemSortHandler(SomeModel a, SomeModel b){
    return TooltipsItemSorting.InvokeAsync(a, b);
}

[Parameter] public EventCallback<TooltipsItemSortEventArgs> TooltipsItemSorting { get; set; }

Now with this approach the good thing is that anyone can use event handlers in razor.

LineChart @ref="lineChart" TItem="double" TooltipsItemSorting="@((a,b)=>b.DatasetIndex - a.DatasetIndex)" />

The bad thing is that every possible event had to be mapped. And since allot of jsinterop is involved it can be pretty slow. And don't forget the Blazor serializer is in pretty bad state now so maybe it won't be possible to do it.

@stsrki
Copy link
Collaborator

stsrki commented Oct 13, 2019

I will reopen this so I can investigate more sometimes in the future. A lot of refactoring is going for 0,8.7 so I need some time.

@stsrki stsrki reopened this Oct 13, 2019
@stsrki stsrki added Type: Feature Request ⛱ New feature or request Status: Help Wanted Extra attention is needed labels Oct 13, 2019
@stsrki stsrki added this to the Backlog milestone Oct 13, 2019
@andrewwilkin
Copy link
Contributor Author

Until the serializer is fixed, hand crafting the JSON is the only way to get more complex charts without breaking things, for instance wanting to set the min and max of the chart such that Y-axis doesn't jump up and down as you change data.

Will have a play next weekend if I get the chance on using the interop, formatting the labels and the tooltips is definitely on my must do list. Will share how I get on.

@andrewwilkin
Copy link
Contributor Author

In terms of registering the callbacks, would it be best to do so following the same pattern you already have with HasDelegate on the initializa:

await JS.InitializeChart( JSRuntime, dotNetObjectRef, Clicked.HasDelegate, Hovered.HasDelegate, ElementId, Type, Data, Options, DataJsonString, OptionsJsonString );

Don't like the idea of having to infer if there is a call back just on the basis of the presence of say the tooltip.

So far I can see the following call backs I would use:

May want to do more with the Tooltips, though there a lot of them and passing them in the constructor is somewhat ugly.

@stsrki
Copy link
Collaborator

stsrki commented Oct 14, 2019

Instead of passing every callback as a boolean flag (Clicked.HasDelegate) maybe create an callback options class.

class ChartCallbacks
{
    public bool Clicked {get;set;}
    public bool Hovered {get;set;}
    public ChartTooltipCallbacks Tooltips {get;set;}
    // other flags
}

class ChartTooltipCallbacks
{
    public bool ItemSorting {get;set;}
    // other flags
}

Then populate it and pass it through the InitializeChart.

@andrewwilkin
Copy link
Contributor Author

Making progress yet feels like one step forward and two back. Wired in everything for the tool tip item sorting function:

        if (chartCallbacks.chartCallbacksTooltips.hasItemSorting) {
            console.log('Wiring In Tooltip Item Sorting');
            chart.config.options.tooltips.mode = 'index';
            chart.config.options.tooltips.intersect = true;
            chart.config.options.tooltips.itemSort = function (a, b) {
                console.log('In JS function');
                console.log(a.datasetIndex);
                console.log(b.datasetIndex);
                return dotnetAdapter.invokeMethodAsync("TooltipItemSortHandler", JSON.stringify(a), JSON.stringify(b));
                //return dotnetAdapter.invokeMethodAsync("TooltipItemSortHandler", a, b);    
            }
        }

Can see the adapter method being called:

        [JSInvokable]
        public Task TooltipItemSortHandler ( string a, string b)
        {
            Console.WriteLine(a);
            Console.WriteLine(b);
            var tooltipItemA = JsonSerializer.Deserialize<TooltipItem>(a);
            var tooltipItemB = JsonSerializer.Deserialize<TooltipItem>(b);

            Console.WriteLine(tooltipItemA.DatasetIndex);
            Console.WriteLine(tooltipItemB.DatasetIndex);

            return chart.TooltipItemSortHandler(tooltipItemA, tooltipItemB);
        }

The problem is that deserializing is not working, possibly because the TooltipItem has a field called XLabel and YLabel which are deprecated in ChartJS which aren't in my model. Further complicated that XLabel and YLabel could be either a string or number.

Any suggestions as how best to deal with this. Newtonsoft would just ignore the field.

The model:

    public class TooltipItem
    {
        /// <summary>
        /// The label of the tooltip
        /// </summary>
        public string Label { get; set; }

        /// <summary>
        /// The value of the tooltip (likely to be a number depending on the chart type)
        /// </summary>
        public string Value { get; set; }

        /// <summary>
        /// Index of the dataset the item comes from
        /// </summary>
        public int DatasetIndex { get; set; }

        /// <summary>
        /// Index of this data item in the dataset
        /// </summary>
        public int Index { get; set; }

        /// <summary>
        /// X position of matching point (not sure this is an integer)
        /// </summary>
        public double X { get; set; }

        /// <summary>
        /// Y position of matching point (not sure this is an integer)
        /// </summary>
        public double Y { get; set; }
    }

Example of the serialised item received by the handler:

{"xLabel":"Purple","yLabel":17.20543779023245,"index":4,"datasetIndex":0,"x":447.94000015258785,"y":179.0249262530007}

@andrewwilkin
Copy link
Contributor Author

Okay cue face palm... need to pass in options to ignore case miss match:

        [JSInvokable]
        public Task<int> TooltipItemSortHandler ( string a, string b)
        {

            var options = new JsonSerializerOptions
            {
                PropertyNameCaseInsensitive = true
            };

            var tooltipItemA = JsonSerializer.Deserialize<TooltipItem>(a, options);
            var tooltipItemB = JsonSerializer.Deserialize<TooltipItem>(b, options);

            return chart.TooltipItemSortHandler(tooltipItemA, tooltipItemB);
        }

So now on to the next head scratcher, C# needs to return a value for this call back to be of any use and it's an integer in this case hence the change to Task in the signature.

The challenge then is how to wire that into the EventCallBack which returns a Task not a Task and cannot see how to wire that in:

[Parameter] public EventCallback<TooltipItemSortingEventArgs> TooltipItemSorting { get; set; }

Is there something I'm missing or do I need to change it from an EventCallback?

@andrewwilkin
Copy link
Contributor Author

And then I think equally big show stopper with this approach of passing in call backs:

dotnetAdapter.invokeMethodAsync("TooltipItemSortHandler", JSON.stringify(a), JSON.stringify(b))
                    .then(r => { return r });

Does not work in the Chart JS callback because it's returning a promise there is no way that the callback can return the result as the function is required to as per the comment here:
https://stackoverflow.com/questions/22951208/return-value-after-a-promise

The function which invokes an asynchronous function cannot wait till the async function returns a value. Because, it just invokes the async function and executes the rest of the code in it. So, when an async function returns a value, it will not be received by the same function which invoked it. So, the general idea is to write the code which depends on the return value of an async function, in the async function itself.

@stsrki
Copy link
Collaborator

stsrki commented Oct 20, 2019

Hi, sorry for not answering before, I was on a trip for a weekend.

Anyways back to your questions. I think you're on a right track.

For your first problem with XLabel and YLabel I think the best options is to have them as strings as that would cover most of the scenarios. This is if the serializer is going to work.

For the second problem. I cannot test it to be 100% sure, but I think you can use TooltipItemSortingEventArgs for Task instead of int. That way calling the EventCallback<TooltipItemSortingEventArgs> should not a problem.

@andrewwilkin
Copy link
Contributor Author

Hey the weekend is for coding (or the only time I get for it unfortunately);

The XLabel and YLabel were not the issue, just needed to pass in the serializer options to ignore the case.

Have uploaded the code to a branch in my repo so you can pull apart, hopefully you can see what I mean: https://github.com/andrewwilkin/Blazorize/tree/chartjs-callback-interop

All testing has been with the bar chart, the issue, could only get it returning a value when it was a Task but then the promises issue bit me.

@andrewwilkin
Copy link
Contributor Author

Here someone describes how they sorted out labels in VueJS:
https://stackoverflow.com/questions/54581264/load-data-into-chartjs-using-promises-in-vue-js

The challenge is that works only when dealing with chart wise config, the tool tip or formatting of scale cannot be simply a result. Will carry on looking but nothing as yet jumps out as a solution.

@stsrki
Copy link
Collaborator

stsrki commented Oct 21, 2019

I made some minor modifications and it seems to work. First I converted EventCallback to Func. The reason for this change is that I think it makes more sense. EventCallback will call the StateHasChanged while Func will just execute the code without any side-effect.

[Parameter] public Func<TooltipItemSortingEventArgs, int> TooltipItemSorting { get; set; }

From there the changes are pretty straightforward.

Handler:

public Task<int> TooltipItemSortHandler( TooltipItem a, TooltipItem b )
{
    Console.WriteLine( "TooltipItemSortHandler Called" );
    var args = new TooltipItemSortingEventArgs( a, b );

    var result = TooltipItemSorting?.Invoke( args ) ?? 0;

    return Task.FromResult( result );
}

Test Page:

<Chart @ref="barChart" Type="ChartType.Bar"
        Clicked="@(async (ChartMouseEventArgs) =>  ClickHandlerTest(ChartMouseEventArgs))"
-       TooltipItemSorting="@(async (TooltipItemSortingEventArgs) =>  TooltipItemSortingTest(TooltipItemSortingEventArgs))"
+        TooltipItemSorting="@((TooltipItemSortingEventArgs) =>  TooltipItemSortingTest(TooltipItemSortingEventArgs))"
        TItem="double" />

With the promise callback in javascript it seems the call-stack is in the right order. So I think you're on a good track.

@andrewwilkin
Copy link
Contributor Author

Thanks for looking at that, though the biggest issue as I see it is that it is not possible to use a promise with ChartJS options which renders this approach as unworkable for the majority of call backs.

Could work around it in client side Blazor by not using the async version. Unless I'm missing something.

@andrewwilkin
Copy link
Contributor Author

@stsrki
Have been looking at this more and the only way this can be done is to create a library of callback functions in JavaScript, and wire them in as a separate step in the Initialize and Update methods.

Given the mess that is with the options and datasets, am going to look to do this as a separate object and look at using numeral.js for providing a locale specific way of doing things.

The challenge is making it generic enough beyond my use cases, and because the namespace is Blazorise.Charts, cannot just bring that code locally as it conflicts with the other Blazorise libraries.

Before I go head long down this route, any advice how I can contribute back my learnings?

@stsrki
Copy link
Collaborator

stsrki commented Nov 3, 2019

Interesting approach. I guess there is going to be a lot of callback functions needed to implement. I'm still not sure if it's going to fix our problem with promises. Anyway I'm really interested in what you're going to do so please keep me posted.

Also, yeah I think the best route is to create a separate library with it's own namespace so that it can be reused by anyone.

@stsrki stsrki closed this as completed Apr 20, 2020
@mpmfrans
Copy link

Hi there! I started using Blazorise.Charts this week and I really appreciate the work you have done. A lot is possible and the way of setting up the charts is very clear. The only thing missing is customizing tooltips. Is there any progress on the tooltip callback so we can modify the tooltip and its return value? I would really appreciate any help because for me thats the final piece missing. Yours sincerely Thijs

@andrewwilkin
Copy link
Contributor Author

The challenge as I recall (which motivated me to switch to a commercial offering) was that there was no simple way of having a callback that ChartJS does not work with promises.

If you're prepared to go with synchronous callbacks you probably could get this working with interop:
mariusmuntean/ChartJs.Blazor#90

@stsrki
Copy link
Collaborator

stsrki commented Jun 27, 2021

I'm experimenting with serializing of custom JS function and early tests look promising. Nothing is for sure but if it keeps working I might add a function callback in the 0.9.5 version.

@mpmfrans
Copy link

@stsrki Thank you for the effort! If custom tooltips are possible I would be very happy! Love blazorise.

I recall:
Use JavaScript (interop) for the custom tooltip callback because Callbacks with return value are not supported on server-side blazor (as far as I understand you're using Server-side).

Any examples? I don't know how to do this and how to pass data to the Javascript?

@vince-sch
Copy link

Any news on this? I am trying to work around this issue right now just to get this functionality in graph tooltip customization.

@stsrki
Copy link
Collaborator

stsrki commented Aug 29, 2024

Any news on this? I am trying to work around this issue right now just to get this functionality in graph tooltip customization.

We have limited support for chart callbacks. Can you give us an example of what part of the chart you want to customize and how to use it?

@vince-sch
Copy link

In our case, we would like to customize the tooltips to provide more data than the data shown in the graph.

@stsrki
Copy link
Collaborator

stsrki commented Aug 29, 2024

In our case, we would like to customize the tooltips to provide more data than the data shown in the graph.

Can you provide us with a code sample? Preferably if there is something similar on https://www.chartjs.org/docs/latest/samples/tooltip/content.html

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Status: Help Wanted Extra attention is needed Type: Feature Request ⛱ New feature or request
Projects
None yet
Development

No branches or pull requests

4 participants