layercake icon indicating copy to clipboard operation
layercake copied to clipboard

New component: Color scale legend

Open janosh opened this issue 1 year ago • 35 comments

Here's a rough 1st draft for closing #120. Feel free to suggest or apply yourself substantial changes.

janosh avatar Mar 17 '23 18:03 janosh

Here are some demos:

https://elementari.janosh.dev/color-bar

janosh avatar Mar 17 '23 18:03 janosh

Thanks for putting this together! I'll go through it. How would you feel about converting it from TypeScript to using JsDoc comments like the other components? I'd also be open to having two versions available – the website would need to be updated to support that. Or, the lowest overhead way would be to keep it in the repo and link to the second version.

mhkeller avatar Mar 17 '23 18:03 mhkeller

Oops, didn't see the code base uses JsDoc. No problem, I'll convert.

janosh avatar Mar 17 '23 18:03 janosh

@mhkeller Let me know if this needs further work.

janosh avatar Mar 23 '23 02:03 janosh

Thanks a bunch for your work on this. I’ll go through it in more detail. One thing would be to not load the d3 scale chromatic library separately. Instead it should be more like the key component https://layercake.graphics/components/Key.html.svelte where it uses the z scale.

I’d probably get rid of the wrapperStyle prop but open to hear why it should stay. My thinking is that the user could either manually add styles to this component’s css or easily add this prop based on their own reusability/customization needs. It may be that they would prefer to do that via a class or just one css rule like a float. In short, there’s a lot of ways the user could handle custom styling and it may be better to let them implement that.

There’s a TODO on the vertical layout. Is that finished?

mhkeller avatar Mar 23 '23 04:03 mhkeller

The style and textStyle props may fall in that same bucket too

mhkeller avatar Mar 23 '23 04:03 mhkeller

I did a first pass of making this more layercake-y – mostly where the scale is set via the context and it uses the scale to determine ticks. I copied the logic from the x-axis component and added the snapTicks option. I see there are a few CSS variables. Where should these be defined or are they not needed for the example?

I set up a page in the component gallery: Screen Shot 2023-03-25 at 1 59 36 PM

It would be nice to add a few more styling and configuration options. Maybe with some checkbox buttons to make it interactive. Let me know what you think or if I got rid of something important. Do you think the vertical option is worth adding or did you find you never had a use for it?

mhkeller avatar Mar 25 '23 18:03 mhkeller

Looks great! Sorry for not responding earlier.

I just looked at your changes. All looks good. Nothing important was removed. snapTicks is a very nice feature. Was planning to add that but hadn't yet even though I think it's high priority.

I do have several use cases for vertical color bars and don't think it takes that much more work to implement it but I'm on a deadline atm. So maybe leave it for another day.

I added a bit of interactivity to the demo page you made. Nice work on that! The form controls I added show how to use the CSS variables you asked about.

https://user-images.githubusercontent.com/30958850/227737903-95c8cb32-53c5-40c8-9102-a2035bf4a785.mp4

janosh avatar Mar 25 '23 19:03 janosh

Very cool! Whenever you have the time to work on the vertical bar that would be great. I'd take a crack at it but I feel like you have your CSS system set up a certain way. I'd like to add more interactivity for the other components too so this is a good start. I'd maybe add some checkboxes showing the name or something but we can figure all that out later. Here are the remaining TODOs

  • [ ] Allow for vertical mode
  • [x] Finalize which props get sliders / checkboxes
  • [ ] Pick which other scales to show examples of on the demo page

mhkeller avatar Mar 25 '23 21:03 mhkeller

I added a tick mark option as well as a tickFormat function to match the axis functions. I also did some different styling on the label position. If you set the ticks to top and the label to top, they'll crash. I think if they are set as siblings in a flex box it would be better.

I'm still not sure what the best way is to dynamically convert this to a vertical layout. With the ticks and labels, it may be easier if that is a separate component. If you see a simple way to make it all work together, though, let me know.

mhkeller avatar Apr 16 '23 05:04 mhkeller

I need to make the sliders nicer / better organized on the demo page.

mhkeller avatar Apr 16 '23 05:04 mhkeller

@janosh I reconfigured it quite a bit with a few different flex boxes so that the user could pick any combination of tick side top or bottom and label side and not have the text elements crash into one another. I'm sure it could be improved though, so let me know what you think.

The added complexity makes me think that adding a vertical layout option for this will be a bit too much. Perhaps that's just an easy separate component.

mhkeller avatar Apr 20 '23 03:04 mhkeller

I think your changes look great!

2 small things I noticed:

In the component gallery, the color bar overflows its tile.

Screenshot 2023-04-20 at 08 02 40

I think when choosing labelSide=left|right, it would look better to have the label flush with the color bar, i.e. not taking the tick height into account when vertically centering.

Screenshot 2023-04-20 at 08 03 11

janosh avatar Apr 20 '23 15:04 janosh

Thanks for taking a look! Totally on the gallery view – I have just been looking at it on the full page. I need to design all of the sliders and things so they fit properly. I really like your technique of assigning css variables to components as extra props – I had never thought of that and it's very cool so I'd like to definitely include the width as an example of that.

On the label, I thought I had fixed that with this one: https://github.com/mhkeller/layercake/pull/121/commits/8e34d8b85551484e2c6cccc8a1b3714ca3d2f906 I'll take another look as to why that's not working

mhkeller avatar Apr 20 '23 15:04 mhkeller

By the way, I increased the ramp value to 100. I noticed that it changed the gradation rather significantly. Looking at the diverging scales, a value of 100 put the middle-neutral color value at the center of the color bar.

mhkeller avatar Apr 20 '23 15:04 mhkeller

Looking at the diverging scales, a value of 100 put the middle-neutral color value at the center of the color bar.

That's good to know!

I really like your technique of assigning css variables to components as extra props – I had never thought of that and it's very cool so I'd like to definitely include the width as an example of that.

I'm a big fan of that Svelte feature too! The only thing I don't like about it is that it relies on wrapping the component in an extra div with display: contents.

janosh avatar Apr 20 '23 15:04 janosh

Ah good to know on `display: contents;' very neat.

Take a look at this now. It still lines up on mine. What browser and OS are you using?

Screen Shot 2023-04-25 at 11 25 36 PM

mhkeller avatar Apr 26 '23 03:04 mhkeller

Brave on macOS here. So Chromium based.

janosh avatar Apr 26 '23 03:04 janosh

I think I fixed it. I'm sure the CSS logic could be improved...

mhkeller avatar Apr 26 '23 03:04 mhkeller

Everything looks spot on now.

In the demos, I think it would be nice to be able to change min and max of the bar range to better see the effect snap ticks has.

Screenshot 2023-04-26 at 07 36 05 Screenshot 2023-04-26 at 07 33 32

janosh avatar Apr 26 '23 14:04 janosh

What kind of control widget would you think would be best for that? Maybe the simplest would be like "Generate random range" ? You mean you want something where instead of 0, there's a number with more digits so the centering is more obvious?

Also, if you have any thoughts on how to shrink these controls down and make them look more like a control box that would be great. Also if you see any improvements to the CSS structure let me know too. I changed it from points to pixels just to be consistent with the other examples. But I could see parameterizing the distance the tick labels are from the bar, too. Thanks for your help with this!

mhkeller avatar Apr 26 '23 15:04 mhkeller

You mean you want something where instead of 0, there's a number with more digits so the centering is more obvious?

Exactly! I think two <input type='number'> for min and max would do. Even more fancy would be a double slider like https://github.com/simeydotme/svelte-range-slider-pips but not sure you want to add extra deps.

Also, if you have any thoughts on how to shrink these controls down and make them look more like a control box that would be great.

I think the controls look great already. No changes needed IMO.

I'll take a look at the CSS and let you know.

janosh avatar Apr 26 '23 15:04 janosh

Maybe the lightest touch solution would be to start at -100 and go to 100. It's currently a big packed and busy so if we can avoid another slider that would be helpful for the layout. I actually made a pretty lightweight double range slider if ever in need: https://github.com/mhkeller/svelte-double-range-slider/

mhkeller avatar Apr 26 '23 15:04 mhkeller

Hadn't noticed https://github.com/mhkeller/svelte-double-range-slider yet. Nice work!

Just had another look at ColorBar and format on save applied a bunch of changes. But nothing jumps out as easily simplified in the CSS.

janosh avatar Apr 26 '23 21:04 janosh

hey @janosh wanted to see if you were still interested in this one

mhkeller avatar May 24 '23 20:05 mhkeller

Absolutely. Sorry about the radio silence. Great catch re diverging color scales.

If we're just interested in fixing that issue, we could use this?

function ramp(scale, steps = 100) {
  const domain = scale.domain()
  const step = (domain.at(-1) - domain[0]) / (steps - 1)
  return [...Array(steps).keys()].map((idx) => scale(domain[0] + idx * step))
}

janosh avatar May 24 '23 21:05 janosh

Hm yea I think that could work. I'll look at what this observable notebook does, too. I think just using whatever the canonical d3 example uses would be best in case there are other use cases or edge cases.

mhkeller avatar May 27 '23 20:05 mhkeller

@mhkeller I looked into that as well. It's possible to just wrap the code Legend code in the notebook into a Svelte component.

Legend.svelte
<script lang="ts">
  import * as d3 from 'd3'
  import { onMount } from 'svelte'
  import { pretty_num } from '../lib/labels'

  export let color: d3.ScaleSequential<string> | d3.ScaleSequential<number>
  export let title = ''
  export let tickSize = 6
  export let width = 400
  export let height = 50 + tickSize
  export let marginTop = 20
  export let marginRight = 0
  export let marginBottom = 20 + tickSize
  export let marginLeft = 0
  export let ticks = width / 100
  export let tick_vals: number[] | null = null
  export let node: SVGElement | null = null

  function ramp(color, samples: Number = 100) {
    const canvas = document.createElement('canvas')
    canvas.width = samples
    canvas.height = 1
    const context = canvas.getContext('2d')
    for (let idx = 0; idx < samples; ++idx) {
      context.fillStyle = color(idx / (samples - 1))
      context.fillRect(idx, 0, 1, 1)
    }
    return canvas
  }

  onMount(async () => {
    const svg = d3
      .select(node)
      .attr('width', width)
      .attr('height', height)
      .attr('viewBox', [0, 0, width, height])
      .style('overflow', 'visible')
      .style('display', 'block')

    let tickAdjust = (g) =>
      g.selectAll('.tick line').attr('y1', marginTop + marginBottom - height)
    let x

    // Continuous
    if (color.interpolate) {
      const n = Math.min(color.domain().length, color.range().length)

      x = color
        .copy()
        .rangeRound(d3.quantize(d3.interpolate(marginLeft, width - marginRight), n))

      svg
        .append('image')
        .attr('x', marginLeft)
        .attr('y', marginTop)
        .attr('width', width - marginLeft - marginRight)
        .attr('height', height - marginTop - marginBottom)
        .attr('preserveAspectRatio', 'none')
        .attr(
          'xlink:href',
          ramp(color.copy().domain(d3.quantize(d3.interpolate(0, 1), n))).toDataURL()
        )
    }

    // Sequential
    else if (color.interpolator) {
      x = Object.assign(
        color.copy().interpolator(d3.interpolateRound(marginLeft, width - marginRight)),
        {
          range() {
            return [marginLeft, width - marginRight]
          },
        }
      )

      svg
        .append('image')
        .attr('x', marginLeft)
        .attr('y', marginTop)
        .attr('width', width - marginLeft - marginRight)
        .attr('height', height - marginTop - marginBottom)
        .attr('preserveAspectRatio', 'none')
        .attr('xlink:href', ramp(color.interpolator()).toDataURL())
    }

    // Threshold
    else if (color.invertExtent) {
      const thresholds = color.thresholds
        ? color.thresholds() // scaleQuantize
        : color.quantiles
        ? color.quantiles() // scaleQuantile
        : color.domain() // scaleThreshold

      x = d3
        .scaleLinear()
        .domain([-1, color.range().length - 1])
        .rangeRound([marginLeft, width - marginRight])

      svg
        .append('g')
        .selectAll('rect')
        .data(color.range())
        .join('rect')
        .attr('x', (d, i) => x(i - 1))
        .attr('y', marginTop)
        .attr('width', (d, i) => x(i) - x(i - 1))
        .attr('height', height - marginTop - marginBottom)
        .attr('fill', (d) => d)

      tick_vals = d3.range(thresholds.length)
    }

    // Ordinal
    else {
      x = d3
        .scaleBand()
        .domain(color.domain())
        .rangeRound([marginLeft, width - marginRight])

      svg
        .append('g')
        .selectAll('rect')
        .data(color.domain())
        .join('rect')
        .attr('x', x)
        .attr('y', marginTop)
        .attr('width', Math.max(0, x.bandwidth() - 1))
        .attr('height', height - marginTop - marginBottom)
        .attr('fill', color)

      tickAdjust = () => {}
    }

    svg
      .append('g')
      .attr('transform', `translate(0,${height - marginBottom})`)
      .call(
        d3
          .axisBottom(x)
          .ticks(ticks, pretty_num)
          .tickFormat(pretty_num)
          .tickSize(tickSize)
          .tickValues(tick_vals)
      )
      .call(tickAdjust)
      .call((g) => g.select('.domain').remove())
      .call((g) =>
        g
          .append('text')
          .attr('x', marginLeft)
          .attr('y', marginTop + marginBottom - height - 6)
          .attr('fill', 'currentColor')
          .attr('text-anchor', 'start')
          .attr('font-weight', 'bold')
          .text(title)
      )
  })
</script>

<svg bind:this={node} class="legend" />

where pretty_num is

import { format } from 'd3-format'

export const pretty_num = (num: number, precision?: string) => {
  if (num === null) return ``
  if (!precision) {
    const [gt_1_fmt, lt_1_fmt] = default_precision
    return format(Math.abs(num) >= 1 ? gt_1_fmt : lt_1_fmt)(num)
  }
  return format(precision)(num)
}

But it's not the best UX. If you load 20 or so on the same page, they're empty at first and then flash into existence, causing a lot of CLS.

janosh avatar May 27 '23 20:05 janosh

For sure. I don't think we need to go the full onMount approach. I think it's most of the way there, it just needs to grab the gradient creation from that example.

mhkeller avatar Jun 27 '23 15:06 mhkeller

Sorry I'm jumping in late on the discussion. Not directly related to this PR, but more general color scale / color bar notes (and can create a separate issue if we want to continue the discussion)

@mhkeller What do you think if we add a cScale or colorScale to LayerCake? I know any of the scales (x,y,z,r) can be used for color, but I think it could be helpful for reusable components to have a common scale used for color. I currently use the r scale in LayerChart (coloR) but there are cases where r overlaps with radius scales, for example. z can also overlap with faceted charts, or another dimension (stack/group/etc). I haven't looked deeply, but I know Plot has an explicit color scale (just called color).

Also, LayerChart has Legend and ColorRamp components , which are heavily inspired by https://observablehq.com/@d3/color-legend and https://clhenrick.github.io/color-legend-element/. There is additional work planned: https://github.com/techniq/layerchart/issues/22. I only mention it in case it helps with this PR (see approach, etc).

techniq avatar Jun 28 '23 15:06 techniq