layercake
layercake copied to clipboard
New component: Color scale legend
Here's a rough 1st draft for closing #120. Feel free to suggest or apply yourself substantial changes.
Here are some demos:
https://elementari.janosh.dev/color-bar
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.
Oops, didn't see the code base uses JsDoc. No problem, I'll convert.
@mhkeller Let me know if this needs further work.
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?
The style and textStyle props may fall in that same bucket too
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:
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?
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
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
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.
I need to make the sliders nicer / better organized on the demo page.
@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.
I think your changes look great!
2 small things I noticed:
In the component gallery, the color bar overflows its tile.
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.
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
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.
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
.
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](https://user-images.githubusercontent.com/498744/234462144-f9c0cc63-8acb-47bf-9e61-90daf702b385.png)
Brave on macOS here. So Chromium based.
I think I fixed it. I'm sure the CSS logic could be improved...
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.
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!
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.
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/
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.
hey @janosh wanted to see if you were still interested in this one
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))
}
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 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.
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.
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).