symfony / ux

Symfony UX initiative: a JavaScript ecosystem for Symfony
https://ux.symfony.com/
MIT License
851 stars 313 forks source link

[Chart.js] Updating chart when data changes (+ using with live components) #695

Closed robert-hoffmann closed 1 year ago

robert-hoffmann commented 1 year ago

This is probably not a bug.

I'm just running into a problem where i'm trying to use chart.js in the context of a live component (without a stimulus controller)

Basically i'd like to have input fields around the chart that control the data that is rendered in the chart, so on modifications of a live prop, the chart should rerender based on the new input.

The first problem i have is that any rerender of the live component makes the chart invisible, and i have to resize the window so that it rerenders correctly.

Second problem, is i still haven't figured out how to actually change the data of the chart, and i don't really want to use a stimulus controller, which IMO defeats the purpose of a live component.

Any pointers for me ? Thanks !

weaverryan commented 1 year ago

Hi there!

This is a really interesting problem. I think the problem is not in LiveCompontents, but with our Chart.js controller itself. The behavior we want is for the Chart.js instance to update (with animations!) whenever the "data" we're feeding into it changes - i.e. whenever the Stimulus "attributes" on the canvas element change. Currently, we read this view value (which contains all of the chart data) and render Chart.js - https://github.com/symfony/ux/blob/2.x/src/Chartjs/assets/src/controller.ts#L27

But, if that attribute ever changed... nothing happens. So, you're not doing anything wrong, we just need to make the Stimulus chart.js controller smarter! The idea would be to listen to the view value to change - https://stimulus.hotwired.dev/reference/values#change-callbacks - then use that to modify the chart instance - https://www.chartjs.org/docs/latest/developers/updates.html. After doing this, you'd even be able to "mess" with the HTML attribute data on your canvas element and watch your chart update - pretty cool.

I think (famous last words) that this should be pretty easy! It's possible something else might need to be done in live components itself as well, but I'm quite sure we need this first part to start.

robert-hoffmann commented 1 year ago

Thanks for the response

Any way i might be able to hook into the chart element and trigger a chart.update() myself via the live component ?

weaverryan commented 1 year ago

You can definitely listen to the render:finished hook from a custom Stimulus controller. I'd actually be interested how well this works. I think (?) that, when the Live Component updates, it will "update" the existing canvas element to have the new attributes, but I'm not entirely sure about that - canvas elements are special element and I haven't used them yet. You mentioned:

The first problem i have is that any rerender of the live component makes the chart invisible, and i have to resize the window so that it rerenders correctly.

If by "rerenders correctly" you mean that it DOES show the updated chart, then that actually makes me think that the canvas element might replaced with the new one... then the new one is completely creating a new Chart. If that's the case, what I mentioned earlier about the "smart chart updating" isn't needed (it would be nice because the updates would animate, but not strictly required). So then, why doesn't the chart show up until you resize? You could try checking here - https://stackoverflow.com/questions/48343189/data-is-shown-on-chartjs-vue-only-after-resizing-browser-or-opening-developer-to#answers - it might be as simple as hooking into render:finished and calling chart.update(). But I'm doing a lot of guessing.

robert-hoffmann commented 1 year ago

Not really sure how to hook into that because this is double imbricked

StatsController.php (routes)

return $this->render('stats/stats.html.twig');

stats/stats.html.twig

{{ component('stats') }}

components/stats.html.twig

{{ render_chart(this.chart) }}

...no clue how to hook into the render_chart() object via a controller (well i could just use that chart straight up via a stimulus-controller, but like i said i wanted to try and use it via a live-component, to keep js to a minimum, and not have to write a bunch of webservices)

robert-hoffmann commented 1 year ago

Is there a way to get the instance of the symfony--ux-chartjs--chart controller or the chart object via some kind of document.queryselector manipulation ?

robert-hoffmann commented 1 year ago

I found this, but there is no chart instance on the page/window

https://stackoverflow.com/questions/36608208/how-to-retrieve-chartjs-instance-after-initialization

weaverryan commented 1 year ago

You'll need to "extend" chartjs with a Stimulus controller - like shown here - https://symfony.com/bundles/ux-chartjs/current/index.html#extend-the-default-behavior - that'll give you access to the Chart instance.

Then, inside there, you'll use the getComponent() method - https://symfony.com/bundles/ux-live-component/current/index.html#working-with-the-component-in-javascript - to hook into the live component. The tricky this is that you won't be able to use this.element like shown in that example, because this is your chartjs controller, so this.element will be the canvas element, not the live controller element. A simple thing would be to add an id="" attribute to your live component, then find it via:

const component = await getComponent(document.getElementById();

This is the kind of thing that should "just work". But it doesn't (yet) for some reason, hence needing some extra JS to work around the issue.

robert-hoffmann commented 1 year ago

Pretty cool, i like how to get access to the components like this

Unfortunately nothing is updating

I have this in the controller (seems to be working)

export default class extends Controller {
    #chart;

    connect() {
        this.element.addEventListener('chartjs:connect', (event) => {
            this.#chart = event.detail.chart;
        });
    }

    async initialize() {
        this.component = await getComponent(document.querySelector('div[data-controller="live"]'));

        this.component.on('render:finished', () => {
            this.#chart.update();
        });
    }
}

Then in the component i have something like this

    #[LiveAction]
    public function update()
    {
        $this->chart->setData([
            'labels'   => ['January', 'February', 'March', 'April', 'May', 'June', 'July'],
            'datasets' => [
                [
                    'label'           => $this->sampleText,
                    'backgroundColor' => 'rgb(255, 99, 132, .4)',
                    'borderColor'     => 'rgb(255, 99, 132)',
                    'data'            => [2, 10, 5, 18, 20, 30, 45],
                    'tension'         => 0.4,
                ],
                [
                    'label'           => 'Km walked 🏃‍♀️',
                    'backgroundColor' => 'rgba(45, 220, 126, .4)',
                    'borderColor'     => 'rgba(45, 220, 126)',
                    'data'            => [10, 15, 4, 3, 25, 41, 25],
                    'tension'         => 0.4,
                ],
            ],
        ]);
    }

I tied this to a live action, to be sure that sampletext (comes from a live input) is up to date, and i trigger the changes manually to be sure there arent any race conditions

but it's always just the same chart

weaverryan commented 1 year ago

Ok, then it goes back to my original guess :). LiveComponents (correctly) doesn't replace the canvas element, it just updates the attributes (which hold the chart data). You can verify this by inspecting element on the canvas after live components loads and look carefully to see if the data has been changed.

The problem is back to this:

I think the problem is not in LiveCompontents, but with our Chart.js controller itself. The behavior we want is for the Chart.js instance to update (with animations!) whenever the "data" we're feeding into it changes - i.e. whenever the Stimulus "attributes" on the canvas element change. Currently, we read this view value (which contains all of the chart data) and render Chart.js - https://github.com/symfony/ux/blob/2.x/src/Chartjs/assets/src/controller.ts#L27 But, if that attribute ever changed... nothing happens

So we need to make the chartjs controller smarter. To work around it, in your controller, you could read the data-symfony--ux-chartjs--chart-view-value directly and try to use the data to update the chart:

        this.component = await getComponent(document.querySelector('div[data-controller="live"]'));

        this.component.on('render:finished', () => {
            const newChartData = JSON.parse(this.element.dataset['symfony-UxChartjs-ChartViewValue']);
            // this will have keys like "type", "data" and "options". Example usage:
           this.#chart.data = newChartData.data;
    });
            this.#chart.update();
        });

Let me know if this helps :) - code above may not be perfect!

robert-hoffmann commented 1 year ago

Nice ;-)

This is working as far as updating data goes

stats_controller.js

export default class extends Controller {
    #chart;

    // https://symfony.com/bundles/ux-chartjs/current/index.html
    connect() {
        this.element.addEventListener('chartjs:connect', (event) => {
            this.#chart = event.detail.chart;
        });
    }

    async initialize() {
        this.component = await getComponent(document.querySelector('div[data-controller="live"]'));

        this.component.on('render:finished', (component) => {
            // do something after the component re-renders
            const newChartData = JSON.parse(this.element.dataset['symfony-UxChartjs-ChartViewValue']);

            // this will have keys like "type", "data" and "options". Example usage:
            this.#chart.data = newChartData.data;
            this.#chart.update('active');
        });
    }
}

templates/components/stats.html.twig

<div {{ attributes }}>
    <h2>{{this.sampleText}}</h2>
    <div style="height: 300px;">
        {{ render_chart(this.chart, {'data-controller': 'stats'}) }}
    </div>

    <input
        type="text"
        class="form-control"
        data-model="debounce(100)|sampleText"
    />
</div>

components/StatsComponent.php

private function update()
{
    $this->chart->setData([
        'labels'   => ['January', 'February', 'March', 'April', 'May', 'June', 'July'],
        'datasets' => [
            [
                'label'           => $this->sampleText,
                'backgroundColor' => 'rgb(255, 99, 132, .4)',
                'borderColor'     => 'rgb(255, 99, 132)',
                'data'            => [2, 10, 5, 18, 20, 30, 45],
                'tension'         => 0.4,
            ],
            [
                'label'           => 'Km walked 🏃‍♀️',
                'backgroundColor' => 'rgba(45, 220, 126, .4)',
                'borderColor'     => 'rgba(45, 220, 126)',
                'data'            => [10, 15, 4, 3, 25, 41, 25],
                'tension'         => 0.4,
            ],
        ],
    ]);
}
//#endregion

//#region Properties
#[LiveProp(writable: true)]
public string $sampleText = 'Chart';
//#endregion

//#region Actions
//#endregion

//#region Getters
public function getSampleText()
{
    $this->update();
    return $this->sampleText;
}

Last problem is the canvas blanking out, and me having to resize the window manually to get the chart to rerender correctly after updating

initial render image

after automatic update image

after manual window resize image

robert-hoffmann commented 1 year ago

was just missing this

this.#chart.resize();

So this is the final code, which can probably be made generic to handle being used inside a Live Component

<div {{ attributes }}>
    <div style="height: 300px;">
        {{ render_chart(this.chart, {'data-controller': 'stats'}) }}
    </div>
</div>

stats_controller.js

export default class extends Controller {
    #chart;

    connect() {
        this.element.addEventListener('chartjs:connect', (event) => {
            this.#chart = event.detail.chart;
        });
    }

    async initialize() {
        this.component = await getComponent(document.querySelector('div[data-controller="live"]'));

        this.component.on('render:finished', (component) => {
            const newChartData = JSON.parse(this.element.dataset['symfony-UxChartjs-ChartViewValue']);

            this.#chart.data = newChartData.data;
            this.#chart.update('active');
            this.#chart.resize();
        });
    }
}
weaverryan commented 1 year ago

Ah, thank you for sharing this! We will likely need that .resize() also after we make our Chartjs controllers smart enough to update themselves :)

robert-hoffmann commented 1 year ago

My pleasure, I'm finding live components interesting. Hope to see some cool UX updates in the future 👍

Only thing missing is making this more robust, to allow for multiple components document.querySelector('div[data-controller="live"]')

weaverryan commented 1 year ago

Btw, in the next version - 2.8.0 - the chart will automatically re-render itself when the view value (i.e. that attribute on the <canvas> changes. It works out-of-the-box with live components - in large part thanks to this conversation :)

robert-hoffmann commented 1 year ago

Cool, looking forward to it 😊

PS. have a look at this one https://github.com/symfony/ux/issues/704