diff --git a/.changeset/five-phones-tease.md b/.changeset/five-phones-tease.md new file mode 100644 index 000000000..d09476a1e --- /dev/null +++ b/.changeset/five-phones-tease.md @@ -0,0 +1,5 @@ +--- +'layerchart': patch +--- + +[Frame] Expose `rectEl` and forward `mousedown`, `touchstart`, and `dblclick` events diff --git a/.changeset/itchy-cameras-guess.md b/.changeset/itchy-cameras-guess.md new file mode 100644 index 000000000..6d0802f88 --- /dev/null +++ b/.changeset/itchy-cameras-guess.md @@ -0,0 +1,5 @@ +--- +'layerchart': minor +--- + +Add Brush component diff --git a/.changeset/pretty-vans-exist.md b/.changeset/pretty-vans-exist.md new file mode 100644 index 000000000..0a69ef836 --- /dev/null +++ b/.changeset/pretty-vans-exist.md @@ -0,0 +1,5 @@ +--- +"layerchart": patch +--- + +[Group] Forward `dblclick` event diff --git a/.changeset/purple-tools-eat.md b/.changeset/purple-tools-eat.md new file mode 100644 index 000000000..197227d9b --- /dev/null +++ b/.changeset/purple-tools-eat.md @@ -0,0 +1,5 @@ +--- +'layerchart': minor +--- + +[ChartClipPath] Remove padding by default (opt-in with `full`) diff --git a/packages/layerchart/src/lib/components/Brush.svelte b/packages/layerchart/src/lib/components/Brush.svelte new file mode 100644 index 000000000..d1a457677 --- /dev/null +++ b/packages/layerchart/src/lib/components/Brush.svelte @@ -0,0 +1,309 @@ + + + + selectAll()} + bind:rectEl={frameEl} + /> + + {#if isActive} + + + reset()} + {...range} + /> + + + + {#if axis === 'both' || axis === 'y'} + { + yDomain[0] = yDomainMin; + dispatch('change', { xDomain, yDomain }); + }} + > + + + + + + { + yDomain[1] = yDomainMax; + }} + > + + + + + {/if} + + {#if axis === 'both' || axis === 'x'} + { + xDomain[0] = xDomainMin; + dispatch('change', { xDomain, yDomain }); + }} + > + + + + + + { + xDomain[1] = xDomainMax; + dispatch('change', { xDomain, yDomain }); + }} + > + + + + + {/if} + + + {/if} + diff --git a/packages/layerchart/src/lib/components/ChartClipPath.svelte b/packages/layerchart/src/lib/components/ChartClipPath.svelte index 319f3c0a6..670908fb8 100644 --- a/packages/layerchart/src/lib/components/ChartClipPath.svelte +++ b/packages/layerchart/src/lib/components/ChartClipPath.svelte @@ -5,15 +5,15 @@ const { width, height, padding } = getContext('LayerCake'); - /** Whether clipping should include chart padding (ex. axis) */ - export let includePadding = false; + /** Include padding area (ex. axis) */ + export let full = false; diff --git a/packages/layerchart/src/lib/components/Frame.svelte b/packages/layerchart/src/lib/components/Frame.svelte index 1be6c57ff..f1146fa9e 100644 --- a/packages/layerchart/src/lib/components/Frame.svelte +++ b/packages/layerchart/src/lib/components/Frame.svelte @@ -5,6 +5,9 @@ /** Include padding area */ export let full = false; + + /** Access underlying `` element */ + export let rectEl: SVGRectElement; diff --git a/packages/layerchart/src/lib/components/Group.svelte b/packages/layerchart/src/lib/components/Group.svelte index 481c7ba0f..282ea63f5 100644 --- a/packages/layerchart/src/lib/components/Group.svelte +++ b/packages/layerchart/src/lib/components/Group.svelte @@ -49,6 +49,7 @@ {transform} {...$$restProps} on:click + on:pointerdown on:pointerenter on:pointermove on:pointerleave diff --git a/packages/layerchart/src/lib/components/Rect.svelte b/packages/layerchart/src/lib/components/Rect.svelte index 196ba8ab7..0af826b02 100644 --- a/packages/layerchart/src/lib/components/Rect.svelte +++ b/packages/layerchart/src/lib/components/Rect.svelte @@ -50,4 +50,5 @@ on:pointermove on:pointerout on:pointerleave + on:dblclick /> diff --git a/packages/layerchart/src/lib/components/TooltipContext.svelte b/packages/layerchart/src/lib/components/TooltipContext.svelte index 8d5ef46aa..63ea7349d 100644 --- a/packages/layerchart/src/lib/components/TooltipContext.svelte +++ b/packages/layerchart/src/lib/components/TooltipContext.svelte @@ -149,6 +149,17 @@ const localX = point?.x ?? 0; const localY = point?.y ?? 0; + if ( + e.offsetX < e.currentTarget.offsetLeft || + e.offsetX > e.currentTarget.offsetLeft + e.currentTarget.offsetWidth || + e.offsetY < e.currentTarget.offsetTop || + e.offsetY > e.currentTarget.offsetTop + e.currentTarget.offsetHeight + ) { + // Ignore if within padding of chart + hideTooltip(); + return; + } + // If tooltipData not provided already (voronoi, etc), attempt to find it // TODO: When using bisect-x/y/band, values should be sorted. Tyipcally are for `x`, but not `y` (and band depends on if x or y scale) if (tooltipData == null) { @@ -316,26 +327,30 @@ } - - {#if ['bisect-x', 'bisect-y', 'bisect-band', 'quadtree'].includes(mode)} - - { - onClick({ data: $tooltip?.data }); - }} - /> - + { + onClick({ data: $tooltip?.data }); + }} + > + + + + + {:else if mode === 'voronoi'} + showTooltip(e.detail.event, e.detail.data)} @@ -348,6 +363,7 @@ /> {:else if mode === 'bounds' || mode === 'band'} + {#each rects as rect} @@ -367,10 +383,12 @@ {/each} +{:else} + {/if} {#if mode === 'quadtree' && debug} - + {#each quadtreeRects(quadtree, false) as rect} diff --git a/packages/layerchart/src/lib/components/index.ts b/packages/layerchart/src/lib/components/index.ts index 8c930a3c3..5042f5034 100644 --- a/packages/layerchart/src/lib/components/index.ts +++ b/packages/layerchart/src/lib/components/index.ts @@ -8,6 +8,7 @@ export { default as Axis } from './Axis.svelte'; export { default as Bar } from './Bar.svelte'; export { default as Bars } from './Bars.svelte'; export { default as Blur } from './Blur.svelte'; +export { default as Brush } from './Brush.svelte'; export { default as Bounds } from './Bounds.svelte'; export { default as Calendar } from './Calendar.svelte'; export { default as Canvas } from './layout/Canvas.svelte'; diff --git a/packages/layerchart/src/lib/utils/genData.ts b/packages/layerchart/src/lib/utils/genData.ts index 90705e7de..857a3cacb 100644 --- a/packages/layerchart/src/lib/utils/genData.ts +++ b/packages/layerchart/src/lib/utils/genData.ts @@ -1,4 +1,7 @@ import { addMinutes, startOfDay, startOfToday, subDays } from 'date-fns'; +import { cumsum } from 'd3-array'; +import { randomNormal } from 'd3-random'; + import { degreesToRadians, radiansToDegrees } from './math.js'; /** @@ -19,6 +22,14 @@ export function getRandomInteger(min: number, max: number, includeMax = true) { return Math.floor(Math.random() * (max - min + (includeMax ? 1 : 0)) + min); } +/** + * @see: https://observablehq.com/@d3/d3-cumsum + */ +export function randomWalk(options?: { count?: number }) { + const random = randomNormal(); + return Array.from(cumsum({ length: options?.count ?? 100 }, random)); +} + export function createSeries(options: { count?: number; min: number; diff --git a/packages/layerchart/src/routes/_NavMenu.svelte b/packages/layerchart/src/routes/_NavMenu.svelte index c95ea60c1..5ec180562 100644 --- a/packages/layerchart/src/routes/_NavMenu.svelte +++ b/packages/layerchart/src/routes/_NavMenu.svelte @@ -66,6 +66,7 @@ 'Threshold', ], Interactions: [ + 'Brush', 'Highlight', 'HitCanvas', 'Tooltip', diff --git a/packages/layerchart/src/routes/docs/components/Brush/+page.svelte b/packages/layerchart/src/routes/docs/components/Brush/+page.svelte new file mode 100644 index 000000000..d3b39c52d --- /dev/null +++ b/packages/layerchart/src/routes/docs/components/Brush/+page.svelte @@ -0,0 +1,544 @@ + + +Examples + +Styling via classes + + + + + + + + + + + + +Styling via props + + + + + + + + + + + + + + + + +Styling via slots + + + + + + + + + + + + + + + + + + + + +Integrated brush (x-axis) + + + + + + + + + + + + + + + + { + set(e.detail.xDomain); + }} + /> + + + + + + + +Integrated brush (y-axis) + + + + + + + + + + + + + + + + { + set(e.detail.yDomain); + }} + /> + + + + + + + +Integrated brush (both axis / area) + + + + + + + + + + + + + + + + { + set({ + xDomain: e.detail.xDomain, + yDomain: e.detail.yDomain, + }); + }} + /> + + + + + + + +Separate chart (clip data) + + + + + + + + + + + + + + + + + + + + + + + { + set(e.detail.xDomain); + }} + /> + + + + + + + +Separate chart (clip data: y-axis) + + + + + + + + + { + set(e.detail.yDomain); + }} + /> + + + + + + + + + + + + + + + + + + + + + +Separate chart (filter data) + + + + + + + (xDomain[0] == null || d.date >= xDomain[0]) && + (xDomain[1] == null || d.date <= xDomain[1]) + )} + x="date" + xScale={scaleTime()} + y="value" + yDomain={[0, null]} + padding={{ left: 16, bottom: 24 }} + > + + + + + + + + + + + + + + + { + set(e.detail.xDomain); + }} + /> + + + + + + + +Sync brushes with `bind:xDomain` + + + {@const colorScale = scaleOrdinal([ + 'var(--color-success-500)', + 'var(--color-info-500)', + 'var(--color-warning-500)', + 'var(--color-danger-500)', + ])} + + + {#each seriesData as data, i} + + + + + + format(v, PeriodType.Day, { variant: 'short' })} + /> + + + + + + + + + + + + + + + + + + + + {/each} + + + +Tooltip interop + + + + + + + + + + + + + + + + + { + set(e.detail.xDomain); + }} + /> + + + + {format(data.value, 'currency')} + + + + {format(data.date, PeriodType.Day)} + + + + + + + +Selection + + + + + + + + + + + {#each points as point} + {@const isSelected = + (value.xDomain[0] == null || value.xDomain[0] <= point.data.x) && + (value.xDomain[1] == null || point.data.x <= value.xDomain[1]) && + (value.yDomain[0] == null || value.yDomain[0] <= point.data.y) && + (value.yDomain[1] == null || point.data.y <= value.yDomain[1])} + + + {/each} + + + { + set({ + xDomain: e.detail.xDomain, + yDomain: e.detail.yDomain, + }); + }} + /> + + + + + diff --git a/packages/layerchart/src/routes/docs/components/Brush/+page.ts b/packages/layerchart/src/routes/docs/components/Brush/+page.ts new file mode 100644 index 000000000..e5984947e --- /dev/null +++ b/packages/layerchart/src/routes/docs/components/Brush/+page.ts @@ -0,0 +1,18 @@ +import { parse } from 'svelte-ux'; + +import api from '$lib/components/Brush.svelte?raw&sveld'; +import source from '$lib/components/Brush.svelte?raw'; +import pageSource from './+page.svelte?raw'; + +export async function load() { + return { + appleStock: await fetch('/data/examples/date/apple-stock.json').then(async (r) => + parse(await r.text()) + ), + meta: { + api, + source, + pageSource, + }, + }; +}