cetz-package / cetz-plot

Create Plots and Charts with CeTZ
GNU Lesser General Public License v3.0
7 stars 1 forks source link

Is there any way to add "DataLabels" like Highcharts. That is, to provide above/next to the bar/column the value of the y axis? #2

Open rousbound opened 5 months ago

rousbound commented 5 months ago

I mean something like this: highchart

johannes-wolf commented 5 months ago

This is currently not implemented, no.

rousbound commented 5 months ago

If it is a desirable feature, I can look into it and suggest an implementation.

If that's the case, could you @johannes-wolf or someone give me some pointers on how to start thinking about this?

I'm not familiar with CeTZ the source code yet.

johannes-wolf commented 5 months ago

If it is a desirable feature, I can look into it and suggest an implementation.

If that's the case, could you @johannes-wolf or someone give me some pointers on how to start thinking about this?

I'm not familiar with CeTZ the source code yet.

Sure. The barchart implementation is here: https://github.com/johannes-wolf/cetz/blob/master/src/lib/plot/bar.typ.

You can do the drawing in the _stroke function: https://github.com/johannes-wolf/cetz/blob/bf3ec2f6894ccd9255243e1fa40d2b56d6ddcd5b/src/lib/plot/bar.typ#L72

If those numbers are drawn (and where) should be styleable self.style gives you the style dictionary. Measuring the content and deciding where to draw it could get tricky, though.

rousbound commented 5 months ago

Well, actually I think that it makes more sense to draw it here while iterating through each bar, doesn't it?

For example:

#let _draw-rects(self, ctx, ..args) = {
  let x-axis = ctx.x
  let y-axis = ctx.y

  let w = self.bar-width
  for d in self.data {
    let (x, n, len, y-min, y-max) = d

    let x-offset = _get-x-offset(self.bar-position, self.bar-width)
    let left  = x - x-offset
    let right = left + w
    let width = (right - left) / len

    if self.mode in ("basic", "clustered") {
      left = left + width * n
      right = left + width
    }

    if (left <= x-axis.max and right >= x-axis.min and
        y-min <= y-axis.max and y-max >= y-axis.min) {
      left = calc.max(left, x-axis.min)
      right = calc.min(right, x-axis.max)
      y-min = calc.max(y-min, y-axis.min)
      y-max = calc.min(y-max, y-axis.max)
---------------------------------------------------------------------------------------------------------------
      draw.rect((left, y-min), (right, y-max))
        // new attribute data-label
        if self.data-label { 
           // Draw data-label here with an offset relative to the top of the bar
        }
---------------------------------------------------------------------------------------------------------------
    }
    }

I couldn't understand how to draw text on that context though.

I'm on the right track?

If that's the case, can you give me some insight into how to draw text in that "if self.data-label" line?

johannes-wolf commented 5 months ago

You are on the right track. You can just use the normal draw.* commands here. The ctx is not a canvas.ctx object, but a plot.ctx object, which contains the axes you are drawing on.

Note, that scaling is already set-up when this function is called.

rousbound commented 5 months ago

Now I got it. I missed the draw.content function.

I arrived on this:

 if self.data-label != none {
        let offset = self.data-label.at("offset")
        let size = self.data-label.at("text-size")
        draw.content((((left) + right)/2, y-max+offset), text(size:size)[#y-max])     
 }

Then I pass this to the columnchart parameters:

data-label:(offset: 0.4, text-size: 7pt)

The offset doesn't work in a fixed manner because it scales, so you need to adjust it.

It kinda works for me right now.

If you wish we can see how to exactly make this follow the API style.

Example:

test

johannes-wolf commented 5 months ago

Looks good. You should use relative coordinates to mix canvas units with absolute units: draw.content(rel: (0, offset), to: ((left + right) / 2, y-max))

You should also use add: anchor: "south" to use the south anchor as origin.

Also, the offset and content must come from the style dict. If we want to add this a functionality to add-bar, we have to consider horizontal bars (check the value of y-axis.horizontal, and if so, either rotate the text or use a different anchor.

rousbound commented 5 months ago

I tried to follow the draw.content function call that you suggested, but couldn't manage to make it work:

        let data_label = text(size:size)[#y-max]
        draw.content(rel: (0, offset), to: ((left + right) / 2, y-max), anchor:"south", data_label) 

It gives this error:

error: panicked with: "Expected 2 or 3 positional arguments, got 1"
    ┌─ src/draw/shapes.typ:758:9
    │
758 │     panic("Expected 2 or 3 positional arguments, got " + str(args.len()))

I checked the docs on code and pdf but couldn't manage to understand how to call it correctly. The docs don't mention these 'rel' and and 'to' parameters. Am I missing something?

johannes-wolf commented 5 months ago

You are missing parentheses arround your coordinate. rel: and to: are not arguments to content.

rousbound commented 5 months ago

Oh! Right. Now I got it.

What I have now is the following:

      draw.rect((left, y-min), (right, y-max))
      if self.style.data-label != none {
        let offset = self.data-label.at("offset")
        let size = self.data-label.at("text-size")
        let data_label = text(size:size)[#y-max]
        if y-axis.horizontal {
          draw.content((rel: (offset, 0), to: (right, (y-min + y-max) / 2)), anchor:"west", data_label)
        } else {
          draw.content((rel: (0, offset), to: ((left + right) / 2, y-max)), anchor:"south", data_label)          
        }
      }

It is working for columnchart but not for barchart. I tried to mess up with parameters but the data-labels didn't appeared at all in the barchart case. Don't know what I'm missing.

Also I couldn't understand how to propagate the 'data-label' from:

  set-style(
      data-label: data-label,
  )

To the _draw-rects(self, ctx) function above where I need to use the data-label from style dict.

Can you give more pointers how to continue?

rousbound commented 5 months ago

Nevermind! I got it. Found on the source code that you needed to use 'draw.group(ctx => {})'.

      draw.rect((left, y-min), (right, y-max))
      draw.group(ctx => {
        if ctx.style.data-label != none {
          let offset = ctx.style.data-label.at("offset")
          let size = ctx.style.data-label.at("text-size")
          let data_label = text(size:size)[#y-max]
          if y-axis.horizontal {
            draw.content((rel: (offset, 0), to: (right, (y-min + y-max) / 2)), anchor:"west", data_label)
          } else {
            draw.content((rel: (0, offset), to: ((left + right) / 2, y-max)), anchor:"south", data_label)

          }
        }     
      })

I just need to know why the data-labels aren't appearing on the barchart.

rousbound commented 5 months ago

Done!

bar.typ:

      draw.group(ctx => {
        if ctx.style.data-label != none {
          let offset = ctx.style.data-label.at("offset")
          let size = ctx.style.data-label.at("text-size")
          let data_label = text(size:size)[#y-max]
          let anchor = if y-axis.horizontal {"west"} else {"south"}
          draw.content((rel: (0, offset), to: ((left + right) / 2, y-max)), anchor:"south", data_label)
        }
      })

I needed to make some changes to columchart.typ and barchart.typ to include these: columnchart.typ

#let columnchart-default-style = (
  axes: (tick: (length: 0), grid: (stroke: (dash: "dotted"))),
  bar-width: .9,
  x-inset: 0.6,
  data-label: (offset: 0.20, text-size: 8pt)
)

barchart.typ

#let barchart-default-style = (
  axes: (tick: (length: 0), grid: (stroke: (dash: "dotted"))),
  bar-width: .9,
  y-inset: 1,
  data-label: (offset: 0.10, text-size: 8pt)
)

The only problem that persists that I've noticed is that the style values aren't overriding the defaults, that applies not only for data-label, but for other custom values like "bar-width" and "x-inset".

I mean:

 canvas({
  draw.set-style(
  x-inset: 0,
  legend: (fill: white),
  padding: 1.5pt,
  data-label: data-label,
  )
  chart.columnchart(
    data,
    label-key: 0,
    value-key: (..range(1, labels.len() + 1)),
    mode: "clustered",
    size: size,
    y-label: smallcaps[Escala de Likert],
    x-label: smallcaps[Perguntas],
    y-tick-step: 0.5,
    y-min: 1,
    y-max: 5,
    labels: labels,
    legend: "legend.north-east",
    bar-style: p
  )
  } )
}

set-style isn't working for 'x-inset', 'barwidth' and 'data-label'. I suspect the merge of the set-style dict and the bar defaults isn't working properly. Maybe it is a bug?

Current state of the charts:

graf1 graf2

johannes-wolf commented 4 months ago

Nice! You must not use ctx.style directly but styles.resolve(ctx.style, ...) (search for uses or look up the documentation). I would also suggest adding a callback to add-bar that allows returning the content to show as label: this allows for custom formatting.

Do you want to open up a PR with your changes? I can also add some additions myself. If we add this feature I would like to have it more configurable: overriding the content anchor, specifying the side of the bar that is used as anchor etc.

rousbound commented 4 months ago

Ok! I've created the PR, take a look!

I looked into using the styles.resolve but couldn't see exactly how to use it. I would need to check which default-style to use inside the draw-rects.

Also the 'add-bar' callback you mentioned I didn't understand how to implement it exactly.

If you need anything just comment on the PR and I can try to help.

wlievens commented 1 week ago

Is this planned to be merged into cetz-plot soon? I don't see a PR for it in this project.

johannes-wolf commented 5 days ago

The cetz-plot repo moved, therefore this PR is in the wrong repo.

johannes-wolf commented 2 days ago

Original PR is here: https://github.com/cetz-package/cetz/pull/516

johannes-wolf commented 2 days ago

It is not easy right now to implement this in a good way, because of how plot scaling works, see #4.