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

Brush component #175

Merged
merged 40 commits into from
Jun 10, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
bf50ac2
Brush WIP
techniq Apr 11, 2024
91dc052
[ChartClipPath] Rename `includePadding` to `full` to match `<Frame>`
techniq Apr 13, 2024
90b889b
[Frame] Expose `rectEl` and forward `mousedown` and `touchstart` events
techniq Apr 13, 2024
87756a5
[Brush] Forward `mousedown` and `touchstart` events
techniq Apr 13, 2024
0777123
[Brush] Use SVG/rect instaed of HTML/div
techniq Apr 13, 2024
b3c3cb0
Forward `dblclick` event
techniq Apr 13, 2024
68b1ac8
[Brush] Support double clicking on frame to select all, double clicki…
techniq Apr 13, 2024
9620b6f
[Brush] Use domain values for min/max instead of percentages to enabl…
techniq Apr 13, 2024
0e386a5
Cleanup
techniq Apr 13, 2024
abbf556
Fix contacting Date + Number resulting in string
techniq Apr 14, 2024
1270477
Simplify overall domain min/max
techniq Apr 14, 2024
2227074
Allow double clicking on handles to set related min/max
techniq Apr 14, 2024
9bf657f
Change `min`/`max` ot single `xDomain` to easily support binding
techniq Apr 14, 2024
9cfe1a5
Update Brush examples to use Svelte UX's <State> to localize xDomain …
techniq Apr 14, 2024
3c84c0e
Add randomWalk() util
techniq Apr 14, 2024
b2cc2f4
Add sync'd brush example (using <Brush bind:xDomain>)
techniq Apr 14, 2024
939239b
Cleanup
techniq Apr 14, 2024
e140cc4
[Brush] Use pointer events
techniq May 1, 2024
4f91e07
[Group] Re-add `pointerdown` (lost from merge conflict)
techniq Jun 1, 2024
2d9b3fb
Cleanup old touch handling after switching to pointer events
techniq Jun 1, 2024
2fcbfee
Add brusStart/brushEnd events, clearOnEnd prop, and integrated brush …
techniq Jun 1, 2024
f7e92ed
[Brush] Add `y` and `both` (area) along with `x` (default)
techniq Jun 2, 2024
9cbd049
Rename `reset` => `createRange` and `clear` => `reset`
techniq Jun 2, 2024
bb950fc
[Brush] Reset back to original domain (not just null/null). Add top/…
techniq Jun 3, 2024
06286d1
Add TODO
techniq Jun 3, 2024
eec6924
Remove `yNice` for more deterministic results
techniq Jun 3, 2024
0bda07e
Set `isActive` reactively to handle cases where `xDomain`/`yDomain` a…
techniq Jun 3, 2024
1114b2b
Do not set original xDomain example outside of data
techniq Jun 3, 2024
3335606
Add Tooltip interop WIP
techniq Jun 3, 2024
4ef6f10
Disable user select to remove text selection on drag
techniq Jun 5, 2024
d6069b6
Fix quadtree debug not allowing tooltip interactions
techniq Jun 7, 2024
c45bdf4
Allow pointer events from Brush to bubble up to TooltipContext to all…
techniq Jun 7, 2024
2f822b0
Move Tooltip interop example to bottom
techniq Jun 7, 2024
e95d372
Simplify markup
techniq Jun 8, 2024
c1f61d8
Support passing classes and props to underlying range/handles/etc and…
techniq Jun 8, 2024
9b95abf
Dispatch change event when double clicking on handle to expand to min…
techniq Jun 8, 2024
3678d71
Add selection example
techniq Jun 9, 2024
adfbedf
Refine pattern styling
techniq Jun 9, 2024
19981c8
[Brush] Support passing "range" and "handle" slots for full control. …
techniq Jun 10, 2024
41ad80e
Update changeset
techniq Jun 10, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/five-phones-tease.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'layerchart': patch
---

[Frame] Expose `rectEl` and forward `mousedown`, `touchstart`, and `dblclick` events
5 changes: 5 additions & 0 deletions .changeset/itchy-cameras-guess.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'layerchart': minor
---

Add Brush component
5 changes: 5 additions & 0 deletions .changeset/pretty-vans-exist.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"layerchart": patch
---

[Group] Forward `dblclick` event
5 changes: 5 additions & 0 deletions .changeset/purple-tools-eat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'layerchart': minor
---

[ChartClipPath] Remove padding by default (opt-in with `full`)
309 changes: 309 additions & 0 deletions packages/layerchart/src/lib/components/Brush.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,309 @@
<script lang="ts">
import { createEventDispatcher, getContext } from 'svelte';
import type { SVGAttributes } from 'svelte/elements';
import { extent } from 'd3-array';

import { clamp, cls } from 'svelte-ux';
import Frame from './Frame.svelte';
import { localPoint } from '$lib/utils/event.js';
import Group from './Group.svelte';

const { xScale, yScale, width, height, padding } = getContext('LayerCake');

const dispatch = createEventDispatcher<{
change: { xDomain?: [any, any]; yDomain?: [any, any] };
brushStart: { xDomain?: [any, any]; yDomain?: [any, any] };
brushEnd: { xDomain?: [any, any]; yDomain?: [any, any] };
}>();

/** Axis to apply brushing */
export let axis: 'x' | 'y' | 'both' = 'x';

/** Size of draggable handles (width/height) */
export let handleSize = 5;

/** Only show range while actively brushing. Useful with `brushEnd` event */
export let resetOnEnd = false;

export let xDomain: [number | null, number | null] = $xScale.domain();
export let yDomain: [number | null, number | null] = $yScale.domain();

// Capture original domains for reset()
const originalXDomain = $xScale.domain();
const originalYDomain = $yScale.domain();

$: [xDomainMin, xDomainMax] = extent($xScale.domain());
$: [yDomainMin, yDomainMax] = extent($yScale.domain());

/** Attributes passed to range <rect> element */
export let range: SVGAttributes<SVGRectElement> | undefined = undefined;

/** Attributes passed to handle <rect> elements */
export let handle: SVGAttributes<SVGRectElement> | undefined = undefined;

export let classes: {
root?: string;
frame?: string;
range?: string;
handle?: string;
} = {};

let frameEl: SVGRectElement;

function handler(
fn: (
start: {
xDomain: [number, number];
yDomain: [number, number];
value: { x: number; y: number };
},
value: { x: number; y: number }
) => void
) {
return (e: PointerEvent) => {
const start = {
xDomain: [xDomain[0] ?? xDomainMin, xDomain[1] ?? xDomainMax],
yDomain: [yDomain[0] ?? yDomainMin, yDomain[1] ?? yDomainMax],
value: {
x: $xScale.invert(localPoint(frameEl, e)?.x - $padding.left),
y: $yScale.invert(localPoint(frameEl, e)?.y - $padding.top),
},
};

dispatch('brushStart', { xDomain, yDomain });

const onPointerMove = (e: PointerEvent) => {
fn(start, {
x: $xScale.invert(localPoint(frameEl, e)?.x - $padding.left),
y: $yScale.invert(localPoint(frameEl, e)?.y - $padding.top),
});

// if (xDomain[0] === xDomain[1] || yDomain[0] === yDomain[1]) {
// // Ignore?
// // TODO: What about when using `x` or `y` axis?
// } else {
dispatch('change', { xDomain, yDomain });
// }
};

const onPointerUp = (e: PointerEvent) => {
if (e.target === frameEl) {
reset();
dispatch('change', { xDomain, yDomain });
}

dispatch('brushEnd', { xDomain, yDomain });

if (resetOnEnd) {
reset();
}

window.removeEventListener('pointermove', onPointerMove);
window.removeEventListener('pointerup', onPointerUp);
};

window.addEventListener('pointermove', onPointerMove);
window.addEventListener('pointerup', onPointerUp);
};
}

const createRange = handler((start, value) => {
isActive = true;

xDomain = [
clamp(Math.min(start.value.x, value.x), xDomainMin, xDomainMax),
clamp(Math.max(start.value.x, value.x), xDomainMin, xDomainMax),
];

yDomain = [
clamp(Math.min(start.value.y, value.y), yDomainMin, yDomainMax),
clamp(Math.max(start.value.y, value.y), yDomainMin, yDomainMax),
];
});

const adjustRange = handler((start, value) => {
const dx = clamp(
value.x - start.value.x,
xDomainMin - start.xDomain[0],
xDomainMax - start.xDomain[1]
);
xDomain = [Number(start.xDomain[0]) + dx, Number(start.xDomain[1]) + dx];

const dy = clamp(
value.y - start.value.y,
yDomainMin - start.yDomain[0],
yDomainMax - start.yDomain[1]
);
yDomain = [Number(start.yDomain[0]) + dy, Number(start.yDomain[1]) + dy];
});

const adjustBottom = handler((start, value) => {
yDomain = [
clamp(value.y > start.yDomain[1] ? start.yDomain[1] : value.y, yDomainMin, yDomainMax),
clamp(value.y > start.yDomain[1] ? value.y : start.yDomain[1], yDomainMin, yDomainMax),
];
});

const adjustTop = handler((start, value) => {
yDomain = [
clamp(value.y < start.yDomain[1] ? value.y : start.yDomain[0], yDomainMin, yDomainMax),
clamp(value.y < start.yDomain[1] ? start.yDomain[0] : value.y, yDomainMin, yDomainMax),
];
});

const adjustLeft = handler((start, value) => {
xDomain = [
clamp(value.x > start.xDomain[1] ? start.xDomain[1] : value.x, xDomainMin, xDomainMax),
clamp(value.x > start.xDomain[1] ? value.x : start.xDomain[1], xDomainMin, xDomainMax),
];
});

const adjustRight = handler((start, value) => {
xDomain = [
clamp(value.x < start.xDomain[0] ? value.x : start.xDomain[0], xDomainMin, xDomainMax),
clamp(value.x < start.xDomain[0] ? start.xDomain[0] : value.x, xDomainMin, xDomainMax),
];
});

function reset() {
isActive = false;

xDomain = originalXDomain;
yDomain = originalYDomain;
}

function selectAll() {
xDomain = [xDomainMin, xDomainMax];
yDomain = [yDomainMin, yDomainMax];
}

$: top = $yScale(yDomain[1]);
$: bottom = $yScale(yDomain[0]);
$: left = $xScale(xDomain[0]);
$: right = $xScale(xDomain[1]);

$: rangeTop = axis === 'both' || axis === 'y' ? top : 0;
$: rangeLeft = axis === 'both' || axis === 'x' ? left : 0;
$: rangeWidth = axis === 'both' || axis === 'x' ? right - left : $width;
$: rangeHeight = axis === 'both' || axis === 'y' ? bottom - top : $height;

// Set reactively to handle cases where xDomain/yDomain are set externally (ex. `bind:xDomain`)
$: isActive =
xDomain[0]?.valueOf() !== originalXDomain[0]?.valueOf() ||
xDomain[1]?.valueOf() !== originalXDomain[1]?.valueOf() ||
yDomain[0]?.valueOf() !== originalYDomain[0]?.valueOf() ||
yDomain[1]?.valueOf() !== originalYDomain[1]?.valueOf();
</script>

<g class={cls('Brush select-none', classes.root, $$props.class)}>
<Frame
class={cls('frame', 'fill-transparent', classes.frame)}
on:pointerdown={createRange}
on:dblclick={() => selectAll()}
bind:rectEl={frameEl}
/>

{#if isActive}
<Group x={rangeLeft} y={rangeTop} class="range">
<slot name="range" {rangeWidth} {rangeHeight}>
<rect
width={rangeWidth}
height={rangeHeight}
class={cls(
'cursor-move select-none',
range?.fill == null && 'fill-surface-content/10',
classes.range
)}
on:pointerdown={adjustRange}
on:dblclick={() => reset()}
{...range}
/>
</slot>
</Group>

{#if axis === 'both' || axis === 'y'}
<Group
x={rangeLeft}
y={rangeTop}
class="handle top"
on:pointerdown={adjustTop}
on:dblclick={() => {
yDomain[0] = yDomainMin;
dispatch('change', { xDomain, yDomain });
}}
>
<slot name="handle" edge="top" {rangeWidth} {rangeHeight}>
<rect
width={rangeWidth}
height={handleSize}
class={cls('fill-transparent cursor-ns-resize select-none', classes.handle)}
{...handle}
/>
</slot>
</Group>

<Group
x={rangeLeft}
y={bottom - handleSize + 1}
class="handle bottom"
on:pointerdown={adjustBottom}
on:dblclick={() => {
yDomain[1] = yDomainMax;
}}
>
<slot name="handle" edge="bottom" {rangeWidth} {rangeHeight}>
<rect
width={rangeWidth}
height={handleSize}
class={cls('fill-transparent cursor-ns-resize select-none', classes.handle)}
{...handle}
/>
</slot>
</Group>
{/if}

{#if axis === 'both' || axis === 'x'}
<Group
x={rangeLeft}
y={rangeTop}
class="handle left"
on:pointerdown={adjustLeft}
on:dblclick={() => {
xDomain[0] = xDomainMin;
dispatch('change', { xDomain, yDomain });
}}
>
<slot name="handle" edge="left" {rangeWidth} {rangeHeight}>
<rect
width={handleSize}
height={rangeHeight}
class={cls('fill-transparent cursor-ew-resize select-none', classes.handle)}
{...handle}
/>
</slot>
</Group>

<Group
x={right - handleSize + 1}
y={rangeTop}
class="handle right"
on:pointerdown={adjustRight}
on:dblclick={() => {
xDomain[1] = xDomainMax;
dispatch('change', { xDomain, yDomain });
}}
>
<slot name="handle" edge="right" {rangeWidth} {rangeHeight}>
<rect
width={handleSize}
height={rangeHeight}
class={cls('fill-transparent cursor-ew-resize select-none', classes.handle)}
{...handle}
/>
</slot>
</Group>
{/if}

<!-- TODO: Add diagonal/corner handles -->
{/if}
</g>
12 changes: 6 additions & 6 deletions packages/layerchart/src/lib/components/ChartClipPath.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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;
</script>

<RectClipPath
x={includePadding ? -$padding.left : 0}
y={includePadding ? -$padding.top : 0}
width={$width + (includePadding ? $padding.left + $padding.right : 0)}
height={$height + (includePadding ? $padding.top + $padding.bottom : 0)}
x={full ? -$padding.left : 0}
y={full ? -$padding.top : 0}
width={$width + (full ? $padding.left + $padding.right : 0)}
height={$height + (full ? $padding.top + $padding.bottom : 0)}
on:click
{...$$restProps}
>
Expand Down
6 changes: 6 additions & 0 deletions packages/layerchart/src/lib/components/Frame.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@

/** Include padding area */
export let full = false;

/** Access underlying `<rect>` element */
export let rectEl: SVGRectElement;
</script>

<rect
Expand All @@ -13,5 +16,8 @@
width={$width + (full ? $padding.left + $padding.right : 0)}
height={$height + (full ? $padding.top + $padding.bottom : 0)}
on:click
on:pointerdown
on:dblclick
bind:this={rectEl}
{...$$restProps}
/>
1 change: 1 addition & 0 deletions packages/layerchart/src/lib/components/Group.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
{transform}
{...$$restProps}
on:click
on:pointerdown
on:pointerenter
on:pointermove
on:pointerleave
Expand Down
1 change: 1 addition & 0 deletions packages/layerchart/src/lib/components/Rect.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -50,4 +50,5 @@
on:pointermove
on:pointerout
on:pointerleave
on:dblclick
/>
Loading