Skip to main content
Announcements
Qlik Connect 2024! Seize endless possibilities! LEARN MORE
Francis_Kabinoff
Former Employee
Former Employee

If you want to add custom interactions that depend on what shapes the user is interacting with in picasso.js, you're going to want to use the shapesAt() function for two reasons.  First, while you can try to use the shape that is the target of the event when rendering in SVG with picasso.js, that won't work if you're rendering with Canvas in picasso.js since the event target will just be the Canvas element.  And second, shapes in some charts, such as a scatter plot, may overlap, so simply using the shape that is the target of the event is not enough if you want the interaction to include all of the shapes the user is interacting with. With the shapesAt() function, whether rendering in SVG or Canvas, you can get all of the shapes at the point of the user interaction.

Let's take a look at one use case and how to use it. Imagine that we have a scatter plot and when the user clicks, we want to select all items at the point of the cursor. We want the selection to apply to Qlik Sense, but we want the chart to having a brushing effect, so we'll use set analysis so that all of the rows return regardless of the selection state in Qlik Sense, but we'll change the opacity based on the selection.

The setup for the hypercube, which we'll create with nebula.js, looks like this.

 

nebula.render({
  element: document.querySelector('#container'),
  type: 'scatter-plot',
  fields: [
    'Product Sub Group Desc',
    '=Sum({<[Product Sub Group Desc]=>} [Sales Margin Amount])/Sum({<[Product Sub Group Desc]=>} [Sales Amount])',
    '=Sum({<[Product Sub Group Desc]=, [Year]={"2020"}>} [Sales Amount])',
  ],
});

 

Notice the set analysis in the measures that ignores the selections in the dimension of the chart, so that when we apply selections in Qlik Sense to the dimension of the chart, every row in the hypercube still returns.

Next let's take a look at the entire picasso.js definition.

 

scatterPlotSn = {
  const picasso = picassojs();
  picasso.use(picassoQ);
  return {
    qae: {
      properties: {
        qHyperCubeDef: {
          qDimensions: [],
          qMeasures: [],
          qInitialDataFetch: [{ qWidth: 3, qHeight: 100 }],
          qSuppressZero: true,
          qSuppressMissing: true,
        },
        showTitles: true,
        title: '',
        subtitle: '',
        footnote: '',
      },
      data: {
        targets: [
          {
            path: '/qHyperCubeDef',
            dimensions: {
              min: 1,
              max: 1,
            },
            measures: {
              min: 2,
              max: 2,
            },
          },
        ],
      },
    },
    component() {
      const element = stardust.useElement();
      const layout = stardust.useStaleLayout();
      const rect = stardust.useRect();
      const model = stardust.useModel();

      const [instance, setInstance] = stardust.useState();

      stardust.useEffect(() => {
        const p = picasso().chart({
          element,
          data: [],
          settings: {},
        });

        setInstance(p);

        return () => {
          p.destroy();
        };
      }, []);

      stardust.useEffect(() => {
        if (!instance) {
          return;
        }

        const selected = layout.qHyperCube.qDataPages[0].qMatrix.filter((row) => row[0].qState === 'S').map((row) => row[0].qElemNumber);

        instance.update({
          data: [
            {
              type: 'q',
              key: 'qHyperCube',
              data: layout.qHyperCube,
            },
          ],
          settings: {
            scales: {
              x: { 
                data: {
                  extract: {
                    field: 'qMeasureInfo/0',
                  },
                },
              },
              y: { 
                data: {
                  extract: {
                    field: 'qMeasureInfo/1',
                  },
                },
                invert: true,
              }
            },
            components: [
              {
                type: 'point',
                data: {
                  extract: {
                    field: 'qDimensionInfo/0',
                    props: {
                      x: { field: 'qMeasureInfo/0' },
                      y: { field: 'qMeasureInfo/1' },
                    },
                  },
                },
                settings: {
                  x: { scale: 'x' },
                  y: { scale: 'y' },
                  shape: 'circle',
                  size: 1,
                  opacity: (d) => selected.length === 0 || selected.includes(d.datum.value) ? 1 : 0.25,
                },
              },
            ],
            interactions: [
              {
                type: 'native',
                events: {
                  click(e) {
                    const chartBounds = element.getBoundingClientRect();
                    const cx = e.clientX - chartBounds.left;
                    const cy = e.clientY - chartBounds.top;
                    const shapes = this.chart.shapesAt({ x: cx, y: cy });
                    const values = shapes.map((shape) => shape.data.value);
                    model.selectHyperCubeValues('/qHyperCubeDef', 0, values, true);
                  },
                },
              },
            ],
          },
        });
      }, [layout, instance]);

      stardust.useEffect(() => {
        if (!instance) {
          return;
        }
        instance.update();
      }, [rect.width, rect.height, instance]);
    },
  };
}

 

 

There's a lot of code there, but if you're familiar with building custom picasso.js charts, much of it will be very familiar. If you've never built a custom picasso.js chart, you may want to check out https://community.qlik.com/t5/Qlik-Design-Blog/Picasso-js-What-separates-it-from-other-visualization...

So let's take a closer look at the part that is the topic of the blog post, the shapesAt() function. The shapesAt() function in this example is used in the 'click' interaction.

 

click(e) {
  const chartBounds = element.getBoundingClientRect();
  const cx = e.clientX - chartBounds.left;
  const cy = e.clientY - chartBounds.top;
  const shapes = this.chart.shapesAt({ x: cx, y: cy });
  const values = shapes.map((shape) => shape.data.value);
  model.selectHyperCubeValues('/qHyperCubeDef', 0, values, true);
}

 

The shapesAt() function is called from the chart instance, and in this case, we're simply checking for the shapes at the exact x and y. There are more advanced ways to use it, including detecting only certain shapes, or using an area instead of a point to detect shapes, and you can check out the docs for more information at https://qlik.dev/apis/javascript/picassojs#%23%2Fdefinitions%2FChart%2Fentries%2FshapesAt

The big thing I want to point out here so you can avoid the struggle I had first using the shapesAt() function is how the x and y coordinates are calculated. You must adjust for where the chart is located in the viewport. So the clientX and clientY values from the event give you the x and y coordinates of where the click happened in the viewport, then you can adjust for the area outside of the chart using the left and top from element.getBoundingClientRect().

You can check out all of this code at https://observablehq.com/@fkabinoff/picasso-js-shapesat.  Let me know if this post inspires you, and what else you'd like to know about picasso.js!

2 Comments