diff --git a/packages/layerchart/package.json b/packages/layerchart/package.json index 8bb6e7e16..5ddac79b1 100644 --- a/packages/layerchart/package.json +++ b/packages/layerchart/package.json @@ -87,7 +87,7 @@ "@layerstack/svelte-actions": "1.0.1-next.14", "@layerstack/svelte-state": "0.1.0-next.19", "@layerstack/tailwind": "2.0.0-next.17", - "@layerstack/utils": "2.0.0-next.14", + "@layerstack/utils": "2.0.0-next.15", "d3-array": "^3.2.4", "d3-color": "^3.1.0", "d3-delaunay": "^6.0.4", diff --git a/packages/layerchart/src/lib/components/BrushContext.svelte b/packages/layerchart/src/lib/components/BrushContext.svelte index 8706b1de3..9064ddba6 100644 --- a/packages/layerchart/src/lib/components/BrushContext.svelte +++ b/packages/layerchart/src/lib/components/BrushContext.svelte @@ -1,47 +1,26 @@ @@ -142,23 +120,26 @@ import { clamp, localPoint } from '@layerstack/utils'; import { cls } from '@layerstack/tailwind'; import { Logger } from '@layerstack/utils'; + import type { NonNullArray } from 'layerchart/utils/types.js'; - import { scaleInvert, type DomainType } from '../utils/scales.svelte.js'; + import { scaleInvert } from '../utils/scales.svelte.js'; import { add } from '../utils/math.js'; import type { HTMLAttributes } from 'svelte/elements'; import { getChartContext } from '$lib/contexts/chart.js'; - import type { Snippet } from 'svelte'; const ctx = getChartContext(); let { + // xDomain, + // yDomain, + x, + y, brushContext: brushContextProp = $bindable(), + axis = 'x', handleSize = 5, resetOnEnd = false, ignoreResetClick = false, - xDomain: xDomain, - yDomain: yDomain, mode = 'integrated', disabled = false, range = {}, @@ -173,89 +154,59 @@ let rootEl = $state(); - if (xDomain === undefined) { - xDomain = ctx.xScale.domain(); - } - if (yDomain === undefined) { - yDomain = ctx.yScale.domain(); - } - - $effect.pre(() => { - if (xDomain !== undefined) return; - xDomain = ctx.xScale.domain(); + const brushState = new BrushState(ctx, { x, y, axis }); + + // if (xDomain === undefined) { + // xDomain = ctx.xScale.domain(); + // } + // if (yDomain === undefined) { + // yDomain = ctx.yScale.domain(); + // } + + // $effect.pre(() => { + // if (xDomain !== undefined) return; + // xDomain = ctx.xScale.domain(); + // }); + + // $effect.pre(() => { + // if (yDomain !== undefined) return; + // yDomain = ctx.yScale.domain(); + // }); + + // const ogXDomain = xDomain; + // const ogYDomain = yDomain; + // const originalXDomain = ctx.config.xDomain; + // const originalYDomain = ctx.config.yDomain; + + // const xDomainMinMax = $derived(extent(ctx.xScale.domain()) as [number, number]); + // const xDomainMin = $derived(xDomainMinMax[0]); + // const xDomainMax = $derived(xDomainMinMax[1]); + const [xDomainMin, xDomainMax] = $derived(ctx.xScale.domain()); + + // const yDomainMinMax = $derived(extent(ctx.yScale.domain()) as [number, number]); + // const yDomainMin = $derived(yDomainMinMax[0]); + // const yDomainMax = $derived(yDomainMinMax[1]); + const [yDomainMin, yDomainMax] = $derived(ctx.yScale.domain()); + + $effect(() => { + brushState.handleSize = handleSize; }); - $effect.pre(() => { - if (yDomain !== undefined) return; - yDomain = ctx.yScale.domain(); - }); - - const ogXDomain = xDomain; - const ogYDomain = yDomain; - const originalXDomain = ctx.config.xDomain; - const originalYDomain = ctx.config.yDomain; - - const xDomainMinMax = $derived(extent(ctx.xScale.domain()) as [number, number]); - const xDomainMin = $derived(xDomainMinMax[0]); - const xDomainMax = $derived(xDomainMinMax[1]); - - const yDomainMinMax = $derived(extent(ctx.yScale.domain()) as [number, number]); - const yDomainMin = $derived(yDomainMinMax[0]); - const yDomainMax = $derived(yDomainMinMax[1]); - - const top = $derived(ctx.yScale(yDomain?.[1])); - const bottom = $derived(ctx.yScale(yDomain?.[0])); - const left = $derived(ctx.xScale(xDomain?.[0])); - const right = $derived(ctx.xScale(xDomain?.[1])); - - const _range = $derived({ - x: axis === 'both' || axis === 'x' ? left : 0, - y: axis === 'both' || axis === 'y' ? top : 0, - width: axis === 'both' || axis === 'x' ? right - left : ctx.width, - height: axis === 'both' || axis === 'y' ? bottom - top : ctx.height, - }); - - let isActive = $state(false); - - const brushContext = { - get xDomain() { - return xDomain!; - }, - set xDomain(v: DomainType) { - xDomain = v; - }, - get yDomain() { - return yDomain!; - }, - set yDomain(v: DomainType) { - yDomain = v; - }, - get isActive() { - return isActive; - }, - set isActive(v: boolean) { - isActive = v; - }, - get range() { - return _range; - }, - get handleSize() { - return handleSize; - }, - }; - - brushContextProp = brushContext; + // brushContextProp = brushContext; + brushContextProp = brushState; - setBrushContext(brushContext); + // setBrushContext(brushContext); + setBrushContext(brushState); const logger = new Logger('BrushContext'); const RESET_THRESHOLD = 1; // size of pointer delta to ignore function handler( + /** Callback on pointer move */ fn: ( start: { - xDomain: [number, number]; - yDomain: [number, number]; + x: NonNullArray; + y: NonNullArray; value: { x: number; y: number }; }, value: { x: number; y: number } @@ -283,15 +234,21 @@ } const start = { - xDomain: [xDomain?.[0] ?? xDomainMin, xDomain?.[1] ?? xDomainMax] as [number, number], - yDomain: [yDomain?.[0] ?? yDomainMin, yDomain?.[1] ?? yDomainMax] as [number, number], + x: [ + brushState.x[0] ?? ctx.xScale.domain()[0], + brushState.x[1] ?? ctx.xScale.domain()[1], + ] as Parameters[0]['x'], + y: [ + brushState.y[0] ?? ctx.yScale.domain()[0], + brushState.y[1] ?? ctx.yScale.domain()[1], + ] as Parameters[0]['y'], value: { x: scaleInvert(ctx.xScale, startPoint?.x ?? 0), y: scaleInvert(ctx.yScale, startPoint?.y ?? 0), }, }; - onBrushStart({ xDomain, yDomain }); + onBrushStart({ brush: brushState }); const onPointerMove = (e: PointerEvent) => { const currentPoint = localPoint(e, rootEl); @@ -300,7 +257,7 @@ y: scaleInvert(ctx.yScale, currentPoint?.y ?? 0), }); - onChange({ xDomain, yDomain }); + onChange({ brush: brushState }); }; const onPointerUp = (e: PointerEvent) => { @@ -315,8 +272,8 @@ if ( (isClickOutside && xPointDelta < RESET_THRESHOLD && yPointDelta < RESET_THRESHOLD) || - _range.width < RESET_THRESHOLD || - _range.height < RESET_THRESHOLD + brushState.range.width < RESET_THRESHOLD || + brushState.range.height < RESET_THRESHOLD ) { // Clicked on frame, or pointer delta was less than threshold (default: 1px) if (ignoreResetClick) { @@ -324,24 +281,24 @@ } else { logger.debug('resetting due to frame click'); reset(); - onChange({ xDomain, yDomain }); + onChange({ brush: brushState }); } } else { logger.debug('drag end', { target: e.target, xPointDelta, yPointDelta, - rangeWidth: _range.width, - rangeHeight: _range.height, + rangeWidth: brushState.range.width, + rangeHeight: brushState.range.height, }); } - onBrushEnd({ xDomain, yDomain }); + onBrushEnd({ brush: brushState }); if (resetOnEnd) { if (ignoreResetClick) { // Still hide brush, but do not reset domain - brushContext.isActive = false; + brushState.active = false; } else { reset(); } @@ -358,109 +315,100 @@ const createRange = handler((start, value) => { logger.debug('createRange'); - brushContext.isActive = true; + brushState.active = true; - xDomain = [ - // @ts-expect-error + brushState.x = [ clamp(min([start.value.x, value.x]), xDomainMin, xDomainMax), - // @ts-expect-error clamp(max([start.value.x, value.x]), xDomainMin, xDomainMax), ]; // xDomain = [start.value.x, value.x]; - yDomain = [ - // @ts-expect-error + brushState.y = [ clamp(min([start.value.y, value.y]), yDomainMin, yDomainMax), - // @ts-expect-error clamp(max([start.value.y, value.y]), yDomainMin, yDomainMax), ]; }); const adjustRange = handler((start, value) => { logger.debug('adjustRange'); - const dx = clamp( - value.x - start.value.x, - xDomainMin - start.xDomain[0], - xDomainMax - start.xDomain[1] - ); - xDomain = [add(start.xDomain[0], dx), add(start.xDomain[1], dx)]; - - const dy = clamp( - value.y - start.value.y, - yDomainMin - start.yDomain[0], - yDomainMax - start.yDomain[1] - ); - yDomain = [add(start.yDomain[0], dy), add(start.yDomain[1], dy)]; + const dx = clamp(value.x - start.value.x, xDomainMin - +start.x[0], xDomainMax - +start.x[1]); + brushState.x = [add(start.x[0], dx), add(start.x[1], dx)]; + + const dy = clamp(value.y - start.value.y, yDomainMin - +start.y[0], yDomainMax - +start.y[1]); + brushState.y = [add(start.y[0], dy), add(start.y[1], dy)]; }); const adjustTop = handler((start, value) => { logger.debug('adjustTop'); - yDomain = [ - clamp(value.y < start.yDomain[0] ? value.y : start.yDomain[0], yDomainMin, yDomainMax), - clamp(value.y < start.yDomain[0] ? start.yDomain[0] : value.y, yDomainMin, yDomainMax), + brushState.y = [ + clamp(value.y < +start.y[0] ? value.y : start.y[0], yDomainMin, yDomainMax), + clamp(value.y < +start.y[0] ? start.y[0] : value.y, yDomainMin, yDomainMax), ]; }); const adjustBottom = handler((start, value) => { logger.debug('adjustBottom'); - 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), + brushState.y = [ + clamp(value.y > +start.y[1] ? start.y[1] : value.y, yDomainMin, yDomainMax), + clamp(value.y > +start.y[1] ? value.y : start.y[1], yDomainMin, yDomainMax), ]; }); const adjustLeft = handler((start, value) => { logger.debug('adjustLeft'); - 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), + brushState.x = [ + clamp(value.x > +start.x[1] ? start.x[1] : value.x, xDomainMin, xDomainMax), + clamp(value.x > +start.x[1] ? value.x : start.x[1], xDomainMin, xDomainMax), ]; }); const adjustRight = handler((start, value) => { logger.debug('adjustRight'); - 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), + brushState.x = [ + clamp(value.x < +start.x[0] ? value.x : start.x[0], xDomainMin, xDomainMax), + clamp(value.x < +start.x[0] ? start.x[0] : value.x, xDomainMin, xDomainMax), ]; }); function reset() { logger.debug('reset'); - brushContext.isActive = false; + brushState.active = false; - onReset({ xDomain, yDomain }); + onReset({ brush: brushState }); - xDomain = ogXDomain; - yDomain = ogYDomain; + // xDomain = ogXDomain; + // yDomain = ogYDomain; + // brushState.x = [ctx.xScale.domain()[0], ctx.xScale.domain()[1]]; + // brushState.y = [ctx.yScale.domain()[0], ctx.yScale.domain()[1]]; + brushState.x = [null, null]; + brushState.y = [null, null]; } function selectAll() { logger.debug('selectedAll'); - xDomain = [xDomainMin, xDomainMax]; - yDomain = [yDomainMin, yDomainMax]; + brushState.x = [xDomainMin, xDomainMax]; + brushState.y = [yDomainMin, yDomainMax]; } $effect.pre(() => { if (mode === 'separated') { // Set reactively to handle cases where xDomain/yDomain are set externally (ex. `bind:xDomain`) - const isXAxisActive = - xDomain?.[0]?.valueOf() !== originalXDomain?.[0]?.valueOf() || - xDomain?.[1]?.valueOf() !== originalXDomain?.[1]?.valueOf(); - - const isYAxisActive = - yDomain?.[0]?.valueOf() !== originalYDomain?.[0]?.valueOf() || - yDomain?.[1]?.valueOf() !== originalYDomain?.[1]?.valueOf(); - - const result = - axis === 'x' ? isXAxisActive : axis == 'y' ? isYAxisActive : isXAxisActive || isYAxisActive; - brushContext.isActive = result; + // TODO: Update + // const isXAxisActive = + // brushState.x[0]?.valueOf() !== originalXDomain?.[0]?.valueOf() || + // brushState.x[1]?.valueOf() !== originalXDomain?.[1]?.valueOf(); + // const isYAxisActive = + // brushState.y[0]?.valueOf() !== originalYDomain?.[0]?.valueOf() || + // brushState.y[1]?.valueOf() !== originalYDomain?.[1]?.valueOf(); + // const result = + // axis === 'x' ? isXAxisActive : axis == 'y' ? isYAxisActive : isXAxisActive || isYAxisActive; + // brushState.active = result; } }); {#if disabled} - {@render children?.({ brushContext })} + {@render children?.({ brushContext: brushState })} {:else}
- {@render children?.({ brushContext })} + {@render children?.({ brushContext: brushState })}
- {#if brushContext.isActive} + {#if brushState.active}
reset()} @@ -498,36 +446,36 @@ {#if axis === 'both' || axis === 'y'}
{ e.stopPropagation(); - if (yDomain) { - yDomain[0] = yDomainMin; - onChange({ xDomain, yDomain }); + if (brushState.y[0]) { + brushState.y[0] = ctx.yScale.domain()[0]; + onChange({ brush: brushState }); } }} >
{ e.stopPropagation(); - if (yDomain) { - yDomain[1] = yDomainMax; - onChange({ xDomain, yDomain }); + if (brushState.y[1]) { + brushState.y[1] = ctx.yScale.domain()[1]; + onChange({ brush: brushState }); } }} >
@@ -536,36 +484,36 @@ {#if axis === 'both' || axis === 'x'}
{ e.stopPropagation(); - if (xDomain) { - xDomain[0] = xDomainMin; - onChange({ xDomain, yDomain }); + if (brushState.x[0]) { + brushState.x[0] = ctx.xScale.domain()[0]; + onChange({ brush: brushState }); } }} >
{ e.stopPropagation(); - if (xDomain) { - xDomain[1] = xDomainMax; - onChange({ xDomain: xDomain, yDomain: yDomain }); + if (brushState.x[1]) { + brushState.x[1] = ctx.xScale.domain()[1]; + onChange({ brush: brushState }); } }} >
diff --git a/packages/layerchart/src/lib/components/Chart.svelte b/packages/layerchart/src/lib/components/Chart.svelte index 479e555fb..f24ef2ebe 100644 --- a/packages/layerchart/src/lib/components/Chart.svelte +++ b/packages/layerchart/src/lib/components/Chart.svelte @@ -1,53 +1,28 @@ -{@render children({ - geoContext, +{@render props.children({ + geoState, })} diff --git a/packages/layerchart/src/lib/components/charts/AreaChart.svelte b/packages/layerchart/src/lib/components/charts/AreaChart.svelte index da1b5122f..e201c832e 100644 --- a/packages/layerchart/src/lib/components/charts/AreaChart.svelte +++ b/packages/layerchart/src/lib/components/charts/AreaChart.svelte @@ -97,6 +97,7 @@ import { setTooltipMetaContext } from '../tooltip/tooltipMetaContext.js'; import DefaultTooltip from './DefaultTooltip.svelte'; import ChartAnnotations from './ChartAnnotations.svelte'; + import type { BrushDomainType } from '../../states/brush.svelte.js'; import { getSettings } from '$lib/contexts/settings.js'; const settings = getSettings(); @@ -429,10 +430,10 @@ ? { axis: 'x', resetOnEnd: true, - xDomain, + x: xDomain as BrushDomainType, ...brushProps, onBrushEnd: (e) => { - xDomain = e.xDomain; + xDomain = e.brush.x; brushProps.onBrushEnd?.(e); }, } diff --git a/packages/layerchart/src/lib/components/charts/BarChart.svelte b/packages/layerchart/src/lib/components/charts/BarChart.svelte index 7395175d9..bfab6ef69 100644 --- a/packages/layerchart/src/lib/components/charts/BarChart.svelte +++ b/packages/layerchart/src/lib/components/charts/BarChart.svelte @@ -115,6 +115,7 @@ import DefaultTooltip from './DefaultTooltip.svelte'; import ChartAnnotations from './ChartAnnotations.svelte'; import { getSettings } from '$lib/contexts/settings.js'; + import type { BrushDomainType } from '../../states/brush.svelte.js'; const settings = getSettings(); @@ -464,11 +465,18 @@ ? { axis: 'x', resetOnEnd: true, - xDomain, + x: xDomain as BrushDomainType, ...brushProps, onBrushEnd: (e) => { // TOOD: This should set xRange instead of xDomain, and/or xDomain should be all values, not just bounds of brush range - xDomain = e.xDomain; + // const values = context?.xScale.domain() ?? []; + // console.log('domain', values, e.xDomain); + // const i0 = values?.indexOf(e.xDomain[0]); + // const i1 = values?.indexOf(e.xDomain[1]); + // xDomain = values.slice(i0, i1); + + xDomain = e.brush.x; + brushProps.onBrushEnd?.(e); }, } diff --git a/packages/layerchart/src/lib/components/charts/LineChart.svelte b/packages/layerchart/src/lib/components/charts/LineChart.svelte index c4308318d..49229d956 100644 --- a/packages/layerchart/src/lib/components/charts/LineChart.svelte +++ b/packages/layerchart/src/lib/components/charts/LineChart.svelte @@ -104,6 +104,7 @@ import DefaultTooltip from './DefaultTooltip.svelte'; import ChartAnnotations from './ChartAnnotations.svelte'; import { isScaleTime } from '../../utils/scales.svelte.js'; + import type { BrushDomainType } from '../../states/brush.svelte.js'; import { getSettings } from '$lib/contexts/settings.js'; const settings = getSettings(); @@ -348,10 +349,10 @@ ? { axis: 'x', resetOnEnd: true, - xDomain, + x: xDomain as BrushDomainType, ...brushProps, onBrushEnd: (e) => { - xDomain = e.xDomain; + xDomain = e.brush.x; brushProps.onBrushEnd?.(e); }, } diff --git a/packages/layerchart/src/lib/components/charts/ScatterChart.svelte b/packages/layerchart/src/lib/components/charts/ScatterChart.svelte index be93cafe7..42df30ab2 100644 --- a/packages/layerchart/src/lib/components/charts/ScatterChart.svelte +++ b/packages/layerchart/src/lib/components/charts/ScatterChart.svelte @@ -38,8 +38,8 @@ 'radial' > & { props?: ScatterChartPropsObjProp; - yDomain?: ComponentProps['yDomain']; - yScale?: AnyScale; + // yDomain?: ComponentProps['yDomain']; + // yScale?: AnyScale; }; @@ -48,7 +48,6 @@ import { cls } from '@layerstack/tailwind'; import Axis from '../Axis.svelte'; - import BrushContext from '../BrushContext.svelte'; import Chart from '../Chart.svelte'; import ChartAnnotations from './ChartAnnotations.svelte'; import ChartClipPath from '../ChartClipPath.svelte'; @@ -66,6 +65,7 @@ import { SeriesState } from '$lib/states/series.svelte.js'; import { createLegendProps } from './utils.svelte.js'; import { getSettings } from '$lib/contexts/settings.js'; + import type { BrushDomainType } from '../../states/brush.svelte.js'; const settings = getSettings(); @@ -165,7 +165,6 @@ const activeSeries = $derived.by(() => { if (!context?.tooltip?.data) return null; - // @ts-expect-error - shh return series.find((s) => s.key === context?.tooltip.data?.seriesKey) ?? series[0]; }); @@ -241,12 +240,12 @@ ? { axis: 'both', resetOnEnd: true, - xDomain, - yDomain, + x: xDomain as BrushDomainType, + y: yDomain as BrushDomainType, ...brushProps, onBrushEnd: (e) => { - xDomain = e.xDomain; - yDomain = e.yDomain; + xDomain = e.brush.x; + yDomain = e.brush.y; brushProps.onBrushEnd?.(e); }, } diff --git a/packages/layerchart/src/lib/components/charts/types.ts b/packages/layerchart/src/lib/components/charts/types.ts index 006211504..b7a52f921 100644 --- a/packages/layerchart/src/lib/components/charts/types.ts +++ b/packages/layerchart/src/lib/components/charts/types.ts @@ -26,7 +26,7 @@ import type TooltipList from '../tooltip/TooltipList.svelte'; import type TooltipItem from '../tooltip/TooltipItem.svelte'; import type TooltipSeparator from '../tooltip/TooltipSeparator.svelte'; import type { ChartPropsWithoutHTML } from '../Chart.svelte'; -import type { ChartContextValue } from '$lib/contexts/chart.js'; +import type { ChartState } from '$lib/states/chart.svelte.js'; import type Grid from '../Grid.svelte'; import type Bars from '../Bars.svelte'; import type Pie from '../Pie.svelte'; @@ -51,7 +51,7 @@ export type SimplifiedChartSnippetProps; + context: ChartState; /** * The series of data for the chart. @@ -138,12 +138,6 @@ export type BaseChartProps< */ y?: Accessor; - xScale?: AnyScale; - /** - * The x domain to be used for the chart. - * - */ - xDomain?: ComponentProps['xDomain']; /** * Use radial instead of cartesian coordinates, mapping `x` to `angle` and `y`` to * radial. Radial lines are positioned relative to the origin, use transform @@ -152,18 +146,21 @@ export type BaseChartProps< * @default false */ radial?: boolean; + /** * The series data to be used for the chart. * * @default [{ key: 'default', value: y, color: 'var(--color-primary)' }] */ series?: SeriesData[]; + /** * The layout of the series. * * @default 'overlap' */ seriesLayout?: 'overlap' | 'stack' | 'stackExpand' | 'stackDiverging'; + /** * The axis to be used for the chart. * @@ -175,6 +172,7 @@ export type BaseChartProps< | 'y' | boolean | SimplifiedChartSnippet; + /** * The brush to be used for the chart. * @@ -195,6 +193,7 @@ export type BaseChartProps< * @default false */ labels?: ComponentProps> | boolean | ChartSnippet; + /** * The legend to be used for the chart. * @@ -261,7 +260,7 @@ export type BaseChartProps< /** * A bindable reference to the chart context. */ - context?: ChartContextValue; + context?: ChartState; children?: ChartSnippet; aboveContext?: ChartSnippet; diff --git a/packages/layerchart/src/lib/components/tooltip/Tooltip.svelte b/packages/layerchart/src/lib/components/tooltip/Tooltip.svelte index b22c6909d..6e92a8d53 100644 --- a/packages/layerchart/src/lib/components/tooltip/Tooltip.svelte +++ b/packages/layerchart/src/lib/components/tooltip/Tooltip.svelte @@ -146,7 +146,7 @@ * Optionally pass the chart's context to the tooltip to get * type inference for the data. */ - context?: ChartContextValue; + context?: ChartState; }; export type TooltipProps = TooltipPropsWithoutHTML & @@ -158,7 +158,8 @@ import { cls } from '@layerstack/tailwind'; import { isScaleBand } from '../../utils/scales.svelte.js'; - import { getChartContext, type ChartContextValue } from '$lib/contexts/chart.js'; + import { getChartContext } from '$lib/contexts/chart.js'; + import type { ChartState } from '$lib/states/chart.svelte.js'; import { getTooltipContext } from '$lib/contexts/tooltip.js'; import { createMotion, type MotionProp } from '$lib/utils/motion.svelte.js'; import { type Snippet } from 'svelte'; diff --git a/packages/layerchart/src/lib/components/tooltip/tooltipMetaContext.ts b/packages/layerchart/src/lib/components/tooltip/tooltipMetaContext.ts index 6ad4111c2..1395bd030 100644 --- a/packages/layerchart/src/lib/components/tooltip/tooltipMetaContext.ts +++ b/packages/layerchart/src/lib/components/tooltip/tooltipMetaContext.ts @@ -6,7 +6,7 @@ import { format, type FormatType, type FormatConfig } from '@layerstack/utils'; import { accessor, findRelatedData, type Accessor } from '$lib/utils/common.js'; import type { SeriesData } from '../charts/index.js'; -import type { ChartContextValue } from '$lib/contexts/chart.js'; +import type { ChartState } from '$lib/states/chart.svelte.js'; import { asAny } from '$lib/utils/types.js'; export type SimplifiedChartType = 'bar' | 'area' | 'line' | 'pie' | 'scatter'; @@ -77,7 +77,7 @@ export type TooltipPayload = { }; type BasePayloadHandlerProps = { - ctx: ChartContextValue; + ctx: ChartState; data: any; }; @@ -243,7 +243,7 @@ export function getTooltipPayload({ tooltipData, metaCtx, }: { - ctx: ChartContextValue; + ctx: ChartState; tooltipData: any; metaCtx: TooltipMetaContextValue | null; }): TooltipPayload[] { diff --git a/packages/layerchart/src/lib/contexts/chart.ts b/packages/layerchart/src/lib/contexts/chart.ts index 5a92c744b..2800a20f7 100644 --- a/packages/layerchart/src/lib/contexts/chart.ts +++ b/packages/layerchart/src/lib/contexts/chart.ts @@ -1,118 +1,25 @@ import { Context } from 'runed'; -import type { TimeInterval } from 'd3-time'; +import type { ChartState } from '$lib/states/chart.svelte.js'; +import type { AnyScale } from '$lib/utils/scales.svelte.js'; -import type { - AxisKey, - DataType, - Extents, - Nice, - Padding, - PaddingArray, - XRangeWithScale, - YRangeWithScale, -} from '$lib/utils/types.js'; -import { type AnyScale, type DomainType } from '$lib/utils/scales.svelte.js'; +export type { ChartState }; -import type { GeoContextValue } from '$lib/contexts/geo.js'; -import type { TooltipContextValue } from '$lib/contexts/tooltip.js'; -import type { TransformContextValue } from '$lib/contexts/transform.js'; -import type { BrushContextValue } from '../components/BrushContext.svelte'; -import type { PreservedChartConfig } from '../components/Chart.svelte'; - -export type ChartContextValue< - T = any, - XScale extends AnyScale = AnyScale, - YScale extends AnyScale = AnyScale, -> = { - activeGetters: Record any>; - width: number; - height: number; - percentRange: boolean; - aspectRatio: number; - containerRef: HTMLElement | undefined; - containerWidth: number; - containerHeight: number; - config: PreservedChartConfig; - x: (d: T) => any; - y: (d: T) => any; - z: (d: T) => any; - r: (d: T) => any; - x1: (d: T) => any; - y1: (d: T) => any; - c: (d: T) => any; - data: DataType; - xNice: Nice; - yNice: Nice; - zNice: Nice; - rNice: Nice; - xDomainSort: boolean; - yDomainSort: boolean; - zDomainSort: boolean; - rDomainSort: boolean; - xReverse: boolean; - yReverse: boolean; - zReverse: boolean; - rReverse: boolean; - xPadding: PaddingArray; - yPadding: PaddingArray; - zPadding: PaddingArray; - rPadding: PaddingArray; - padding: Padding; - flatData: T[]; - extents: Extents; - xDomain: number[]; - yDomain: number[]; - zDomain: DomainType; - rDomain: DomainType; - cDomain: DomainType; - x1Domain: DomainType; - y1Domain: DomainType; - xRange: any[]; - yRange: any[]; - zRange: any[]; - rRange: any[]; - cRange: readonly string[] | string[] | undefined; - x1Range: XRangeWithScale | undefined; - y1Range: YRangeWithScale | undefined; - meta: Record; - xScale: AnyScale; - yScale: AnyScale; - zScale: AnyScale; - rScale: AnyScale; - cScale: AnyScale | null; - x1Scale: AnyScale | null; - y1Scale: AnyScale | null; - yGet: (d: T) => any; - xGet: (d: T) => any; - zGet: (d: T) => any; - rGet: (d: T) => any; - cGet: (d: T) => any; - x1Get: (d: T) => any; - y1Get: (d: T) => any; - xInterval: TimeInterval | null; - yInterval: TimeInterval | null; - radial: boolean; - tooltip: TooltipContextValue; - geo: GeoContextValue; - brush: BrushContextValue; - transform: TransformContextValue; -}; - -const _ChartContext = new Context>('ChartContext'); +const _ChartContext = new Context>('ChartContext'); export function getChartContext< T, XScale extends AnyScale = AnyScale, YScale extends AnyScale = AnyScale, ->(): ChartContextValue { - return _ChartContext.getOr({} as ChartContextValue); +>(): ChartState { + // @ts-expect-error - Type variance is acceptable here + return _ChartContext.getOr({} as ChartState); } export function setChartContext< T, XScale extends AnyScale = AnyScale, YScale extends AnyScale = AnyScale, ->(context: ChartContextValue): ChartContextValue { +>(context: ChartState): ChartState { // @ts-expect-error - shh return _ChartContext.set(context); } diff --git a/packages/layerchart/src/lib/contexts/geo.ts b/packages/layerchart/src/lib/contexts/geo.ts index c1f9947cc..72cb165d7 100644 --- a/packages/layerchart/src/lib/contexts/geo.ts +++ b/packages/layerchart/src/lib/contexts/geo.ts @@ -1,19 +1,17 @@ import { Context } from 'runed'; -import { type GeoProjection } from 'd3-geo'; +import type { GeoState } from '$lib/states/geo.svelte.js'; -export type GeoContextValue = { - projection: GeoProjection | undefined; -}; +export type { GeoState }; /** * Access or set the current GeoContext. */ -const _GeoContext = new Context('GeoContext'); +const _GeoContext = new Context('GeoContext'); export function getGeoContext() { - return _GeoContext.getOr({ projection: undefined } as GeoContextValue); + return _GeoContext.getOr({ projection: undefined } as GeoState); } -export function setGeoContext(geo: GeoContextValue) { +export function setGeoContext(geo: GeoState) { return _GeoContext.set(geo); } diff --git a/packages/layerchart/src/lib/states/brush.svelte.ts b/packages/layerchart/src/lib/states/brush.svelte.ts new file mode 100644 index 000000000..12f5396c9 --- /dev/null +++ b/packages/layerchart/src/lib/states/brush.svelte.ts @@ -0,0 +1,58 @@ +import { getChartContext } from '$lib/contexts/chart.js'; + +// TODO: Should we support the full `DomainType` (`string`, etc) +// type BrushDomainType = NonNullable; +export type BrushDomainType = Array; + +export type BrushRange = { + x: number; + y: number; + width: number; + height: number; +}; + +export class BrushState { + ctx: ReturnType | null; + + x = $state([null, null]); + y = $state([null, null]); + active = $state(); + axis = $state<'x' | 'y' | 'both'>('x'); + handleSize = $state(0); + + constructor( + ctx: typeof this.ctx, + options?: { + x?: BrushDomainType; + y?: BrushDomainType; + active?: boolean; + axis?: 'x' | 'y' | 'both'; + } + ) { + this.ctx = ctx; + + this.x = options?.x ?? [null, null]; + this.y = options?.y ?? [null, null]; + // this.active = options?.active ?? (this.x !== null || this.y !== null); + this.active = options?.active; + this.axis = options?.axis ?? 'x'; + } + + get range() { + if (!this.ctx) { + return { x: 0, y: 0, width: 0, height: 0 }; + } + + const top = this.ctx.yScale(this.y?.[1]); + const bottom = this.ctx.yScale(this.y?.[0]); + const left = this.ctx.xScale(this.x?.[0]); + const right = this.ctx.xScale(this.x?.[1]); + + return { + x: this.axis === 'both' || this.axis === 'x' ? left : 0, + y: this.axis === 'both' || this.axis === 'y' ? top : 0, + width: this.axis === 'both' || this.axis === 'x' ? right - left : this.ctx.width, + height: this.axis === 'both' || this.axis === 'y' ? bottom - top : this.ctx.height, + }; + } +} diff --git a/packages/layerchart/src/lib/states/chart.svelte.ts b/packages/layerchart/src/lib/states/chart.svelte.ts new file mode 100644 index 000000000..a4011adc8 --- /dev/null +++ b/packages/layerchart/src/lib/states/chart.svelte.ts @@ -0,0 +1,519 @@ +import { scaleOrdinal, scaleSqrt } from 'd3-scale'; +import { extent, max, min } from 'd3-array'; +import { unique } from '@layerstack/utils'; +import { useDebounce } from 'runed'; + +import type { AnyScale, DomainType } from '$lib/utils/scales.svelte.js'; +import { + autoScale, + createScale, + getRange, + isScaleBand, + isScaleTime, + makeAccessor, +} from '$lib/utils/scales.svelte.js'; +import type { ChartPropsWithoutHTML } from '$lib/components/Chart.svelte'; +import type { Extents } from '$lib/utils/types.js'; +import { accessor, chartDataArray } from '$lib/utils/common.js'; +import { filterObject } from '$lib/utils/filterObject.js'; +import { calcDomain, calcScaleExtents, createGetter, createChartScale } from '$lib/utils/chart.js'; +import { printDebug } from '$lib/utils/debug.js'; + +import type { GeoState } from '$lib/contexts/geo.js'; +import type { TransformContextValue } from '$lib/contexts/transform.js'; +import type { TooltipContextValue } from '$lib/contexts/tooltip.js'; +import type { BrushState } from './brush.svelte.js'; + +const defaultPadding = { top: 0, right: 0, bottom: 0, left: 0 }; + +interface ScaleEntry { + scale: AnyScale; + sort?: boolean; +} + +export class ChartState< + TData = any, + XScale extends AnyScale = AnyScale, + YScale extends AnyScale = AnyScale, +> { + // Props getter function - set in constructor + private _propsGetter!: () => ChartPropsWithoutHTML; + + // Props - accessed via getter function for fine-grained reactivity + props = $derived(this._propsGetter()); + + // Context references + geoContext = $state(null!); + transformContext = $state(null!); + tooltipContext = $state(null!); + brushContext = $state(null!); + + // Container dimensions + _containerWidth = $state(100); + _containerHeight = $state(100); + + // Mount state + isMounted = $state(false); + + // Container ref (set from Chart.svelte) + containerRef = $state(); + + // Meta data - reactive to props.meta changes + meta = $derived(this.props.meta ?? {}); + + constructor(propsGetter: () => ChartPropsWithoutHTML) { + this._propsGetter = propsGetter; + + const logDebug = useDebounce(printDebug, 200); + + // Set mounted state once component initializes + $effect(() => { + this.isMounted = true; + }); + + // Call onResize callback when dimensions change + $effect(() => { + if (!this.isMounted) return; + this.props.onResize?.({ + width: this.width, + height: this.height, + containerWidth: this.containerWidth, + containerHeight: this.containerHeight, + }); + }); + + // Debug logging when mounted + $effect(() => { + if ( + !this.isMounted || + !this.props.debug || + (!this.props.ssr && typeof window === 'undefined') + ) { + return; + } + + if (this.box) { + logDebug({ + data: this.data, + flatData: this.flatData, + boundingBox: this.box, + activeGetters: this.activeGetters, + x: this.props.x, + y: this.props.y, + z: this.props.z, + r: this.props.r, + xScale: this.xScale, + yScale: this.yScale, + zScale: this.zScale, + rScale: this.rScale, + }); + } + }); + } + + // Use $derived fields instead of getters for caching + containerWidth = $derived(this.props.width ?? this._containerWidth); + containerHeight = $derived(this.props.height ?? this._containerHeight); + data = $derived(this.props.data ?? []); + flatData = $derived((this.props.flatData ?? this.data) as TData[]); + + // Cached scale props - use props directly to avoid accessing this.flatData + _xScaleProp = $derived.by(() => { + const flatData = (this.props.flatData ?? this.props.data ?? []) as TData[]; + return this.props.xScale ?? autoScale(this.props.xDomain, flatData, this.props.x); + }); + + _yScaleProp = $derived.by(() => { + const flatData = (this.props.flatData ?? this.props.data ?? []) as TData[]; + return this.props.yScale ?? autoScale(this.props.yDomain, flatData, this.props.y); + }); + + _zScaleProp = $derived.by(() => { + const flatData = (this.props.flatData ?? this.props.data ?? []) as TData[]; + return this.props.zScale ?? autoScale(this.props.zDomain, flatData, this.props.z); + }); + + _rScaleProp = $derived(this.props.rScale ?? scaleSqrt()); + + xRangeProp = $derived( + this.props.xRange ? this.props.xRange : this.props.radial ? [0, 2 * Math.PI] : undefined + ); + + yRangeProp = $derived( + this.props.yRange ?? + (this.props.radial ? ({ height }: { height: number }) => [0, height / 2] : undefined) + ); + + yReverse = $derived( + this.props.yScale ? !isScaleBand(this.props.yScale) && !isScaleTime(this.props.yScale) : true + ); + + _xDomain = $derived.by((): DomainType | undefined => { + if (this.props.xDomain !== undefined) return this.props.xDomain; + + if (this.props.xInterval != null && Array.isArray(this.data) && this.data.length > 0) { + const lastXValue = accessor(this.props.x)(this.data[this.data.length - 1]); + return [null, this.props.xInterval.offset(lastXValue)]; + } + + if (this.props.xBaseline != null && Array.isArray(this.data)) { + const xValues = this.data.flatMap(accessor(this.props.x)); + return [min([this.props.xBaseline, ...xValues]), max([this.props.xBaseline, ...xValues])]; + } + }); + + _yDomain = $derived.by((): DomainType | undefined => { + if (this.props.yDomain !== undefined) return this.props.yDomain; + + if (this.props.yInterval != null && Array.isArray(this.data) && this.data.length > 0) { + const lastYValue = accessor(this.props.y)(this.data[this.data.length - 1]); + return [null, this.props.yInterval.offset(lastYValue)]; + } + + if (this.props.yBaseline != null && Array.isArray(this.data)) { + const yValues = this.data.flatMap(accessor(this.props.y)); + return [min([this.props.yBaseline, ...yValues]), max([this.props.yBaseline, ...yValues])]; + } + }); + + x = $derived(makeAccessor(this.props.x)); + y = $derived(makeAccessor(this.props.y)); + z = $derived(makeAccessor(this.props.z)); + r = $derived(makeAccessor(this.props.r)); + c = $derived(accessor(this.props.c)); + x1 = $derived(accessor(this.props.x1)); + y1 = $derived(accessor(this.props.y1)); + + filteredExtents = $derived(filterObject($state.snapshot(this.props.extents ?? {}))); + + activeGetters = $derived({ + x: this.x, + y: this.y, + z: this.z, + r: this.r, + }); + + padding = $derived.by(() => { + const paddingProp = this.props.padding ?? {}; + if (typeof paddingProp === 'number') { + return { + ...defaultPadding, + top: paddingProp, + right: paddingProp, + bottom: paddingProp, + left: paddingProp, + }; + } + return { ...defaultPadding, ...paddingProp }; + }); + + box = $derived.by(() => { + const top = this.padding.top; + const right = this.containerWidth - this.padding.right; + const bottom = this.containerHeight - this.padding.bottom; + const left = this.padding.left; + const width = right - left; + const height = bottom - top; + + if (this.props.verbose === true) { + if (width <= 0 && this.isMounted === true) { + console.warn( + `[LayerChart] Target div has zero or negative width (${width}). Did you forget to set an explicit width in CSS on the container?` + ); + } + if (height <= 0 && this.isMounted === true) { + console.warn( + `[LayerChart] Target div has zero or negative height (${height}). Did you forget to set an explicit width in CSS on the container?` + ); + } + } + + return { + top, + left, + bottom, + right, + width, + height, + }; + }); + + width = $derived(this.box.width); + height = $derived(this.box.height); + + extents = $derived.by((): Extents => { + const scaleLookup: Record = { + x: { + scale: this._xScaleProp, + sort: this.props.xDomainSort, + }, + y: { + scale: this._yScaleProp, + sort: this.props.yDomainSort, + }, + z: { + scale: this._zScaleProp, + sort: this.props.zDomainSort, + }, + r: { + scale: this._rScaleProp, + sort: this.props.rDomainSort, + }, + }; + + const getters = filterObject(this.activeGetters, this.filteredExtents); + const activeScales: Record = Object.fromEntries( + Object.keys(getters).map((k) => [k, scaleLookup[k]]) + ); + + if (Object.keys(getters).length > 0) { + const calculatedExtents = calcScaleExtents(this.flatData, getters, activeScales); + return { ...calculatedExtents, ...this.filteredExtents }; + } else { + return {}; + } + }); + + xDomain = $derived(calcDomain('x', this.extents, this._xDomain)); + yDomain = $derived(calcDomain('y', this.extents, this._yDomain)); + zDomain = $derived(calcDomain('z', this.extents, this.props.zDomain)); + rDomain = $derived(calcDomain('r', this.extents, this.props.rDomain)); + + x1Domain = $derived(this.props.x1Domain ?? extent(chartDataArray(this.data), this.x1)); + y1Domain = $derived(this.props.y1Domain ?? extent(chartDataArray(this.data), this.y1)); + cDomain = $derived(this.props.cDomain ?? unique(chartDataArray(this.data).map(this.c))); + + snappedPadding = $derived($state.snapshot(this.props.xPadding)); + snappedExtents = $derived($state.snapshot(this.extents)); + + xScale = $derived( + createChartScale('x', { + scale: this._xScaleProp, + domain: this.xDomain, + padding: this.snappedPadding, + nice: this.props.xNice ?? false, + reverse: this.props.xReverse ?? false, + percentRange: this.props.percentRange ?? false, + range: this.xRangeProp, + height: this.height, + width: this.width, + extents: this.snappedExtents, + }) + ); + + xGet = $derived(createGetter(this.x, this.xScale)); + + yScale = $derived( + createChartScale('y', { + scale: this._yScaleProp, + domain: this.yDomain, + padding: this.props.yPadding, + nice: this.props.yNice ?? false, + reverse: this.yReverse, + percentRange: this.props.percentRange ?? false, + range: this.yRangeProp, + height: this.height, + width: this.width, + extents: this.filteredExtents, + }) + ); + + yGet = $derived(createGetter(this.y, this.yScale)); + + zScale = $derived( + createChartScale('z', { + scale: this._zScaleProp, + domain: this.zDomain, + padding: this.props.zPadding, + nice: this.props.zNice ?? false, + reverse: this.props.zReverse ?? false, + percentRange: this.props.percentRange ?? false, + range: this.props.zRange, + height: this.height, + width: this.width, + extents: this.filteredExtents, + }) + ); + + zGet = $derived(createGetter(this.z, this.zScale)); + + rScale = $derived( + createChartScale('r', { + scale: this._rScaleProp, + domain: this.rDomain, + padding: this.props.rPadding, + nice: this.props.rNice ?? false, + reverse: this.props.rReverse ?? false, + percentRange: this.props.percentRange ?? false, + range: this.props.rRange, + height: this.height, + width: this.width, + extents: this.filteredExtents, + }) + ); + + rGet = $derived(createGetter(this.r, this.rScale)); + + x1Scale = $derived( + this.props.x1Range + ? createScale( + this.props.x1Scale ?? autoScale(this.props.x1Domain, this.flatData, this.props.x1), + this.x1Domain, + this.props.x1Range, + { + xScale: this.xScale, + width: this.width, + height: this.height, + } + ) + : null + ); + + x1Get = $derived(createGetter(this.x1, this.x1Scale)); + + y1Scale = $derived( + this.props.y1Range + ? createScale( + this.props.y1Scale ?? autoScale(this.props.y1Domain, this.flatData, this.props.y1), + this.y1Domain, + this.props.y1Range, + { + yScale: this.yScale, + width: this.width, + height: this.height, + } + ) + : null + ); + + y1Get = $derived(createGetter(this.y1, this.y1Scale)); + + cScale = $derived( + this.props.cRange + ? createScale(this.props.cScale ?? scaleOrdinal(), this.cDomain, this.props.cRange, { + width: this.width, + height: this.height, + }) + : null + ); + + cGet = $derived((d: any) => this.cScale?.(this.c(d))); + + xDomainPossiblyNice = $derived(this.xScale.domain()); + yDomainPossiblyNice = $derived(this.yScale.domain()); + zDomainPossiblyNice = $derived(this.zScale.domain()); + rDomainPossiblyNice = $derived(this.rScale.domain()); + + xRange = $derived(getRange(this.xScale)); + yRange = $derived(getRange(this.yScale)); + zRange = $derived(getRange(this.zScale)); + rRange = $derived(getRange(this.rScale)); + + aspectRatio = $derived(this.width / this.height); + + // Properties that come directly from props (not derived) + get percentRange() { + return this.props.percentRange ?? false; + } + get xNice() { + return this.props.xNice ?? false; + } + get yNice() { + return this.props.yNice ?? false; + } + get zNice() { + return this.props.zNice ?? false; + } + get rNice() { + return this.props.rNice ?? false; + } + get xDomainSort() { + return this.props.xDomainSort ?? false; + } + get yDomainSort() { + return this.props.yDomainSort ?? false; + } + get zDomainSort() { + return this.props.zDomainSort ?? false; + } + get rDomainSort() { + return this.props.rDomainSort ?? false; + } + get xReverse() { + return this.props.xReverse ?? false; + } + get zReverse() { + return this.props.zReverse ?? false; + } + get rReverse() { + return this.props.rReverse ?? false; + } + get xPadding() { + return this.props.xPadding; + } + get yPadding() { + return this.props.yPadding; + } + get zPadding() { + return this.props.zPadding; + } + get rPadding() { + return this.props.rPadding; + } + get cRange() { + return this.props.cRange; + } + get x1Range() { + return this.props.x1Range; + } + get y1Range() { + return this.props.y1Range; + } + get xInterval() { + return this.props.xInterval ?? null; + } + get yInterval() { + return this.props.yInterval ?? null; + } + get radial() { + return this.props.radial ?? false; + } + get tooltip() { + return this.tooltipContext; + } + get geo() { + return this.geoContext; + } + get brush() { + return this.brushContext; + } + get transform() { + return this.transformContext; + } + + get config() { + return { + x: this.props.x, + y: this.props.y, + z: this.props.z, + r: this.props.r, + c: this.props.c, + x1: this.props.x1, + y1: this.props.y1, + xDomain: this._xDomain, + yDomain: this._yDomain, + zDomain: this.props.zDomain, + rDomain: this.props.rDomain, + x1Domain: this.props.x1Domain, + y1Domain: this.props.y1Domain, + cDomain: this.props.cDomain, + xRange: this.props.xRange, + yRange: this.props.yRange, + zRange: this.props.zRange, + rRange: this.props.rRange, + cRange: this.props.cRange, + x1Range: this.props.x1Range, + y1Range: this.props.y1Range, + }; + } +} diff --git a/packages/layerchart/src/lib/states/geo.svelte.ts b/packages/layerchart/src/lib/states/geo.svelte.ts new file mode 100644 index 000000000..19266e275 --- /dev/null +++ b/packages/layerchart/src/lib/states/geo.svelte.ts @@ -0,0 +1,111 @@ +import type { GeoProjection } from 'd3-geo'; +import type { GeoContextProps } from '$lib/components/GeoContext.svelte'; + +export class GeoState { + // Props getter function - set in constructor + private _propsGetter!: () => GeoContextProps; + + // Props - accessed via getter function for fine-grained reactivity + props = $derived(this._propsGetter()); + + // Context references + chartWidth = $state(100); + chartHeight = $state(100); + transformScale = $state(1); + transformTranslateX = $state(0); + transformTranslateY = $state(0); + + // The actual projection instance + projection = $state(undefined); + + constructor(propsGetter: () => GeoContextProps) { + this._propsGetter = propsGetter; + + // Main effect to build and configure the projection + $effect.pre(() => { + if (!this.props.projection) return; + + const _projection = this.props.projection(); + + // Apply fitSize if fitGeojson is provided + if (this.props.fitGeojson && 'fitSize' in _projection) { + _projection.fitSize(this.fitSizeRange, this.props.fitGeojson); + } + + // Apply scale + if ('scale' in _projection) { + if (this.props.scale) { + _projection.scale(this.props.scale); + } + + if (this.props.applyTransform?.includes('scale')) { + _projection.scale(this.transformScale); + } + } + + // Apply rotate + if ('rotate' in _projection) { + if (this.props.rotate) { + _projection.rotate([ + this.props.rotate.yaw, + this.props.rotate.pitch, + this.props.rotate.roll, + ]); + } + + if (this.props.applyTransform?.includes('rotate')) { + _projection.rotate([ + this.transformTranslateX, // yaw + this.transformTranslateY, // pitch + // TODO: `roll` from `transformContext`? + ]); + } + } + + // Apply translate + if ('translate' in _projection) { + if (this.props.translate) { + _projection.translate(this.props.translate); + } + + if (this.props.applyTransform?.includes('translate')) { + _projection.translate([this.transformTranslateX, this.transformTranslateY]); + } + } + + // Apply center + if (this.props.center && 'center' in _projection) { + _projection.center(this.props.center); + } + + // Apply reflectX + if (this.props.reflectX) { + _projection.reflectX(this.props.reflectX); + } + + // Apply reflectY + if (this.props.reflectY) { + _projection.reflectY(this.props.reflectY); + } + + // Apply clipAngle + if (this.props.clipAngle && 'clipAngle' in _projection) { + _projection.clipAngle(this.props.clipAngle); + } + + // Apply clipExtent + if (this.props.clipExtent && 'clipExtent' in _projection) { + _projection.clipExtent(this.props.clipExtent); + } + + this.projection = _projection; + }); + } + + // Derived properties + fitSizeRange = $derived( + this.props.fixedAspectRatio + ? [100, 100 / this.props.fixedAspectRatio] + : [this.chartWidth, this.chartHeight] + ) as [number, number]; +} diff --git a/packages/layerchart/src/lib/states/series.svelte.ts b/packages/layerchart/src/lib/states/series.svelte.ts index 8cbe491be..fd1346e88 100644 --- a/packages/layerchart/src/lib/states/series.svelte.ts +++ b/packages/layerchart/src/lib/states/series.svelte.ts @@ -3,14 +3,6 @@ import type { SeriesData } from '../components/charts/types.js'; import { SelectionState } from '@layerstack/svelte-state'; -class HighlightKey { - current = $state['key'] | null>(null); - - set = (seriesKey: typeof this.current) => { - this.current = seriesKey; - }; -} - export class SeriesState { #series = $state.raw[]>([]); selectedKeys = new SelectionState(); @@ -68,3 +60,11 @@ export class SeriesState { >; } } + +class HighlightKey { + current = $state['key'] | null>(null); + + set = (seriesKey: typeof this.current) => { + this.current = seriesKey; + }; +} diff --git a/packages/layerchart/src/lib/utils/rect.svelte.ts b/packages/layerchart/src/lib/utils/rect.svelte.ts index 75dfb20b5..bcece7e4f 100644 --- a/packages/layerchart/src/lib/utils/rect.svelte.ts +++ b/packages/layerchart/src/lib/utils/rect.svelte.ts @@ -1,5 +1,5 @@ import { max, min } from 'd3-array'; -import type { ChartContextValue } from '$lib/contexts/chart.js'; +import type { ChartState } from '$lib/states/chart.svelte.js'; import { accessor, type Accessor } from './common.js'; import { isScaleBand } from './scales.svelte.js'; @@ -58,7 +58,7 @@ function resolveInsets(insets?: Insets): ResolvedInsets { } export function createDimensionGetter( - ctx: ChartContextValue, + ctx: ChartState, getOptions?: () => DimensionGetterOptions ) { const options = $derived(getOptions?.()); diff --git a/packages/layerchart/src/lib/utils/types.ts b/packages/layerchart/src/lib/utils/types.ts index dfb41385d..cf1f3a714 100644 --- a/packages/layerchart/src/lib/utils/types.ts +++ b/packages/layerchart/src/lib/utils/types.ts @@ -19,12 +19,22 @@ export function asAny(x: any): any { * * @template T - The base object type from which properties will be omitted. * @template U - The object type whose properties will be omitted from 'T'. + * * @example * type Result = Without<{ a: number; b: string; }, { b: string; }>; * // Result type will be { a: number; } */ export type Without = Omit; +/** + * Extracts the non-nullable array type from a type that may include nulls. + * + * @example + * type Result = NonNullArray<(number | null)[]>; + * // Result type will be number[]. + */ +export type NonNullArray = T extends (infer U | null)[] ? U[] : never; + export type AxisKey = 'x' | 'y' | 'z' | 'r'; export type Extents = { diff --git a/packages/layerchart/src/routes/docs/components/AreaChart/+page.svelte b/packages/layerchart/src/routes/docs/components/AreaChart/+page.svelte index d7531a00a..ebf9dec6a 100644 --- a/packages/layerchart/src/routes/docs/components/AreaChart/+page.svelte +++ b/packages/layerchart/src/routes/docs/components/AreaChart/+page.svelte @@ -14,7 +14,7 @@ Threshold, pivotLonger, accessor, - type ChartContextValue, + type ChartState, defaultChartPadding, Layer, getSettings, @@ -129,7 +129,7 @@ let xDomain: DomainType | undefined = $state(); let markerPoints: { date: Date; value: number }[] = $state([]); - let context = $state>(null!); + let context = $state>(null!); let selectedCurve = $state(curveStepAfter); @@ -1228,7 +1228,7 @@ x="date" y="value" {xDomain} - brush={{ onBrushEnd: (e) => (xDomain = e.xDomain) }} + brush={{ onBrushEnd: (e) => (xDomain = e.brush.x) }} props={{ area: { motion: { type: 'tween', duration: 200 } }, xAxis: { motion: { type: 'tween', duration: 200 }, tickMultiline: true }, @@ -1241,8 +1241,7 @@ data={denseDateSeriesData2} x="date" y="value" - {xDomain} - brush={{ onBrushEnd: (e) => (xDomain = e.xDomain) }} + brush={{ onBrushEnd: (e) => (xDomain = e.brush.x) }} props={{ area: { motion: { type: 'tween', duration: 200 } }, xAxis: { motion: { type: 'tween', duration: 200 }, tickMultiline: true }, diff --git a/packages/layerchart/src/routes/docs/components/Axis/+page.svelte b/packages/layerchart/src/routes/docs/components/Axis/+page.svelte index c21ddbce7..f72f0c92b 100644 --- a/packages/layerchart/src/routes/docs/components/Axis/+page.svelte +++ b/packages/layerchart/src/routes/docs/components/Axis/+page.svelte @@ -848,7 +848,7 @@ brush={{ resetOnEnd: true, onBrushEnd: (e) => { - xDomain = asAny(e.xDomain); + xDomain = asAny(e.brush.x); }, }} > @@ -865,9 +865,9 @@ padding={{ top: 20, bottom: 20, left: 20, right: 20 }} brush={{ mode: 'separated', - xDomain, + x: xDomain, onChange: (e) => { - xDomain = asAny(e.xDomain); + xDomain = asAny(e.brush.x); }, }} > @@ -901,7 +901,7 @@ brush={{ resetOnEnd: true, onBrushEnd: (e) => { - xDomain = asAny(e.xDomain); + xDomain = asAny(e.brush.x); }, }} > @@ -918,9 +918,9 @@ padding={{ top: 20, bottom: 20, left: 20, right: 20 }} brush={{ mode: 'separated', - xDomain, + x: xDomain, onChange: (e) => { - xDomain = asAny(e.xDomain); + xDomain = asAny(e.brush.x); }, }} > diff --git a/packages/layerchart/src/routes/docs/components/BrushContext/+page.svelte b/packages/layerchart/src/routes/docs/components/BrushContext/+page.svelte index 2e3b1e3f9..516f204f9 100644 --- a/packages/layerchart/src/routes/docs/components/BrushContext/+page.svelte +++ b/packages/layerchart/src/routes/docs/components/BrushContext/+page.svelte @@ -1,9 +1,9 @@

Examples

+

Html click

+ + + +
+ { + console.log('onBrushEnd', e); + xDomain2 = e.brush.x; + }, + }} + > + {#snippet children({ context })} + + {#each dataSeriesData as d} + {@const start = d.date} + {@const end = endOfInterval('day', d.date)} + + {@const x = context.xScale(start)} + {@const width = context.xScale(end) - x} + {@const height = context.height} + + +
{ + console.log('clicked', start); + }} + onpointerenter={(e) => { + // console.log(start); + }} + >
+ {/each} +
+ {/snippet} +
+
+
+ +

Band scale (WIP)

+ + +
+ { + console.log(e); + }, + }} + > + + + + +
+
+

Basic

@@ -131,7 +211,7 @@ - {#if context.brush.isActive} + {#if context.brush.active} - {#if context.brush.isActive} + {#if context.brush.active} @@ -203,11 +283,11 @@ - {#if context.brush.isActive} + {#if context.brush.active} @@ -244,7 +324,7 @@ resetOnEnd: true, onBrushEnd: (e) => { // @ts-expect-error - set(e.xDomain); + set(e.brush.x); }, }} > @@ -282,7 +362,7 @@ resetOnEnd: true, onBrushEnd: (e) => { // @ts-expect-error - set(e.yDomain); + set(e.brush.y); }, }} > @@ -322,9 +402,9 @@ onBrushEnd: (e) => { set({ // @ts-expect-error - xDomain: e.xDomain, + xDomain: e.brush.x, // @ts-expect-error - yDomain: e.yDomain, + yDomain: e.brush.y, }); }, }} @@ -383,7 +463,7 @@ brush={{ onChange: (e) => { // @ts-expect-error - set(e.xDomain); + set(e.brush.x); }, }} > @@ -411,7 +491,7 @@ axis: 'y', onChange: (e) => { // @ts-expect-error - set(e.yDomain); + set(e.brush.y); }, }} > @@ -493,7 +573,7 @@ brush={{ onChange: (e) => { // @ts-expect-error - set(e.xDomain); + set(e.brush.x); }, }} > @@ -554,9 +634,9 @@ padding={{ left: 16 }} brush={{ mode: 'separated', - xDomain, - onChange: (e) => (xDomain = e.xDomain), - onReset: (e) => (xDomain = null), + x: xDomain, + onChange: (e) => (xDomain = e.brush.x), + onReset: (e) => (xDomain = [null, null]), }} > @@ -564,7 +644,6 @@ line={{ class: 'stroke-2 stroke-(--chart-color)' }} class="fill-(--chart-color) opacity-20" /> -
@@ -591,7 +670,7 @@ resetOnEnd: true, onBrushEnd: (e) => { // @ts-expect-error - set(e.xDomain); + set(e.brush.x); }, }} > @@ -657,9 +736,9 @@ onChange: (e) => { set({ // @ts-expect-error - xDomain: e.xDomain, + xDomain: e.brush.x, // @ts-expect-error - yDomain: e.yDomain, + yDomain: e.brush.y, }); }, }} @@ -716,9 +795,9 @@ onBrushEnd: (e) => { set({ // @ts-expect-error - xDomain: e.xDomain, + xDomain: e.brush.x, // @ts-expect-error - yDomain: e.yDomain, + yDomain: e.brush.y, }); }, }} @@ -743,14 +822,14 @@ brush={{ axis: 'both', mode: 'separated', - xDomain: value?.xDomain, - yDomain: value?.yDomain, + x: value?.xDomain, + y: value?.yDomain, onChange: (e) => { set({ // @ts-expect-error - xDomain: e.xDomain, + xDomain: e.brush.x, // @ts-expect-error - yDomain: e.yDomain, + yDomain: e.brush.y, }); }, }} diff --git a/packages/layerchart/src/routes/docs/components/Tooltip/+page.svelte b/packages/layerchart/src/routes/docs/components/Tooltip/+page.svelte index b33028208..604f6270b 100644 --- a/packages/layerchart/src/routes/docs/components/Tooltip/+page.svelte +++ b/packages/layerchart/src/routes/docs/components/Tooltip/+page.svelte @@ -14,7 +14,7 @@ Points, Rule, Tooltip, - type ChartContextValue, + type ChartState, } from 'layerchart'; import { Button, Duration, Field, Menu, MenuField, Toggle } from 'svelte-ux'; import { flatten, format } from '@layerstack/utils'; @@ -137,7 +137,7 @@ let snap: 'pointer' | 'data' = $state('pointer'); let contained: ComponentProps['contained'] = $state(false); - let context: ChartContextValue<(typeof dateSeries)[number]> | undefined = $state(); + let context: ChartState<(typeof dateSeries)[number]> | undefined = $state();

Examples

diff --git a/packages/layerchart/src/routes/docs/examples/AnimatedGlobe/+page.svelte b/packages/layerchart/src/routes/docs/examples/AnimatedGlobe/+page.svelte index 911ed1ec2..5825ddd85 100644 --- a/packages/layerchart/src/routes/docs/examples/AnimatedGlobe/+page.svelte +++ b/packages/layerchart/src/routes/docs/examples/AnimatedGlobe/+page.svelte @@ -13,7 +13,7 @@ Graticule, Layer, Tooltip, - type ChartContextValue, + type ChartState, } from 'layerchart'; import { Button, ButtonGroup } from 'svelte-ux'; import { sortFunc } from '@layerstack/utils'; @@ -31,7 +31,7 @@ const countries = feature(data.geojson, data.geojson.objects.countries); - let context = $state(null!); + let context = $state(null!); let selectedFeature: (typeof countries.features)[0] | null = $state(null); diff --git a/packages/layerchart/src/routes/docs/examples/Candlestick/+page.svelte b/packages/layerchart/src/routes/docs/examples/Candlestick/+page.svelte index c0db8ab06..2fe9450e5 100644 --- a/packages/layerchart/src/routes/docs/examples/Candlestick/+page.svelte +++ b/packages/layerchart/src/routes/docs/examples/Candlestick/+page.svelte @@ -106,7 +106,7 @@ yNice brush={{ onChange: (e) => { - xDomain = e.xDomain; + xDomain = e.brush.x; }, }} > diff --git a/packages/layerchart/src/routes/docs/examples/EarthquakeGlobe/+page.svelte b/packages/layerchart/src/routes/docs/examples/EarthquakeGlobe/+page.svelte index 3875fcb7d..c3bee5e41 100644 --- a/packages/layerchart/src/routes/docs/examples/EarthquakeGlobe/+page.svelte +++ b/packages/layerchart/src/routes/docs/examples/EarthquakeGlobe/+page.svelte @@ -13,7 +13,7 @@ Graticule, Layer, Tooltip, - type ChartContextValue, + type ChartState, } from 'layerchart'; import Preview from '$lib/docs/Preview.svelte'; @@ -24,7 +24,7 @@ const countries = feature(data.geojson, data.geojson.objects.countries); - let context = $state>(); + let context = $state>(); let velocity = $state(3); diff --git a/packages/layerchart/src/routes/docs/examples/EclipsesGlobe/+page.svelte b/packages/layerchart/src/routes/docs/examples/EclipsesGlobe/+page.svelte index aee9bc7ae..e9a9b10fa 100644 --- a/packages/layerchart/src/routes/docs/examples/EclipsesGlobe/+page.svelte +++ b/packages/layerchart/src/routes/docs/examples/EclipsesGlobe/+page.svelte @@ -5,15 +5,7 @@ import { interpolateGreens, interpolatePurples } from 'd3-scale-chromatic'; import { feature } from 'topojson-client'; - import { - Chart, - GeoPath, - Graticule, - Legend, - Layer, - Tooltip, - type ChartContextValue, - } from 'layerchart'; + import { Chart, GeoPath, Graticule, Legend, Layer, Tooltip, type ChartState } from 'layerchart'; import { Button, ButtonGroup, Field, RangeField } from 'svelte-ux'; import { format } from '@layerstack/utils'; @@ -27,7 +19,7 @@ const countries = feature(data.geojson, data.geojson.objects.countries); const eclipses = feature(data.eclipses, data.eclipses.objects.eclipses); - let context = $state(null!); + let context = $state(null!); let velocity = $state(3); const timer = new TimerState({ diff --git a/packages/layerchart/src/routes/docs/examples/Pack/+page.svelte b/packages/layerchart/src/routes/docs/examples/Pack/+page.svelte index cca61c884..ef9bdcba6 100644 --- a/packages/layerchart/src/routes/docs/examples/Pack/+page.svelte +++ b/packages/layerchart/src/routes/docs/examples/Pack/+page.svelte @@ -14,7 +14,7 @@ Layer, Text, findAncestor, - type ChartContextValue, + type ChartState, } from 'layerchart'; import { Breadcrumb, Button, Field, RangeField, ToggleGroup, ToggleOption } from 'svelte-ux'; import { format, sortFunc } from '@layerstack/utils'; @@ -32,7 +32,7 @@ let padding = $state(3); let nodes = $state.raw[]>([]); let selected = $state.raw>(); - let context = $state(null!); + let context = $state(null!); $effect(() => { if (context?.transform && selected) { diff --git a/packages/layerchart/src/routes/docs/examples/SketchyGlobe/+page.svelte b/packages/layerchart/src/routes/docs/examples/SketchyGlobe/+page.svelte index d2c002338..3ed2ffb99 100644 --- a/packages/layerchart/src/routes/docs/examples/SketchyGlobe/+page.svelte +++ b/packages/layerchart/src/routes/docs/examples/SketchyGlobe/+page.svelte @@ -4,7 +4,7 @@ import { feature } from 'topojson-client'; import { presimplify, simplify } from 'topojson-simplify'; - import { Chart, GeoPath, Graticule, Layer, type ChartContextValue } from 'layerchart'; + import { Chart, GeoPath, Graticule, Layer, type ChartState } from 'layerchart'; import { Button, ButtonGroup, Field, RangeField } from 'svelte-ux'; import { TimerState } from '@layerstack/svelte-state'; @@ -19,7 +19,7 @@ const geojson = $derived(simplify(presimplify(data.geojson), Math.pow(10, 2 - minArea))); const land = $derived(feature(geojson, data.geojson.objects.land)); - let context = $state(null!); + let context = $state(null!); let velocity = $state(1); const timer = new TimerState({ diff --git a/packages/layerchart/src/routes/docs/examples/SubmarineCablesGlobe/+page.svelte b/packages/layerchart/src/routes/docs/examples/SubmarineCablesGlobe/+page.svelte index b2223df77..7f797a072 100644 --- a/packages/layerchart/src/routes/docs/examples/SubmarineCablesGlobe/+page.svelte +++ b/packages/layerchart/src/routes/docs/examples/SubmarineCablesGlobe/+page.svelte @@ -14,7 +14,7 @@ Graticule, Layer, Tooltip, - type ChartContextValue, + type ChartState, } from 'layerchart'; import Preview from '$lib/docs/Preview.svelte'; @@ -24,7 +24,7 @@ const countries = feature(data.geojson, data.geojson.objects.countries); - let context = $state(); + let context = $state(); let velocity = $state(3); const timer = new TimerState({ diff --git a/packages/layerchart/src/routes/docs/examples/TranslucentGlobe/+page.svelte b/packages/layerchart/src/routes/docs/examples/TranslucentGlobe/+page.svelte index 1cb031df3..222372bcc 100644 --- a/packages/layerchart/src/routes/docs/examples/TranslucentGlobe/+page.svelte +++ b/packages/layerchart/src/routes/docs/examples/TranslucentGlobe/+page.svelte @@ -9,7 +9,7 @@ Graticule, Layer, Tooltip, - type ChartContextValue, + type ChartState, } from 'layerchart'; import { Button, ButtonGroup, Field, RangeField } from 'svelte-ux'; import { TimerState } from '@layerstack/svelte-state'; @@ -20,7 +20,7 @@ const countries = feature(data.geojson, data.geojson.objects.countries); - let context = $state(); + let context = $state(); let velocity = $state(3); const timer = new TimerState({ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4f75a391d..961bfba1c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -546,8 +546,8 @@ importers: specifier: 2.0.0-next.17 version: 2.0.0-next.17 '@layerstack/utils': - specifier: 2.0.0-next.14 - version: 2.0.0-next.14 + specifier: 2.0.0-next.15 + version: 2.0.0-next.15 d3-array: specifier: ^3.2.4 version: 3.2.4 @@ -5536,7 +5536,7 @@ snapshots: '@layerstack/tailwind@2.0.0-next.17': dependencies: - '@layerstack/utils': 2.0.0-next.14 + '@layerstack/utils': 2.0.0-next.15 clsx: 2.1.1 d3-array: 3.2.4 lodash-es: 4.17.21