From 4170fcba8e6fe35b01df5586a48e07df0638afe8 Mon Sep 17 00:00:00 2001 From: Moritz Leidinger Date: Tue, 6 Jan 2026 17:45:39 +0100 Subject: [PATCH 1/4] add type AreaCurveNames and overloads to maybeCurves --- src/lib/helpers/curves.ts | 26 ++++++++++++++++++++++++-- src/lib/marks/Area.svelte | 8 ++++---- src/lib/types/index.ts | 5 +++++ 3 files changed, 33 insertions(+), 6 deletions(-) diff --git a/src/lib/helpers/curves.ts b/src/lib/helpers/curves.ts index 9f103e4d..46f7c09d 100644 --- a/src/lib/helpers/curves.ts +++ b/src/lib/helpers/curves.ts @@ -1,4 +1,4 @@ -import type { CurveName } from '../types/index.js'; +import type { AreaCurveName, CurveName } from '../types/index.js'; import { curveBasis, curveBasisClosed, @@ -52,7 +52,29 @@ 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 +export function maybeCurve(curve: 'bundle', tension?: number): CurveBundleFactory; +export function maybeCurve( + curve: 'cardinal' | 'cardinal-closed' | 'cardinal-open', + tension?: number +): CurveCardinalFactory; +export function maybeCurve( + curve: 'catmull-rom' | 'catmull-rom-closed' | 'catmull-rom-open', + tension?: number +): CurveCatmullRomFactory; +export function maybeCurve(curve?: AreaCurveName, tension?: number): CurveFactory; +export function maybeCurve(curve: CurveFactory, tension?: number): CurveFactory; +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}`); diff --git a/src/lib/marks/Area.svelte b/src/lib/marks/Area.svelte index 6e06b6fd..e608a1a8 100644 --- a/src/lib/marks/Area.svelte +++ b/src/lib/marks/Area.svelte @@ -9,7 +9,7 @@ y1?: ChannelAccessor; y2?: ChannelAccessor; z?: ChannelAccessor; - curve?: CurveName | CurveFactory; + curve?: AreaCurveName | CurveFactory; tension?: number; sort?: ConstantAccessor | { channel: 'stroke' | 'fill' }; stack?: Partial; @@ -29,7 +29,7 @@ import Anchor from './helpers/Anchor.svelte'; import type { - CurveName, + AreaCurveName, DataRecord, BaseMarkProps, ConstantAccessor, @@ -47,7 +47,7 @@ const DEFAULTS = { fill: 'currentColor', - curve: 'linear' as CurveName, + curve: 'linear' as AreaCurveName, tension: 0, ...getPlotDefaults().area }; @@ -55,7 +55,7 @@ const { data = [{} as Datum], /** the curve */ - curve = 'linear' as CurveName, + curve = 'linear' as AreaCurveName, tension = 0, class: className = '', areaClass, diff --git a/src/lib/types/index.ts b/src/lib/types/index.ts index 41c61b6b..0cee6650 100644 --- a/src/lib/types/index.ts +++ b/src/lib/types/index.ts @@ -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; + export type MarkerOptions = { /** * the marker for the starting point of a line segment From de0330053b9cf7bd2a9670f90f64bd3f354ae7c0 Mon Sep 17 00:00:00 2001 From: Moritz Leidinger Date: Tue, 6 Jan 2026 17:45:59 +0100 Subject: [PATCH 2/4] add type improvements to callWithProps --- src/lib/helpers/callWithProps.ts | 44 +++++++++++++++++++++++++------- 1 file changed, 35 insertions(+), 9 deletions(-) diff --git a/src/lib/helpers/callWithProps.ts b/src/lib/helpers/callWithProps.ts index 1c5f7341..9c55dd58 100644 --- a/src/lib/helpers/callWithProps.ts +++ b/src/lib/helpers/callWithProps.ts @@ -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 = { + [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 = { + [K in SetterKeys]?: 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 ( - d3func: (...args: RawValue[]) => T, - args: RawValue[] = [], - props: Record = {} -): T { +export default function callWithProps< + F extends (...args: any[]) => any, + R extends ReturnType +>( + d3func: F, + args: Parameters = [] as any, + props: Partial> = {} +): R { const res = d3func(...args); const resWithKeys = res as Record; 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; } From dbb20b27b4edd03d2936fe6de65720710b20fcbe Mon Sep 17 00:00:00 2001 From: Moritz Leidinger Date: Tue, 6 Jan 2026 18:05:50 +0100 Subject: [PATCH 3/4] combine some overloads to make code less verbose --- src/lib/helpers/curves.ts | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/src/lib/helpers/curves.ts b/src/lib/helpers/curves.ts index 46f7c09d..21c6c733 100644 --- a/src/lib/helpers/curves.ts +++ b/src/lib/helpers/curves.ts @@ -57,17 +57,14 @@ const curves = new Map< * 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: 'cardinal' | 'cardinal-closed' | 'cardinal-open', + curve: AreaCurveName | CurveFactory, tension?: number -): CurveCardinalFactory; -export function maybeCurve( - curve: 'catmull-rom' | 'catmull-rom-closed' | 'catmull-rom-open', - tension?: number -): CurveCatmullRomFactory; -export function maybeCurve(curve?: AreaCurveName, tension?: number): CurveFactory; -export function maybeCurve(curve: CurveFactory, tension?: number): CurveFactory; +): CurveFactory | CurveCardinalFactory | CurveCatmullRomFactory; +// catch-all overload (needed e.g. for custom curve factories) export function maybeCurve( curve?: CurveName | CurveFactory, tension?: number From 9597bd2d1aa1a4c321998f56d9a9e0960f76790e Mon Sep 17 00:00:00 2001 From: Moritz Leidinger Date: Tue, 6 Jan 2026 18:08:41 +0100 Subject: [PATCH 4/4] let claude add some tests for callWithProps (quality unknown) --- src/lib/helpers/callWithProps.test.ts | 141 ++++++++++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 src/lib/helpers/callWithProps.test.ts diff --git a/src/lib/helpers/callWithProps.test.ts b/src/lib/helpers/callWithProps.test.ts new file mode 100644 index 00000000..b7d74e57 --- /dev/null +++ b/src/lib/helpers/callWithProps.test.ts @@ -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(); + }); +});