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

New component: Color scale legend #121

Draft
wants to merge 19 commits into
base: main
Choose a base branch
from
Draft

Conversation

janosh
Copy link

@janosh janosh commented Mar 17, 2023

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

@janosh
Copy link
Author

janosh commented Mar 17, 2023

Here are some demos:

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

@mhkeller
Copy link
Owner

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.

@janosh
Copy link
Author

janosh commented Mar 17, 2023

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

@janosh
Copy link
Author

janosh commented Mar 23, 2023

@mhkeller Let me know if this needs further work.

@mhkeller
Copy link
Owner

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
Copy link
Owner

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

@mhkeller
Copy link
Owner

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?

@janosh
Copy link
Author

janosh commented Mar 25, 2023

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.

Screen.Recording.2023-03-25.at.12.32.50.mp4

@mhkeller
Copy link
Owner

mhkeller commented Mar 25, 2023

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
  • Finalize which props get sliders / checkboxes
  • Pick which other scales to show examples of on the demo page

@mhkeller
Copy link
Owner

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
Copy link
Owner

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

@mhkeller
Copy link
Owner

@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.

@janosh
Copy link
Author

janosh commented Apr 20, 2023

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

@mhkeller
Copy link
Owner

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: 8e34d8b I'll take another look as to why that's not working

@mhkeller
Copy link
Owner

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.

@janosh
Copy link
Author

janosh commented Apr 20, 2023

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.

@mhkeller
Copy link
Owner

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

@janosh
Copy link
Author

janosh commented Apr 26, 2023

Brave on macOS here. So Chromium based.

@mhkeller
Copy link
Owner

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

@janosh
Copy link
Author

janosh commented Apr 26, 2023

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

@mhkeller
Copy link
Owner

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!

@janosh
Copy link
Author

janosh commented Apr 26, 2023

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.

@mhkeller
Copy link
Owner

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/

@janosh
Copy link
Author

janosh commented Apr 26, 2023

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.

? ticks($zScale.ticks())
: $zScale.ticks(ticks);

$: ramped = [...Array(steps).keys()].map((i) => $zScale(i / steps));
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@janosh I think the gradient creation needs some tweaks. I was testing it out with scaleDiverging using a domain of [-1, 0, 1] and because the gradient uses i here, it will always start at 0 and never get negative values. It should instead use the tick values. This should be a good example to work from: https://observablehq.com/@d3/color-legend

@mhkeller
Copy link
Owner

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

@janosh
Copy link
Author

janosh commented May 24, 2023

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))
}

@mhkeller
Copy link
Owner

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.

@janosh
Copy link
Author

janosh commented May 27, 2023

@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.

@mhkeller
Copy link
Owner

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.

@techniq
Copy link
Contributor

techniq commented Jun 28, 2023

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: techniq/layerchart#22. I only mention it in case it helps with this PR (see approach, etc).

@mhkeller
Copy link
Owner

Thanks @techniq! Moved the color scale discussion to here: #138

@mhkeller
Copy link
Owner

mhkeller commented Jul 1, 2023

Hm this notebook no longer exists: https://observablehq.com/@d3/color-legend

@techniq
Copy link
Contributor

techniq commented Jul 1, 2023

I can still see it...

image

@mhkeller
Copy link
Owner

mhkeller commented Jul 1, 2023

Weird. Same now...

@mhkeller mhkeller marked this pull request as draft February 10, 2024 16:37
@mhkeller
Copy link
Owner

Looking at https://observablehq.com/@d3/color-legend, there are a lot of different types of scales that are supported here that each require their own implementation. I'm not sure this will be a big priority for me in the near-term but if someone wants to take this up, that would be great.

@mhkeller mhkeller changed the title Add src/_components/ColorBar.svelte New component: Color scale legend Feb 10, 2024
@mhkeller mhkeller added the help wanted Extra attention is needed label Mar 12, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
help wanted Extra attention is needed website-related
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants