diff --git a/.changeset/hot-dots-stick.md b/.changeset/hot-dots-stick.md new file mode 100644 index 000000000..e1b9dbd87 --- /dev/null +++ b/.changeset/hot-dots-stick.md @@ -0,0 +1,5 @@ +--- +'layerchart': patch +--- + +feat(Circle|Rect): Support passing children snippet for Html layers diff --git a/docs/src/app.css b/docs/src/app.css index d542418e6..3e4d0be44 100644 --- a/docs/src/app.css +++ b/docs/src/app.css @@ -79,5 +79,5 @@ code[data-theme*=' '] span { /* Inline code styling */ code:not(pre > code):not(.custom) { - @apply text-sm font-medium bg-surface-100 text-[initial] font-pixel px-2 rounded border border-primary/10; + @apply text-sm font-medium bg-primary/5 text-primary font-pixel px-2 rounded border border-primary/50; } diff --git a/docs/src/lib/attachments/movable.ts b/docs/src/lib/attachments/movable.ts new file mode 100644 index 000000000..ea2f95d73 --- /dev/null +++ b/docs/src/lib/attachments/movable.ts @@ -0,0 +1,116 @@ +type MovableOptions = { + /** + * Number of pixels to step + */ + step?: number; + + /** + * Percentage of parent element's pixels to step + */ + stepPercent?: number; + + axis?: 'x' | 'y' | 'xy'; + + onMoveStart?: (opts: { x: number; y: number }) => void; + onMove?: (opts: { x: number; y: number; dx: number; dy: number }) => void; + onMoveEnd?: (opts: { x: number; y: number }) => void; +}; + +export function movable(options: MovableOptions = {}) { + return (node: HTMLElement | SVGElement) => { + let lastX = 0; + let lastY = 0; + let moved = false; + + function onMouseDown(event: MouseEvent) { + lastX = event.clientX; + lastY = event.clientY; + moved = false; + + options?.onMoveStart?.({ x: lastX, y: lastY }); + + window.addEventListener('mousemove', onMouseMove); + window.addEventListener('mouseup', onMouseUp); + } + (node as HTMLElement).addEventListener('mousedown', onMouseDown); + + function onMouseMove(event: MouseEvent) { + moved = true; + + // TODO: Handle page scroll? clientX/Y is based on viewport (apply to parent?) + let dx = event.clientX - lastX; + let dy = event.clientY - lastY; + + const xEnabled = options?.axis?.includes('x') ?? true; + const yEnabled = options?.axis?.includes('y') ?? true; + + if (options.step) { + if (Math.abs(dx) >= options.step) { + const overStep = dx % options.step; + dx = dx - overStep; + lastX = event.clientX - overStep; + } else { + dx = 0; + } + + if (Math.abs(dy) >= options.step) { + const overStep = dy % options.step; + dy = dy - overStep; + lastY = event.clientY - overStep; + } else { + dy = 0; + } + } else if (options.stepPercent) { + const parentWidth = node.parentElement?.offsetWidth ?? 0; + const parentHeight = node.parentElement?.offsetHeight ?? 0; + + if (Math.abs(dx / parentWidth) >= options.stepPercent) { + const overStep = dx % (parentWidth * options.stepPercent); + dx = dx - overStep; + lastX = event.clientX - overStep; + } else { + dx = 0; + } + + if (Math.abs(dy / parentHeight) >= options.stepPercent) { + const overStep = dy % (parentHeight * options.stepPercent); + dy = dy - overStep; + lastY = event.clientY - overStep; + } else { + dy = 0; + } + } else { + lastX = event.clientX; + lastY = event.clientY; + } + + if ((xEnabled && dx) || (yEnabled && dy)) { + options.onMove?.({ x: lastX, y: lastX, dx: xEnabled ? dx : 0, dy: yEnabled ? dy : 0 }); + } else { + // Not enough change + } + } + + function onMouseUp(event: MouseEvent) { + lastX = event.clientX; + lastY = event.clientY; + + options.onMoveEnd?.({ x: lastX, y: lastY }); + + window.removeEventListener('mousemove', onMouseMove); + window.removeEventListener('mouseup', onMouseUp); + } + + function onClick(event: MouseEvent) { + if (moved) { + event.stopImmediatePropagation(); + } + } + (node as HTMLElement).addEventListener('click', onClick); + + return () => { + (node as HTMLElement).removeEventListener('mousedown', onMouseDown); + (node as HTMLElement).removeEventListener('click', onClick); + }; + }; +} diff --git a/docs/src/lib/components/Code.svelte b/docs/src/lib/components/Code.svelte index d56cccb83..181e7b63e 100644 --- a/docs/src/lib/components/Code.svelte +++ b/docs/src/lib/components/Code.svelte @@ -19,6 +19,7 @@ import SimpleIconsTerminal from '~icons/simple-icons/windowsterminal'; import SimpleIconsSvelte from '~icons/simple-icons/svelte'; import SimpleIconsHTML5 from '~icons/simple-icons/html5'; + import { stripIndent } from '$lib/utils/string'; interface Props { source?: string | null; @@ -37,6 +38,8 @@ classes = {}, class: className }: Props & HTMLAttributes = $props(); + + let sourceStr = $derived(stripIndent(source ?? ''));
@@ -76,7 +79,7 @@
Loading...
{:then h} - {@html h.codeToHtml(source, { + {@html h.codeToHtml(sourceStr, { lang: language, themes: { light: 'github-light-default', @@ -98,7 +101,7 @@ )} > diff --git a/docs/src/lib/utils/string.ts b/docs/src/lib/utils/string.ts index 6503fb7ff..315d46520 100644 --- a/docs/src/lib/utils/string.ts +++ b/docs/src/lib/utils/string.ts @@ -59,3 +59,25 @@ function getPixel(imageData: ImageData, x: number, y: number) { const d = imageData.data; return [d[i], d[i + 1], d[i + 2], d[i + 3]]; } + +/** + * Remove leading indentation from multiline string (useful for template literals) + */ +export function stripIndent(text: string) { + const lines = text.split('\n'); + + // Find the minimum indentation (ignoring empty lines) + const minIndent = lines + .filter((line) => line.trim().length > 0) + .reduce((min, line) => { + const match = line.match(/^(\s*)/); + const indent = match ? match[1].length : 0; + return Math.min(min, indent); + }, Infinity); + + // Remove the minimum indentation from all lines + return lines + .map((line) => line.slice(minIndent)) + .filter((line) => line !== '') + .join('\n'); +} diff --git a/docs/src/routes/docs/guides/layers/FeatureTable.svelte b/docs/src/routes/docs/guides/layers/FeatureTable.svelte index 9523e434f..44e187801 100644 --- a/docs/src/routes/docs/guides/layers/FeatureTable.svelte +++ b/docs/src/routes/docs/guides/layers/FeatureTable.svelte @@ -16,7 +16,7 @@ { name: 'feature', header: 'Feature', - classes: { th: 'bg-surface-200', td: 'w-100 bg-surface-200' }, + classes: { th: 'bg-surface-200', td: 'w-100 max-sm:bg-surface-200' }, sticky: { left: true } diff --git a/docs/src/routes/docs/guides/layers/features.ts b/docs/src/routes/docs/guides/layers/features.ts index e03b3e91d..c5bc462bb 100644 --- a/docs/src/routes/docs/guides/layers/features.ts +++ b/docs/src/routes/docs/guides/layers/features.ts @@ -382,5 +382,21 @@ export const other = [ webgl: { support: true } + }, + { + feature: 'Marks can contain children', + html: { + support: true, + note: 'Example content' + }, + svg: { + support: false + }, + canvas: { + support: false + }, + webgl: { + support: false + } } ]; diff --git a/docs/src/routes/docs/guides/scales/+page.md b/docs/src/routes/docs/guides/scales/+page.md index 6c3e85505..d16b1f8d7 100644 --- a/docs/src/routes/docs/guides/scales/+page.md +++ b/docs/src/routes/docs/guides/scales/+page.md @@ -1,61 +1,140 @@ + + # Scales -> WIP +## What is a scale? + +At its essence, a scale is a function that maps data values (`domain`) to pixel or color values (`range`) on a per-dimension basis (`x`, `y`, `color`, etc). -## What is a scale +LayerChart uses [d3-scale](https://d3js.org/d3-scale) under the hood, which provides many different scale types including [`scaleLinear`](https://d3js.org/d3-scale/linear), [`scaleTime`](https://d3js.org/d3-scale/time), [`scaleBand`](https://d3js.org/d3-scale/band), and [many others](https://d3js.org/d3-scale). -At its essenece, a scale is a function that maps data values (`domain`) to pixel values (`range`) on a per-dimension basis (x, y, color, etc). + -LayerChart uses [d3-scale](https://d3js.org/d3-scale) under the hood which provides many different scales (i.e. "mappers") including `scaleLinear`, `scaleTime`, `scaleBand`, and others. +In this interactive example, the **domain** (top bar) represents your data values ranging from {domain[0]} to {domain[1]}, while the **range** (bottom bar) represents the pixel values from {range[0]} to {range[1]}. The animated line shows how a domain value maps to its corresponding range value. Try dragging the edges to resize the domain/range, or mouse over to see how different values map between them. -![d3 scale image](https://jckr.github.io/blog/wp-content/uploads/2011/08/d3scale1.png) +### Creating a scale -this example basically says data/domain values are between `20` and `80` and range/pixels values are between `0` and `120`, and you would setup this (under the hood, what LayerChart does for you). +Under the hood, LayerChart creates scales for you, but here's what that looks like: ```js -const xScale = scaleLinear().domain([20, 80]).range([0, 120]); +const xScale = scaleLinear().domain(domain).range(range); ``` -or shorthand +or using the shorthand syntax: ```js -const xScale = scaleLinear([20, 80], [0, 120]); +const xScale = scaleLinear(domain, range); ``` -and would produce the following: +With the current values, this scale would produce: -- `xScale(20)` => `0` -- `xScale(80)` => `120` -- `xScale(50)` => `60` + -In LayerChart, range and domain are determined / defaulted for you +## Automatic scale selection -- `xDomain` => all `x` values in data (based on value accessor), -- `xRange` => `width` of chart (minus left/right padding), -- `yDomain` => all `y` values in data (based on value accessor), -- `yRange` => `height` of chart (minus top/bottom padding) +LayerChart intelligently selects the appropriate scale type based on your data. You don't need to explicitly specify whether to use `scaleLinear`, `scaleTime`, or `scaleBand` — LayerChart determines this automatically by inspecting your domain values or data. -for most scales, like scaleLinear, you specify the extents (min/max), which is why it is a 2 item array +The scale selection logic works as follows: -LayerChart will calcualte these for you, or you can pass an explicit value for one of both. If the other value is `null`, it will be calculated based on the data/chart dimensions +1. **`scaleTime`** — Used when domain or data contains `Date` objects +2. **`scaleLinear`** — Used when domain or data contains numbers +3. **`scaleBand`** — Used when domain or data contains strings (categorical data) -```js +For example: + +```svelte + + + + + + + + +``` + +You can override the automatic selection by explicitly passing a scale: + +```svelte + +``` + +## Automatic domain and range calculation + +Within a `Chart`, the domain and range are automatically determined for you based on your data and chart dimensions: + +- **`xDomain`**: The extent (min/max) of all `x` values in your data (based on the value accessor) +- **`xRange`**: The `width` of the chart (minus left/right padding) +- **`yDomain`**: The extent (min/max) of all `y` values in your data (based on the value accessor) +- **`yRange`**: The `height` of the chart (minus top/bottom padding) + +For most continuous scales like `scaleLinear`, the domain and range are specified as two-element arrays representing the minimum and maximum values. + +### Overriding domain and range + +You can override the automatic calculation by providing explicit values. If you want LayerChart to calculate one of the extent values, pass `null` for that position: + +```svelte ``` -This means also have a min extent of `0` regardless of your smallest data value, but calculate the max (by specifying `null`) +This sets a minimum y-domain value of `0` (regardless of your smallest data value) while letting LayerChart calculate the maximum value based on your data. This is useful when you want to ensure your y-axis always starts at zero. + +You can also specify both values to have complete control: + +```svelte + +``` + +## Scale types + +LayerChart supports all d3-scale types: + +- **Continuous scales**: Map continuous domains to continuous ranges + - [`scaleLinear`](https://d3js.org/d3-scale/linear): Linear mapping (most common) + - [`scaleLog`](https://d3js.org/d3-scale/log): Logarithmic mapping + - [`scalePow`](https://d3js.org/d3-scale/pow): Power (exponential) mapping + - [`scaleTime`](https://d3js.org/d3-scale/time): Time-based mapping with calendar intervals + +- **Discrete scales**: Map discrete domains to discrete or continuous ranges + - [`scaleBand`](https://d3js.org/d3-scale/band): For bar charts and categorical data + - [`scalePoint`](https://d3js.org/d3-scale/point): For scatter plots with categorical data + - [`scaleOrdinal`](https://d3js.org/d3-scale/ordinal): General categorical mapping -this guarantees you always show `0` on your a-axis +- **Sequential scales**: Map continuous domains to interpolated color schemes + - [`scaleSequential`](https://d3js.org/d3-scale/sequential): For heatmaps and choropleth maps + +See the [d3-scale documentation](https://d3js.org/d3-scale) for a complete reference. ## Ticks -https://observablehq.com/@d3/scale-ticks?collection=@d3/d3-scale +Scales provide a `.ticks()` method that generates human-readable reference values at regular intervals. LayerChart uses these for axis labels and grid lines. You can customize the number of ticks or provide explicit tick values. + +Learn more: [Scale Ticks (Observable)](https://observablehq.com/@d3/scale-ticks?collection=@d3/d3-scale) -## Resources +## Useful resources +- [d3-scale documentation](https://d3js.org/d3-scale) - [Introducing d3-scale](https://medium.com/@mbostock/introducing-d3-scale-61980c51545f 'https://medium.com/@mbostock/introducing-d3-scale-61980c51545f') - [D3 in Depth: D3 Scale functions](https://www.d3indepth.com/scales/) - [https://scottmurray.org/tutorials/d3/scales](https://scottmurray.org/tutorials/d3/scales 'https://scottmurray.org/tutorials/d3/scales') - [https://jckr.github.io/blog/2011/08/11/d3-scales-and-color/](https://jckr.github.io/blog/2011/08/11/d3-scales-and-color/ 'https://jckr.github.io/blog/2011/08/11/d3-scales-and-color/') -- [d3-scale documentation](https://d3js.org/d3-scale) - https://observablehq.com/@d3/continuous-scales?collection=@d3/d3-scale diff --git a/docs/src/routes/docs/guides/scales/DomainRangeChart.svelte b/docs/src/routes/docs/guides/scales/DomainRangeChart.svelte new file mode 100644 index 000000000..2483c64b5 --- /dev/null +++ b/docs/src/routes/docs/guides/scales/DomainRangeChart.svelte @@ -0,0 +1,160 @@ + + +
+ +
+ + + {#snippet children({ context })} + + { + rangeValue = Math.round(scale(v)); + }} + /> + + { + value = Math.round(scale.invert(v)); + }} + /> + + + + + {@const lineCount = 20} + {#each Array.from({ length: lineCount }) as _, i} + {@const t = i / (lineCount - 1)} + {@const domainVal = domain[0] + (domain[1] - domain[0]) * t} + {@const rangeVal = range[0] + (range[1] - range[0]) * t} + + {/each} + + + + + {/snippet} + diff --git a/docs/src/routes/docs/guides/scales/ResizableRect.svelte b/docs/src/routes/docs/guides/scales/ResizableRect.svelte new file mode 100644 index 000000000..29c921406 --- /dev/null +++ b/docs/src/routes/docs/guides/scales/ResizableRect.svelte @@ -0,0 +1,166 @@ + + + + { + isHovering = true; + }} + onpointerleave={() => { + isHovering = false; + }} + onpointermove={(e) => handlePointerMove(e)} +/> + + + { + isHovering = true; + }} + onpointerleave={() => { + isHovering = false; + }} + onpointermove={(e) => handlePointerMove(e)} + {@attach movable({ + onMove: ({ dx }) => { + // @ts-expect-error + const newLow = context.xScale.invert(context.xScale(bounds[0]) + dx); + if (newLow >= 0 && newLow < bounds[1]) { + bounds = [Math.round(newLow), bounds[1]]; + } + } + })} +> + + + + + { + isHovering = true; + }} + onpointerleave={() => { + isHovering = false; + }} + onpointermove={(e) => handlePointerMove(e, true)} + {@attach movable({ + onMove: ({ dx }) => { + // @ts-expect-error + const newHigh = context.xScale.invert(context.xScale(bounds[1]) + dx); + if (newHigh <= chartDomain[1] && newHigh > bounds[0]) { + bounds = [bounds[0], Math.round(newHigh)]; + } + } + })} +> + + + + + + + + + + + + + + diff --git a/packages/layerchart/src/lib/components/Circle.svelte b/packages/layerchart/src/lib/components/Circle.svelte index fabfe82fa..66ba984e4 100644 --- a/packages/layerchart/src/lib/components/Circle.svelte +++ b/packages/layerchart/src/lib/components/Circle.svelte @@ -1,4 +1,5 @@