Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
141 changes: 141 additions & 0 deletions src/lib/helpers/callWithProps.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import { describe, it, expect } from 'vitest';
import callWithProps from './callWithProps.js';
import { area, line } from 'd3-shape';
import { geoPath, geoAlbers } from 'd3-geo';
import { scaleLinear, scaleTime } from 'd3-scale';

describe('callWithProps', () => {
it('should call d3 function without arguments or props', () => {
const lineGen = callWithProps(line);
expect(lineGen).toBeDefined();
expect(typeof lineGen).toBe('function');
});

it('should call d3 function with arguments', () => {
const x = (d: [number, number]) => d[0];
const y = (d: [number, number]) => d[1];
const lineGen = callWithProps(line<[number, number]>, [x, y]);

expect(lineGen).toBeDefined();
const path = lineGen([
[0, 0],
[1, 1],
[2, 2]
]);
expect(path).toBeTruthy();
expect(path).toBe('M0,0L1,1L2,2');
});

it('should apply props to d3 area generator', () => {
const xAccessor = (d: [number, number]) => d[0];
const y0Accessor = () => 0;
const y1Accessor = (d: [number, number]) => d[1];

const areaGen = callWithProps(area<[number, number]>, [], {
x: xAccessor,
y0: y0Accessor,
y1: y1Accessor
});

const path = areaGen([
[0, 5],
[1, 10],
[2, 8]
]);
expect(path).toBeTruthy();
expect(typeof path).toBe('string');
});

it('should apply props to d3 line generator', () => {
const data = [
[0, 0],
[1, 1],
[2, 2]
] as [number, number][];

const lineGen = callWithProps(line<[number, number]>, [], {
x: (d: [number, number]) => d[0] * 10,
y: (d: [number, number]) => d[1] * 20
});

const path = lineGen(data);
expect(path).toBeTruthy();
expect(typeof path).toBe('string');
expect(path).toBe('M0,0L10,20L20,40');
});

it('should apply props to d3 geoPath generator', () => {
const projection = geoAlbers();
const pathGen = callWithProps(geoPath, [], { projection });

expect(pathGen).toBeDefined();
expect(typeof pathGen).toBe('function');
});

it('should apply multiple props to scale', () => {
const scale = callWithProps(scaleLinear, undefined, {
domain: [0, 100],
range: [0, 500]
});

expect(scale(0)).toBe(0);
expect(scale(50)).toBe(250);
expect(scale(100)).toBe(500);
});

it('should work with time scale', () => {
const start = new Date(2020, 0, 1);
const end = new Date(2020, 11, 31);

const scale = callWithProps(scaleTime, undefined, {
domain: [start, end],
range: [0, 1000]
});

expect(scale(start)).toBe(0);
expect(scale(end)).toBe(1000);
});

it('should throw error for non-existent setter', () => {
expect(() => {
callWithProps(line, [], {
// @ts-expect-error - Testing invalid property
nonExistentMethod: 'value'
});
}).toThrow("Setter function 'nonExistentMethod' does not exist on this d3 object");
});

it('should handle empty props object', () => {
const lineGen = callWithProps(line, [], {});
expect(lineGen).toBeDefined();
expect(typeof lineGen).toBe('function');
});

it('should handle chaining setters correctly', () => {
const scale = callWithProps(scaleLinear, undefined, {
domain: [0, 10],
range: [0, 100],
clamp: true
});

// Test that clamp is applied
expect(scale(-1)).toBe(0); // clamped to min
expect(scale(11)).toBe(100); // clamped to max
expect(scale(5)).toBe(50); // normal value
});

it('should work with line curve setter', async () => {
const { curveMonotoneX } = await import('d3-shape');

const lineGen = callWithProps(line<[number, number]>, [], {
curve: curveMonotoneX
});

const path = lineGen([
[0, 0],
[1, 5],
[2, 3]
]);
expect(path).toBeTruthy();
});
});
44 changes: 35 additions & 9 deletions src/lib/helpers/callWithProps.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,48 @@
import type { RawValue } from '$lib/types/index.js';
/**
* Extract keys from T that are setter methods (functions that accept a value and return something)
*/
type SetterKeys<T> = {
[K in keyof T]: T[K] extends (value: any) => any ? K : never;
}[keyof T];

type Setter = (v: any) => void;
/**
* Map setter methods to their parameter types
*/
type SetterProps<T> = {
[K in SetterKeys<T>]?: T[K] extends (value: infer V) => any ? V : never;
};

/**
* Helper function to call a D3 "function class" while also calling
* property setter functions on the result.
*
* @param d3func - A D3 factory function (e.g., d3.geoPath, d3.area, d3.line)
* @param args - Arguments to pass to the D3 factory function
* @param props - Object mapping setter method names to their values
* @returns The configured D3 object
*
* @example
* ```ts
* const areaGen = callWithProps(area, [], { x: d => d.x, y: d => d.y });
* const path = callWithProps(geoPath, [], { projection: myProjection });
* ```
*/
export default function <T extends object>(
d3func: (...args: RawValue[]) => T,
args: RawValue[] = [],
props: Record<string, RawValue> = {}
): T {
export default function callWithProps<
F extends (...args: any[]) => any,
R extends ReturnType<F>
>(
d3func: F,
args: Parameters<F> = [] as any,
props: Partial<SetterProps<R>> = {}
): R {
const res = d3func(...args);
const resWithKeys = res as Record<string, unknown>;
for (const [key, val] of Object.entries(props)) {
const setter = resWithKeys[key];
if (typeof setter !== 'function') throw new Error(`function ${key} does not exist`);
(setter as Setter)(val);
if (typeof setter !== 'function') {
throw new Error(`Setter function '${key}' does not exist on this d3 object`);
}
setter.call(res, val);
}
return res;
}
23 changes: 21 additions & 2 deletions src/lib/helpers/curves.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { CurveName } from '../types/index.js';
import type { AreaCurveName, CurveName } from '../types/index.js';
import {
curveBasis,
curveBasisClosed,
Expand Down Expand Up @@ -52,7 +52,26 @@ const curves = new Map<
['step-before', curveStepBefore]
]);

export function maybeCurve(curve: CurveName | CurveFactory = curveLinear, tension: number) {
/**
* Returns the appropriate D3 curve factory based on the curve name or custom factory.
* Supports optional tension parameter for curves that accept it (cardinal, catmull-rom).
*/
// overloads
// bundle curve only works with lines, not areas, so we want a more specific return type
export function maybeCurve(curve: 'bundle', tension?: number): CurveBundleFactory;
// all other curve factories are either of type CurveFactory or extend it
export function maybeCurve(
curve: AreaCurveName | CurveFactory,
tension?: number
): CurveFactory | CurveCardinalFactory | CurveCatmullRomFactory;
// catch-all overload (needed e.g. for custom curve factories)
export function maybeCurve(
curve?: CurveName | CurveFactory,
tension?: number
): CurveFactory | CurveBundleFactory | CurveCardinalFactory | CurveCatmullRomFactory;

// implementation
export function maybeCurve(curve: CurveName | CurveFactory = curveLinear, tension?: number) {
if (typeof curve === 'function') return curve; // custom curve
const c = curves.get(`${curve}`.toLowerCase() as CurveName);
if (!c) throw new Error(`unknown curve: ${curve}`);
Expand Down
8 changes: 4 additions & 4 deletions src/lib/marks/Area.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
y1?: ChannelAccessor<Datum>;
y2?: ChannelAccessor<Datum>;
z?: ChannelAccessor<Datum>;
curve?: CurveName | CurveFactory;
curve?: AreaCurveName | CurveFactory;
tension?: number;
sort?: ConstantAccessor<RawValue> | { channel: 'stroke' | 'fill' };
stack?: Partial<StackOptions>;
Expand All @@ -29,7 +29,7 @@
import Anchor from './helpers/Anchor.svelte';

import type {
CurveName,
AreaCurveName,
DataRecord,
BaseMarkProps,
ConstantAccessor,
Expand All @@ -47,15 +47,15 @@

const DEFAULTS = {
fill: 'currentColor',
curve: 'linear' as CurveName,
curve: 'linear' as AreaCurveName,
tension: 0,
...getPlotDefaults().area
};

const {
data = [{} as Datum],
/** the curve */
curve = 'linear' as CurveName,
curve = 'linear' as AreaCurveName,
tension = 0,
class: className = '',
areaClass,
Expand Down
5 changes: 5 additions & 0 deletions src/lib/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ export type CurveName =
| 'step-after'
| 'step-before';

/**
* Curve names that are compatible with area generators. Excludes 'bundle' which only works with lines.
*/
export type AreaCurveName = Exclude<CurveName, 'bundle'>;

export type MarkerOptions = {
/**
* the marker for the starting point of a line segment
Expand Down