From b588cca1055c3188c1b0fafebe21b47ebc4539fa Mon Sep 17 00:00:00 2001 From: Scott Rhamy Date: Fri, 5 Dec 2025 20:04:00 -0500 Subject: [PATCH 01/15] Reactive Domain-Range Component Could not figure out how to bind a derived value to expose customDomain and customRange for use in md. Tried to do hover over domain with tooltip showing equivilent range and vice versa. Svelte Tooltip doesn't work as tooltip does not follow cursor. Have never seen tooltip implementation that does. html title works, but is janky and looks like trash. I also found that within triplebackticks, values like {lowDomain} and not interpreted. I had to use --- docs/src/routes/docs/guides/scales/+page.md | 28 +-- .../docs/guides/scales/DomainRange.svelte | 179 ++++++++++++++++++ .../docs/guides/scales/Resizable.svelte | 111 +++++++++++ 3 files changed, 307 insertions(+), 11 deletions(-) create mode 100644 docs/src/routes/docs/guides/scales/DomainRange.svelte create mode 100644 docs/src/routes/docs/guides/scales/Resizable.svelte diff --git a/docs/src/routes/docs/guides/scales/+page.md b/docs/src/routes/docs/guides/scales/+page.md index 6c3e85505..272caf153 100644 --- a/docs/src/routes/docs/guides/scales/+page.md +++ b/docs/src/routes/docs/guides/scales/+page.md @@ -1,3 +1,8 @@ + + # Scales > WIP @@ -8,25 +13,26 @@ At its essenece, a scale is a function that maps data values (`domain`) to pixel 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. -![d3 scale image](https://jckr.github.io/blog/wp-content/uploads/2011/08/d3scale1.png) + -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). +this example basically says data/domain values are between {lowDomain} and {highDomain} and range/pixels values are between {lowRange} and {highRange}, and you would setup this (under the hood, what LayerChart does for you). -```js -const xScale = scaleLinear().domain([20, 80]).range([0, 120]); -``` +
+ +const xScale = scaleLinear().domain([{lowDomain}, {highDomain}]).range([{lowRange}, {highRange}]); + or shorthand -```js -const xScale = scaleLinear([20, 80], [0, 120]); -``` + +const xScale = scaleLinear([{lowDomain}, {highDomain}], [{lowRange}, {highRange}]); + and would produce the following: -- `xScale(20)` => `0` -- `xScale(80)` => `120` -- `xScale(50)` => `60` +xScale({lowDomain}) => {lowRange}
+xScale({highDomain}) => {highRange}
+xScale(50) => 60 In LayerChart, range and domain are determined / defaulted for you diff --git a/docs/src/routes/docs/guides/scales/DomainRange.svelte b/docs/src/routes/docs/guides/scales/DomainRange.svelte new file mode 100644 index 000000000..463751e76 --- /dev/null +++ b/docs/src/routes/docs/guides/scales/DomainRange.svelte @@ -0,0 +1,179 @@ + + +
+ + + + + + + + + + + + + + + + + +
diff --git a/docs/src/routes/docs/guides/scales/Resizable.svelte b/docs/src/routes/docs/guides/scales/Resizable.svelte new file mode 100644 index 000000000..56076a50a --- /dev/null +++ b/docs/src/routes/docs/guides/scales/Resizable.svelte @@ -0,0 +1,111 @@ + + +
+ +
0 ? ((low - min) / range) * 100 + '%' : '0%'} + style:width={range > 0 ? ((high - low) / range) * 100 + '%' : '0%'} + style:min-width={minWidth + 'px'} + > + {Math.round(low)} +
+
{title}
+
{domain}
+
+ {Math.round(high)} + + + + +
+
From 56c7946d5a96e6a89c8fb60a72e5406456866172 Mon Sep 17 00:00:00 2001 From: Sean Lynch Date: Sat, 6 Dec 2025 02:18:00 -0500 Subject: [PATCH 02/15] Create `movable` as Svelte attachment (works with components) --- docs/src/lib/attachments/movable.ts | 116 ++++++++++++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 docs/src/lib/attachments/movable.ts 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); + }; + }; +} From 75d36a4d0b4b42b1bebf8a2577035340457e9b17 Mon Sep 17 00:00:00 2001 From: Sean Lynch Date: Sat, 6 Dec 2025 02:19:00 -0500 Subject: [PATCH 03/15] Create Chart-based domain range example --- docs/src/routes/docs/guides/scales/+page.md | 3 + .../guides/scales/DomainRangeChart.svelte | 250 ++++++++++++++++++ 2 files changed, 253 insertions(+) create mode 100644 docs/src/routes/docs/guides/scales/DomainRangeChart.svelte diff --git a/docs/src/routes/docs/guides/scales/+page.md b/docs/src/routes/docs/guides/scales/+page.md index 272caf153..50dc6d219 100644 --- a/docs/src/routes/docs/guides/scales/+page.md +++ b/docs/src/routes/docs/guides/scales/+page.md @@ -1,5 +1,6 @@ @@ -13,6 +14,8 @@ At its essenece, a scale is a function that maps data values (`domain`) to pixel 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. + + this example basically says data/domain values are between {lowDomain} and {highDomain} and range/pixels values are between {lowRange} and {highRange}, and you would setup this (under the hood, what LayerChart does for you). 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..a81668c80 --- /dev/null +++ b/docs/src/routes/docs/guides/scales/DomainRangeChart.svelte @@ -0,0 +1,250 @@ + + + + {#snippet children({ context })} + + + { + const { x } = localPoint(e); + domainValue = Math.round(domainScale(x)); + rangeValue = Math.round(scale(domainValue)); + }} + /> + + { + // @ts-expect-error + const newLow = context.xScale.invert(context.xScale(domain[0]) + dx); + if (newLow >= 0 && newLow < domain[1]) { + domain = [Math.round(newLow), domain[1]]; + } + } + })} + /> + + { + // @ts-expect-error + const newHigh = context.xScale.invert(context.xScale(domain[1]) + dx); + if (newHigh <= chartDomain[1] && newHigh > domain[0]) { + domain = [domain[0], Math.round(newHigh)]; + } + } + })} + /> + + + + + + + { + const { x } = localPoint(e); + rangeValue = Math.round(rangeScale(x)); + domainValue = Math.round(scale.invert(rangeValue)); + }} + /> + + { + // @ts-expect-error + const newLow = context.xScale.invert(context.xScale(range[0]) + dx); + if (newLow >= 0 && newLow < range[1]) { + range = [Math.round(newLow), range[1]]; + } + } + })} + /> + + { + // @ts-expect-error + const newHigh = context.xScale.invert(context.xScale(range[1]) + dx); + if (newHigh <= chartDomain[1] && newHigh > range[0]) { + range = [range[0], Math.round(newHigh)]; + } + } + })} + /> + + + + + + + + + + + + + + + + + {/snippet} + From 786528581d024430b924d05c1572f4cd37cd7ed1 Mon Sep 17 00:00:00 2001 From: Sean Lynch Date: Sat, 6 Dec 2025 11:15:44 -0500 Subject: [PATCH 04/15] Only apply feature column background on small viewport to allow hover styling to work (not optimal, but works for most use cases) --- docs/src/routes/docs/guides/layers/FeatureTable.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 } From 4b5dba0bf2fabf76b460bc2efa6b9d408b4dbc9f Mon Sep 17 00:00:00 2001 From: Sean Lynch Date: Sat, 6 Dec 2025 11:38:10 -0500 Subject: [PATCH 05/15] feat(Circle|Rect): Support passing children snippet for Html layers --- .changeset/hot-dots-stick.md | 5 +++++ docs/src/routes/docs/guides/layers/features.ts | 16 ++++++++++++++++ .../layerchart/src/lib/components/Circle.svelte | 9 ++++++++- .../layerchart/src/lib/components/Rect.svelte | 11 +++++++++-- 4 files changed, 38 insertions(+), 3 deletions(-) create mode 100644 .changeset/hot-dots-stick.md 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/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/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 @@
@@ -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'); +} From a5ba7c3c2b05119d90e99bc1159aedd3b33ebeb5 Mon Sep 17 00:00:00 2001 From: Sean Lynch Date: Sat, 6 Dec 2025 20:48:47 -0500 Subject: [PATCH 08/15] Refine DomainRangeChart. Cleanup old impl. --- docs/src/routes/docs/guides/scales/+page.md | 53 ++++-- .../docs/guides/scales/DomainRange.svelte | 179 ------------------ .../guides/scales/DomainRangeChart.svelte | 47 +++-- .../docs/guides/scales/Resizable.svelte | 111 ----------- 4 files changed, 65 insertions(+), 325 deletions(-) delete mode 100644 docs/src/routes/docs/guides/scales/DomainRange.svelte delete mode 100644 docs/src/routes/docs/guides/scales/Resizable.svelte diff --git a/docs/src/routes/docs/guides/scales/+page.md b/docs/src/routes/docs/guides/scales/+page.md index 50dc6d219..933ad27ba 100644 --- a/docs/src/routes/docs/guides/scales/+page.md +++ b/docs/src/routes/docs/guides/scales/+page.md @@ -1,7 +1,21 @@ # Scales @@ -10,41 +24,38 @@ ## What is a 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). +At its essenece, 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). 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. - - - + -this example basically says data/domain values are between {lowDomain} and {highDomain} and range/pixels values are between {lowRange} and {highRange}, and you would setup this (under the hood, what LayerChart does for you). +This example basically says data/domain values are between {domain[0]} and {domain[1]} and range/pixels values are between {range[0]} and {range[1]}, and you would setup this (under the hood, what LayerChart does for you).
- -const xScale = scaleLinear().domain([{lowDomain}, {highDomain}]).range([{lowRange}, {highRange}]); - + +```js +const xScale = scaleLinear().domain(domain).range(range); +``` or shorthand - -const xScale = scaleLinear([{lowDomain}, {highDomain}], [{lowRange}, {highRange}]); - +```js +const xScale = scaleLinear(domain, range); +``` and would produce the following: -xScale({lowDomain}) => {lowRange}
-xScale({highDomain}) => {highRange}
-xScale(50) => 60 + In LayerChart, range and domain are determined / defaulted for you -- `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) +- `xDomain`: extents of all `x` values in data (based on value accessor), +- `xRange`: `width` of chart (minus left/right padding), +- `yDomain`: extents of all `y` values in data (based on value accessor), +- `yRange`: `height` of chart (minus top/bottom padding) -for most scales, like scaleLinear, you specify the extents (min/max), which is why it is a 2 item array +for most scales, like `scaleLinear`, you specify the extents (min/max), which is why it is a 2 item array 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 diff --git a/docs/src/routes/docs/guides/scales/DomainRange.svelte b/docs/src/routes/docs/guides/scales/DomainRange.svelte deleted file mode 100644 index 463751e76..000000000 --- a/docs/src/routes/docs/guides/scales/DomainRange.svelte +++ /dev/null @@ -1,179 +0,0 @@ - - -
- - - - - - - - - - - - - - - - - -
diff --git a/docs/src/routes/docs/guides/scales/DomainRangeChart.svelte b/docs/src/routes/docs/guides/scales/DomainRangeChart.svelte index 98796b7b0..d3aaa4673 100644 --- a/docs/src/routes/docs/guides/scales/DomainRangeChart.svelte +++ b/docs/src/routes/docs/guides/scales/DomainRangeChart.svelte @@ -9,8 +9,18 @@ const rectHeight = 64; const handleWidth = 16; - let domain = $state([100, 400]); - let range = $state([0, 500]); + let { + domain = $bindable([100, 400]), + range = $bindable([0, 500]), + value = $bindable(), + rangeValue = $bindable() + }: { + domain?: number[]; + range?: number[]; + value?: number; + rangeValue?: number; + } = $props(); + let scale = $derived(scaleLinear().domain(domain).range(range)); const chartDomain = [0, 500]; @@ -18,8 +28,15 @@ let midDomain = $derived(Math.round(domain[0] + (domain[1] - domain[0]) / 2)); let midRange = $derived(Math.round(range[0] + (range[1] - range[0]) / 2)); - let domainValue = $derived(midDomain); - let rangeValue = $derived(Math.round(scale(midDomain))); + // Initialize domainValue and rangeValue if not provided + $effect(() => { + if (value === undefined) { + value = midDomain; + } + if (rangeValue === undefined) { + rangeValue = Math.round(scale(midDomain)); + } + }); let context = $state(null!); @@ -55,8 +72,8 @@ class="bg-primary/10 border-2 border-primary/70 rounded-lg grid items-center" onpointermove={(e) => { const { x } = localPoint(e); - domainValue = Math.round(domainScale(x)); - rangeValue = Math.round(scale(domainValue)); + value = Math.round(domainScale(x)); + rangeValue = Math.round(scale(value)); }} /> @@ -118,11 +135,12 @@ class="text-primary font-semibold pointer-events-none" /> { const { x } = localPoint(e); rangeValue = Math.round(rangeScale(x)); - domainValue = Math.round(scale.invert(rangeValue)); + value = Math.round(scale.invert(rangeValue)); }} /> @@ -210,8 +228,9 @@ value={rangeValue} x={context.xScale(rangeValue)} y={context.height - rectHeight} + dy={3} textAnchor="middle" - verticalAnchor="middle" + verticalAnchor="start" class="text-sm text-white bg-primary rounded-full px-2 font-medium pointer-events-none" /> - import { onMount } from 'svelte'; - import { Tooltip } from 'svelte-ux'; - import { cls } from '@layerstack/tailwind'; - - let { - title = '', - low = $bindable(20), - high = $bindable(60), - min = 0, - max = 100, - value = $bindable(0), - minWidth = 150, - containerRef = $bindable(), - className = '' - } = $props(); - - let containerEl: HTMLDivElement; - let containerWidth = 0; - - const range = $derived(max - min); - const domain = $derived(Math.round(low + (high - low) / 2)); - - let resizingFrom: 'left' | 'right' | null = null; - let startX = 0; - let startLow = 0; - let startHigh = 0; - - onMount(() => { - window.addEventListener('mousemove', onMouseMove); - window.addEventListener('mouseup', onMouseUp); - return () => { - window.removeEventListener('mousemove', onMouseMove); - window.removeEventListener('mouseup', onMouseUp); - }; - }); - - function startResize(e: MouseEvent, side: 'left' | 'right') { - resizingFrom = side; - startX = e.pageX; - startLow = low; - startHigh = high; - e.preventDefault(); - } - - function onMouseMove(e: MouseEvent) { - if (!resizingFrom || !containerEl) return; - - containerWidth = containerEl.clientWidth; - if (containerWidth === 0 || range === 0) return; - - const deltaPx = e.pageX - startX; - const deltaValue = (deltaPx / containerWidth) * range; - const minWidthValue = (minWidth / containerWidth) * range; - - if (resizingFrom === 'left') { - const newLow = Math.round( - Math.min(Math.max(min, startLow + deltaValue), startHigh - minWidthValue) - ); - low = newLow; - } else if (resizingFrom === 'right') { - const newHigh = Math.round( - Math.max(Math.min(max, startHigh + deltaValue), startLow + minWidthValue) - ); - high = newHigh; - } - } - - function onMouseUp() { - resizingFrom = null; - } - - $effect(() => { - if (containerEl) { - containerRef = containerEl; - } - }); - - -
- -
0 ? ((low - min) / range) * 100 + '%' : '0%'} - style:width={range > 0 ? ((high - low) / range) * 100 + '%' : '0%'} - style:min-width={minWidth + 'px'} - > - {Math.round(low)} -
-
{title}
-
{domain}
-
- {Math.round(high)} - - - - -
-
From 11299c5a1b48df7d88984cc92206fcaee0e2e796 Mon Sep 17 00:00:00 2001 From: Sean Lynch Date: Sat, 6 Dec 2025 21:19:48 -0500 Subject: [PATCH 09/15] Animate value with play/stop button --- .../guides/scales/DomainRangeChart.svelte | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/docs/src/routes/docs/guides/scales/DomainRangeChart.svelte b/docs/src/routes/docs/guides/scales/DomainRangeChart.svelte index d3aaa4673..54ced7034 100644 --- a/docs/src/routes/docs/guides/scales/DomainRangeChart.svelte +++ b/docs/src/routes/docs/guides/scales/DomainRangeChart.svelte @@ -3,8 +3,12 @@ import { movable } from '$lib/attachments/movable'; import { localPoint } from '@layerstack/utils'; import { scaleLinear } from 'd3-scale'; + import { AnimationFrames } from 'runed'; + import { Button, ButtonGroup } from 'svelte-ux'; import LucideGripVertical from '~icons/lucide/grip-vertical'; + import LucidePlay from '~icons/lucide/play'; + import LucideSquare from '~icons/lucide/square'; const rectHeight = 64; const handleWidth = 16; @@ -38,6 +42,35 @@ } }); + // Track hover state for domain and range rects + let isHoveringDomain = $state(false); + let isHoveringRange = $state(false); + + // Animation state + let isPlaying = $state(true); + let animationDirection = $state<'forward' | 'backward'>('forward'); + const animationSpeed = 1; // whole integers per step + + const animationFrames = new AnimationFrames( + () => { + if (isPlaying && !isHoveringDomain && !isHoveringRange) { + if (animationDirection === 'forward') { + value = Math.min(Math.round(value! + animationSpeed), domain[1]); + if (value >= domain[1]) { + animationDirection = 'backward'; + } + } else { + value = Math.max(Math.round(value! - animationSpeed), domain[0]); + if (value <= domain[0]) { + animationDirection = 'forward'; + } + } + rangeValue = Math.round(scale(value)); + } + }, + { fpsLimit: 60 } + ); + let context = $state(null!); // Map domain rect width to domain values @@ -54,6 +87,23 @@ ); +
+ +
+ { + isHoveringDomain = true; + }} + onpointerleave={() => { + isHoveringDomain = false; + }} onpointermove={(e) => { const { x } = localPoint(e); value = Math.round(domainScale(x)); @@ -161,6 +217,12 @@ height={rectHeight} rx={8} class="bg-primary/10 border-2 border-primary/70 rounded-lg" + onpointerenter={() => { + isHoveringRange = true; + }} + onpointerleave={() => { + isHoveringRange = false; + }} onpointermove={(e) => { const { x } = localPoint(e); rangeValue = Math.round(rangeScale(x)); From a144b8aec21a46b0a245eb2a6b49c271b8094454 Mon Sep 17 00:00:00 2001 From: Sean Lynch Date: Sat, 6 Dec 2025 21:40:31 -0500 Subject: [PATCH 10/15] Improve pointer handling --- .../guides/scales/DomainRangeChart.svelte | 49 +++++++++++++++++-- 1 file changed, 44 insertions(+), 5 deletions(-) diff --git a/docs/src/routes/docs/guides/scales/DomainRangeChart.svelte b/docs/src/routes/docs/guides/scales/DomainRangeChart.svelte index 54ced7034..f849d8155 100644 --- a/docs/src/routes/docs/guides/scales/DomainRangeChart.svelte +++ b/docs/src/routes/docs/guides/scales/DomainRangeChart.svelte @@ -1,7 +1,6 @@
@@ -112,236 +94,30 @@ > {#snippet children({ context })} - - { - isHoveringDomain = true; - }} - onpointerleave={() => { - isHoveringDomain = false; - }} - onpointermove={(e) => { - value = Math.round(domainScale(e.offsetX)); - rangeValue = Math.round(scale(value)); + {chartDomain} + onValueChange={(v) => { + rangeValue = Math.round(scale(v)); }} /> - - { - isHoveringDomain = true; - }} - onpointerleave={() => { - isHoveringDomain = false; - }} - onpointermove={(e) => { - value = Math.round(domainScale(e.offsetX)); - rangeValue = Math.round(scale(value)); - }} - {@attach movable({ - onMove: ({ dx }) => { - // @ts-expect-error - const newLow = context.xScale.invert(context.xScale(domain[0]) + dx); - if (newLow >= 0 && newLow < domain[1]) { - domain = [Math.round(newLow), domain[1]]; - } - } - })} - > - - - - { - isHoveringDomain = true; - }} - onpointerleave={() => { - isHoveringDomain = false; - }} - onpointermove={(e) => { - const rectWidth = context.xScale(domain[1]) - context.xScale(domain[0]); - value = Math.round(domainScale(rectWidth - handleWidth + e.offsetX)); - rangeValue = Math.round(scale(value)); + { + value = Math.round(scale.invert(v)); }} - {@attach movable({ - onMove: ({ dx }) => { - // @ts-expect-error - const newHigh = context.xScale.invert(context.xScale(domain[1]) + dx); - if (newHigh <= chartDomain[1] && newHigh > domain[0]) { - domain = [domain[0], Math.round(newHigh)]; - } - } - })} - > - - - - - - - - - { - isHoveringRange = true; - }} - onpointerleave={() => { - isHoveringRange = false; - }} - onpointermove={(e) => { - rangeValue = Math.round(rangeScale(e.offsetX)); - value = Math.round(scale.invert(rangeValue)); - }} - /> - - { - isHoveringRange = true; - }} - onpointerleave={() => { - isHoveringRange = false; - }} - onpointermove={(e) => { - rangeValue = Math.round(rangeScale(e.offsetX)); - value = Math.round(scale.invert(rangeValue)); - }} - {@attach movable({ - onMove: ({ dx }) => { - // @ts-expect-error - const newLow = context.xScale.invert(context.xScale(range[0]) + dx); - if (newLow >= 0 && newLow < range[1]) { - range = [Math.round(newLow), range[1]]; - } - } - })} - > - - - - { - isHoveringRange = true; - }} - onpointerleave={() => { - isHoveringRange = false; - }} - onpointermove={(e) => { - const rectWidth = context.xScale(range[1]) - context.xScale(range[0]); - rangeValue = Math.round(rangeScale(rectWidth - handleWidth + e.offsetX)); - value = Math.round(scale.invert(rangeValue)); - }} - {@attach movable({ - onMove: ({ dx }) => { - // @ts-expect-error - const newHigh = context.xScale.invert(context.xScale(range[1]) + dx); - if (newHigh <= chartDomain[1] && newHigh > range[0]) { - range = [range[0], Math.round(newHigh)]; - } - } - })} - > - - - - - - 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)]; + } + } + })} +> + + + + + + + + + + + + + + From 53a8453a265f16731d080d1814be696750178995 Mon Sep 17 00:00:00 2001 From: Sean Lynch Date: Sat, 6 Dec 2025 22:00:01 -0500 Subject: [PATCH 12/15] Add grid lines for better referenence --- .../guides/scales/DomainRangeChart.svelte | 45 ++++++++++--------- 1 file changed, 24 insertions(+), 21 deletions(-) diff --git a/docs/src/routes/docs/guides/scales/DomainRangeChart.svelte b/docs/src/routes/docs/guides/scales/DomainRangeChart.svelte index a5b5353df..2483c64b5 100644 --- a/docs/src/routes/docs/guides/scales/DomainRangeChart.svelte +++ b/docs/src/routes/docs/guides/scales/DomainRangeChart.svelte @@ -3,6 +3,8 @@ import { scaleLinear } from 'd3-scale'; import { AnimationFrames } from 'runed'; import { Button, ButtonGroup } from 'svelte-ux'; + import { cls } from '@layerstack/tailwind'; + import ResizableRect from './ResizableRect.svelte'; import LucidePlay from '~icons/lucide/play'; @@ -122,17 +124,28 @@ - - - - + + {@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} From bb16e9d89fda2baa932ad88a827d172feb21449e Mon Sep 17 00:00:00 2001 From: Sean Lynch Date: Sat, 6 Dec 2025 22:11:42 -0500 Subject: [PATCH 13/15] Refine inline code styling --- docs/src/app.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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; } From 27a8e3d9b98dc2318167d147543537743967c6cf Mon Sep 17 00:00:00 2001 From: Sean Lynch Date: Sat, 6 Dec 2025 22:14:56 -0500 Subject: [PATCH 14/15] Add links to scales --- docs/src/routes/docs/guides/scales/+page.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/src/routes/docs/guides/scales/+page.md b/docs/src/routes/docs/guides/scales/+page.md index 933ad27ba..756466a49 100644 --- a/docs/src/routes/docs/guides/scales/+page.md +++ b/docs/src/routes/docs/guides/scales/+page.md @@ -26,7 +26,7 @@ At its essenece, 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). -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. +LayerChart uses [d3-scale](https://d3js.org/d3-scale) under the hood which provides many different scales (i.e. "mappers") including [`scaleLinear`](https://d3js.org/d3-scale/linear), [`scaleTime`](https://d3js.org/d3-scale/time), [`scaleBand`](https://d3js.org/d3-scale/band), and others. From 822114c4237e1af79b11a5c0d4b2aa5d620306e6 Mon Sep 17 00:00:00 2001 From: Sean Lynch Date: Sat, 6 Dec 2025 22:26:44 -0500 Subject: [PATCH 15/15] Improve scale docs --- docs/src/routes/docs/guides/scales/+page.md | 103 +++++++++++++++----- 1 file changed, 81 insertions(+), 22 deletions(-) diff --git a/docs/src/routes/docs/guides/scales/+page.md b/docs/src/routes/docs/guides/scales/+page.md index 756466a49..d16b1f8d7 100644 --- a/docs/src/routes/docs/guides/scales/+page.md +++ b/docs/src/routes/docs/guides/scales/+page.md @@ -20,62 +20,121 @@ # Scales -> WIP +## What is a scale? -## 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). -At its essenece, 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). - -LayerChart uses [d3-scale](https://d3js.org/d3-scale) under the hood which provides many different scales (i.e. "mappers") including [`scaleLinear`](https://d3js.org/d3-scale/linear), [`scaleTime`](https://d3js.org/d3-scale/time), [`scaleBand`](https://d3js.org/d3-scale/band), and others. +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). -This example basically says data/domain values are between {domain[0]} and {domain[1]} and range/pixels values are between {range[0]} and {range[1]}, and you would setup this (under the hood, what LayerChart does for you). +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. + +### Creating a scale -
+Under the hood, LayerChart creates scales for you, but here's what that looks like: ```js const xScale = scaleLinear().domain(domain).range(range); ``` -or shorthand +or using the shorthand syntax: ```js const xScale = scaleLinear(domain, range); ``` -and would produce the following: +With the current values, this scale would produce: -In LayerChart, range and domain are determined / defaulted for you +## Automatic scale selection -- `xDomain`: extents of all `x` values in data (based on value accessor), -- `xRange`: `width` of chart (minus left/right padding), -- `yDomain`: extents of 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 -this guarantees you always show `0` on your a-axis +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 + +- **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. -## Resources +Learn more: [Scale Ticks (Observable)](https://observablehq.com/@d3/scale-ticks?collection=@d3/d3-scale) +## 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