diff --git a/website/src/arcade/Arcade.tsx b/website/src/arcade/Arcade.tsx index d3cdb442..a7d8e2b5 100644 --- a/website/src/arcade/Arcade.tsx +++ b/website/src/arcade/Arcade.tsx @@ -13,7 +13,10 @@ import { ArcadeEditor } from "@site/src/arcade/ArcadeEditor"; import datasets from "@site/src/datasets.json"; import { ARCADE_STORAGE_KEYS } from "@site/src/arcade/consts"; import { ExamplePayload, EXAMPLES } from "@site/src/arcade/examples"; -import { ArcadeSection } from "@site/src/arcade/ArcadeSection"; +import { + ArcadeSection, + ArcadeSectionResizer, +} from "@site/src/arcade/ArcadeSection"; import { ArcadeDatasetEditor } from "@site/src/arcade/ArcadeDatasetEditor"; import { ArcadeResponseView } from "@site/src/arcade/ArcadeResponseView"; import lzstring from "lz-string"; @@ -22,6 +25,7 @@ import { DefaultToastOptions, Toaster } from "react-hot-toast"; import { HiPlay } from "react-icons/hi"; import clsx from "clsx"; import { ArcadeActionList } from "@site/src/arcade/ArcadeActionList"; +import Abacus from "@site/src/arcade/abacus"; export function Arcade() { const [ @@ -89,12 +93,49 @@ export function Arcade() { } }, []); + const abacus = React.useMemo(() => { + return new Abacus({ + dividers: 2, + dividerWidth: 18, + }); + }, []); + + React.useEffect( + () => + abacus.onBoundChange(() => { + abacus.container.style.setProperty( + "--w-data", + `${abacus.getNormalizedSectionWidth(0) * 100}%` + ); + abacus.container.style.setProperty( + "--w-query", + `${abacus.getNormalizedSectionWidth(1) * 100}%` + ); + abacus.container.style.setProperty( + "--w-result", + `${abacus.getNormalizedSectionWidth(2) * 100}%` + ); + }), + [] + ); + return (
-
+
} + onExpandSection={() => abacus.expand(0)} + onContractSection={abacus.reset} + style={{ + width: "var(--w-data)", + }} >
+ { + abacus.updateDivider(0, x); + }} + style={{ + width: "var(--w-resizer)", + }} + /> abacus.expand(1)} + onContractSection={abacus.reset} + style={{ + width: "var(--w-query)", + }} headerRightContent={
+ { + abacus.updateDivider(1, x); + }} + style={{ + width: "var(--w-resizer)", + }} + /> abacus.expand(2)} + onContractSection={abacus.reset} + style={{ + width: "var(--w-result)", + }} >
-
+        
           {query || "..."}
         
diff --git a/website/src/arcade/ArcadeSection.tsx b/website/src/arcade/ArcadeSection.tsx index 0daf22e6..65c20f16 100644 --- a/website/src/arcade/ArcadeSection.tsx +++ b/website/src/arcade/ArcadeSection.tsx @@ -1,26 +1,62 @@ import * as React from "react"; +import { RiExpandLeftRightFill, RiContractLeftRightFill } from "react-icons/ri"; + +import clsx from "clsx"; type ArcadeSectionProps = { title: string; subtitle?: string; headerRightContent?: JSX.Element; + style?: React.CSSProperties; + onContractSection?: () => void; + onExpandSection?: () => void; }; export function ArcadeSection({ title, subtitle, headerRightContent, + style, + onContractSection, + onExpandSection, children, }: React.PropsWithChildren) { return ( -
-
+
+
+ + +
+
-
+
{title}
{subtitle && ( -
+
{subtitle}
)} @@ -31,3 +67,54 @@ export function ArcadeSection({
); } + +export function ArcadeSectionResizer({ + onMouseDrag, + style, +}: { + onMouseDrag?: (x: number) => void; + style?: React.CSSProperties; +}) { + const isDragging = React.useRef(false); + const offset = React.useRef(0); + const mouseDragHandler = React.useRef(onMouseDrag); + mouseDragHandler.current = onMouseDrag; + + React.useEffect(() => { + const handleMouseMove = (e: MouseEvent) => { + if (!isDragging.current) return; + mouseDragHandler.current?.(e.clientX - offset.current); + }; + const handleMouseUp = () => { + if (!isDragging.current) return; + offset.current = 0; + isDragging.current = false; + document.body.style.userSelect = "auto"; + }; + + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + + return () => { + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + }; + }, []); + + return ( +
{ + offset.current = + e.clientX - e.currentTarget.getBoundingClientRect().left; + isDragging.current = true; + document.body.style.userSelect = "none"; + }} + /> + ); +} diff --git a/website/src/arcade/abacus.ts b/website/src/arcade/abacus.ts new file mode 100644 index 00000000..24ce93fb --- /dev/null +++ b/website/src/arcade/abacus.ts @@ -0,0 +1,209 @@ +type Range = [number, number]; +type Op = (v: number) => number; + +function animate(duration: number, ops: Op[] = []) { + const loop = (cb: (v: number) => void, start: number) => { + const delta = performance.now() - start; + + if (delta > duration) return; + + const p = progress(delta, [0, duration]); + cb(ops.reduce((acc, op) => op(acc), p)); + requestAnimationFrame(() => loop(cb, start)); + }; + + return { + pipe: (op: Op) => { + return animate(duration, [...ops, op]); + }, + start: (cb: (v: number) => void) => { + loop(cb, performance.now()); + }, + }; +} + +function clamp(v: number, range: Range) { + return Math.min(Math.max(range[0], v), range[1]); +} + +function lerp(progress: number, range: Range) { + return (1 - progress) * range[0] + progress * range[1]; +} + +function progress(v: number, range: Range) { + return (v - range[0]) / (range[1] - range[0]); +} + +function ease(t: number) { + return -Math.pow(2, -10 * t) + 1; +} + +export default class Abacus { + animation = animate(600).pipe(ease); + bounds: number[] | null = null; + container: HTMLElement | null = null; + containerRect: DOMRectReadOnly | null = null; + dividerWidth = 18; + initialSectionCssWidth: string | null = null; + listeners: Set<(bounds: number[]) => void> = new Set(); + numDividers: number | null = null; + observer: ResizeObserver | null = null; + + constructor({ + dividers, + dividerWidth, + }: { + dividers: number; + dividerWidth?: number; + }) { + if (dividerWidth) this.dividerWidth = dividerWidth; + this.numDividers = dividers; + const numSections = dividers + 1; + this.initialSectionCssWidth = `calc(${(1 / numSections) * 100}% - ${ + this.dividerWidth * (this.numDividers / numSections) + }px)`; + } + + boundToRealWorldSpace = (boundIndex: number) => { + return lerp(this.bounds[boundIndex], [ + this.containerRect.left, + this.containerRect.right, + ]); + }; + + createEvenSplitBounds = () => { + // numBounds = numDividers + frameLeft + frameRight + const numBounds = this.numDividers + 2; + + // bounds represented by array of x coord normalized by containerRect.width + return Array.from({ length: numBounds }, (_, boundIndex) => { + if (boundIndex === 0) return 0; + + const numSections = this.numDividers + 1; + const sectionWidth = + (1 - this.numDividers * this.getNormalizedDividerWidth()) / numSections; + + return sectionWidth * boundIndex; + }); + }; + + emitBoundChange = () => { + this.listeners.forEach((listener) => listener(this.bounds)); + }; + + expand = (sectionIndex: number) => { + const leftBoundIndex = sectionIndex; + const leftDividerIndex = leftBoundIndex - 1; + const leftDividerRange: Range = [ + this.boundToRealWorldSpace(leftBoundIndex), + 0, + ]; + + const rightBoundIndex = sectionIndex + 1; + const rightDividerIndex = rightBoundIndex - 1; + const rightDividerRange: Range = [ + this.boundToRealWorldSpace(rightBoundIndex), + this.containerRect.right, + ]; + + this.animation.start((p) => { + this.updateDivider(leftDividerIndex, lerp(p, leftDividerRange)); + this.updateDivider(rightDividerIndex, lerp(p, rightDividerRange)); + }); + }; + + reset = () => { + const evenSplitBounds = this.createEvenSplitBounds(); + const dividerRanges = this.bounds.map( + (bound, i) => [bound, evenSplitBounds[i]] as Range + ); + + this.animation.start((p) => { + this.bounds = dividerRanges.map((dividerRange) => lerp(p, dividerRange)); + this.emitBoundChange(); + }); + }; + + getNormalizedBoundRange = (): Range => { + return [0, 1 - this.getNumDividers() * this.getNormalizedDividerWidth()]; + }; + + getNormalizedDividerWidth = () => { + return progress(this.dividerWidth, [0, this.containerRect?.width || 1200]); + }; + + getNormalizedSectionWidth = (sectionIndex: number) => { + const lbi = sectionIndex; + const rbi = sectionIndex + 1; + + return clamp( + this.bounds[rbi] - this.bounds[lbi], + this.getNormalizedBoundRange() + ); + }; + + getNumDividers = () => { + return this.bounds.length - 2; + }; + + onBoundChange = (listener: (bounds?: number[]) => void): (() => void) => { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + }; + + setContainer = (el: T | null) => { + // instanciate + if (!this.observer) { + this.observer = new ResizeObserver((entries) => { + for (const entry of entries) { + this.containerRect = entry.contentRect; + if (!this.bounds) { + this.setInitialBounds(); + } + } + }); + } + + // cleanup + if (this.container) this.observer.unobserve(this.container); + + // update + if (!el) return; + this.container = el; + this.observer.observe(this.container); + }; + + setInitialBounds = () => { + this.bounds = this.createEvenSplitBounds(); + }; + + updateDivider = (dividerIndex: number, clientX: number) => { + if (!this.bounds) { + this.setInitialBounds(); + } + + if (0 > dividerIndex || dividerIndex > this.numDividers - 1) return; + + const p = + progress(clientX, [this.containerRect.left, this.containerRect.right]) - + dividerIndex * this.getNormalizedDividerWidth(); + + // update bound + const boundIndex = dividerIndex + 1; + const clampedBound = clamp(p, this.getNormalizedBoundRange()); + this.bounds[boundIndex] = clampedBound; + + // ensure bounds don't intersect, but persist update + this.bounds = this.bounds.map((bound, i, arr) => { + if (0 >= i || i >= arr.length - 1) return bound; + if (boundIndex === i) return bound; + + const lbi = i - 1; + const rbi = i + 1; + + return clamp(bound, [arr[lbi], arr[rbi]]); + }); + + this.emitBoundChange(); + }; +}