Skip to content

Commit 399bc78

Browse files
authored
Axis tick enhancements (#615)
* Handle removing last tick within Axis and not resolveTickVals to remove confusing second interval argument * Rename `resolveTickVals` / `resolveTickFormat` to `autoTickVals` / `autoTickFormat` * fix(Axis): Filter distinct tick values (useful when manually injecting extra values) * Update LayerStack packages, notable utils with adding period types formats (hour, minute, second, millisecond) * feat(Axis): Use `format` to filter ticks (integer and date/time). Helpful to keep ticks above a threshold for wide charts or short durations. * Remove no longer applicable autoTickVals test (xInterval removing last tick is now handled by Axis) * docs: Adjust 6 month format example
1 parent 28e0d95 commit 399bc78

9 files changed

Lines changed: 233 additions & 73 deletions

File tree

.changeset/full-pens-cheat.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'layerchart': patch
3+
---
4+
5+
fix(Axis): Filter distinct tick values (useful when manually injecting extra values)

.changeset/loud-lies-film.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'layerchart': patch
3+
---
4+
5+
feat(Axis): Use `format` to filter ticks (integer and date/time). Helpful to keep ticks above a threshold for wide charts or short durations.

packages/layerchart/package.json

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -85,10 +85,10 @@
8585
"type": "module",
8686
"dependencies": {
8787
"@dagrejs/dagre": "^1.1.4",
88-
"@layerstack/svelte-actions": "1.0.1-next.12",
89-
"@layerstack/svelte-state": "0.1.0-next.17",
90-
"@layerstack/tailwind": "2.0.0-next.15",
91-
"@layerstack/utils": "2.0.0-next.12",
88+
"@layerstack/svelte-actions": "1.0.1-next.14",
89+
"@layerstack/svelte-state": "0.1.0-next.19",
90+
"@layerstack/tailwind": "2.0.0-next.17",
91+
"@layerstack/utils": "2.0.0-next.14",
9292
"d3-array": "^3.2.4",
9393
"d3-color": "^3.1.0",
9494
"d3-delaunay": "^6.0.4",

packages/layerchart/src/lib/components/Axis.svelte

Lines changed: 51 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -125,8 +125,17 @@
125125
126126
import { extent } from 'd3-array';
127127
import { pointRadial } from 'd3-shape';
128-
129-
import { type FormatType, type FormatConfig } from '@layerstack/utils';
128+
import {
129+
timeDay,
130+
timeHour,
131+
timeMillisecond,
132+
timeMinute,
133+
timeMonth,
134+
timeSecond,
135+
timeYear,
136+
} from 'd3-time';
137+
138+
import { type FormatType, type FormatConfig, unique, PeriodType } from '@layerstack/utils';
130139
import { cls } from '@layerstack/tailwind';
131140
132141
import Group, { type GroupProps } from './Group.svelte';
@@ -138,7 +147,7 @@
138147
import { getChartContext } from './Chart.svelte';
139148
import { extractLayerProps, layerClass } from '$lib/utils/attributes.js';
140149
import { type MotionProp } from '$lib/utils/motion.svelte.js';
141-
import { resolveTickFormat, resolveTickVals, type TicksConfig } from '$lib/utils/ticks.js';
150+
import { autoTickVals, autoTickFormat, type TicksConfig } from '$lib/utils/ticks.js';
142151
143152
let {
144153
placement,
@@ -209,9 +218,46 @@
209218
? Math.round(ctxSize / tickSpacing)
210219
: undefined
211220
);
212-
const tickVals = $derived(resolveTickVals(scale, ticks, tickCount, interval));
221+
const tickVals = $derived.by(() => {
222+
let tickVals = autoTickVals(scale, ticks, tickCount);
223+
224+
if (interval != null) {
225+
// Remove last tick when interval is provided (such as for bar charts with center aligned (offset) ticks)
226+
tickVals.pop();
227+
}
228+
229+
// Use format to filter ticks (helpful to keep ticks above a threshold for wide charts or short durations)
230+
const formatType = typeof format === 'object' ? format?.type : format;
231+
232+
if (formatType === 'integer') {
233+
tickVals = tickVals.filter(Number.isInteger);
234+
} else if (formatType === 'year' || formatType === PeriodType.CalendarYear) {
235+
tickVals = tickVals.filter((val) => +timeYear.floor(val) === +val);
236+
} else if (
237+
formatType === 'month' ||
238+
formatType === PeriodType.Month ||
239+
formatType === PeriodType.MonthYear
240+
) {
241+
// tickVals = tickVals.filter((val) => +timeMonth.floor(val) === +val);
242+
tickVals = tickVals.filter((val) => val.getDate() < 7); // first week of the month
243+
} else if (formatType === 'day' || formatType === PeriodType.Day) {
244+
tickVals = tickVals.filter((val) => +timeDay.floor(val) === +val);
245+
} else if (formatType === 'hour' || formatType === PeriodType.Hour) {
246+
tickVals = tickVals.filter((val) => +timeHour.floor(val) === +val);
247+
} else if (formatType === 'minute' || formatType === PeriodType.Minute) {
248+
tickVals = tickVals.filter((val) => +timeMinute.floor(val) === +val);
249+
} else if (formatType === 'second' || formatType === PeriodType.Second) {
250+
tickVals = tickVals.filter((val) => +timeSecond.floor(val) === +val);
251+
} else if (formatType === 'millisecond' || formatType === PeriodType.Millisecond) {
252+
tickVals = tickVals.filter((val) => +timeMillisecond.floor(val) === +val);
253+
}
254+
255+
// Remove any duplicates (manually added)
256+
return unique(tickVals);
257+
});
258+
213259
const tickFormat = $derived(
214-
resolveTickFormat({
260+
autoTickFormat({
215261
scale,
216262
ticks,
217263
count: tickCount,

packages/layerchart/src/lib/components/Grid.svelte

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@
9999
import Spline from './Spline.svelte';
100100
import { getChartContext } from './Chart.svelte';
101101
import { extractLayerProps, layerClass } from '$lib/utils/attributes.js';
102-
import { resolveTickVals, type TicksConfig } from '$lib/utils/ticks.js';
102+
import { autoTickVals, type TicksConfig } from '$lib/utils/ticks.js';
103103
104104
const ctx = getChartContext();
105105
@@ -131,8 +131,8 @@
131131
132132
const transitionIn = $derived((transitionInProp ?? tweenConfig?.options) ? fade : () => ({}));
133133
134-
const xTickVals = $derived(resolveTickVals(ctx.xScale, xTicks));
135-
const yTickVals = $derived(resolveTickVals(ctx.yScale, yTicks));
134+
const xTickVals = $derived(autoTickVals(ctx.xScale, xTicks));
135+
const yTickVals = $derived(autoTickVals(ctx.yScale, yTicks));
136136
137137
const xBandOffset = $derived(
138138
isScaleBand(ctx.xScale)
Lines changed: 12 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,75 +1,69 @@
11
import { describe, it, expect, vi } from 'vitest';
2-
import { resolveTickVals } from './ticks.js';
2+
import { autoTickVals } from './ticks.js';
33
import type { TimeInterval } from 'd3-time';
44

55
// Mock helpers
66
const mockTicksFn = vi.fn();
77
const mockDomain = vi.fn(() => ['a', 'b', 'c', 'd', 'e']);
88

9-
describe('resolveTickVals', () => {
9+
describe('autoTickVals', () => {
1010
it('returns array ticks directly', () => {
1111
const ticks = [1, 2, 3];
1212
const scale = { ticks: mockTicksFn } as any;
13-
expect(resolveTickVals(scale, ticks)).toEqual([1, 2, 3]);
13+
expect(autoTickVals(scale, ticks)).toEqual([1, 2, 3]);
1414
});
1515

1616
it('calls function ticks with scale', () => {
1717
const fnTicks = vi.fn(() => [4, 5, 6]);
1818
const scale = { ticks: mockTicksFn } as any;
19-
expect(resolveTickVals(scale, fnTicks)).toEqual([4, 5, 6]);
19+
expect(autoTickVals(scale, fnTicks)).toEqual([4, 5, 6]);
2020
expect(fnTicks).toHaveBeenCalledWith(scale);
2121
});
2222

2323
it('uses interval when provided', () => {
2424
const interval = { every: vi.fn() } as unknown as TimeInterval;
2525
const ticksConfig = { interval };
2626
const scale = { ticks: vi.fn(() => [7, 8, 9]) } as any;
27-
expect(resolveTickVals(scale, ticksConfig)).toEqual([7, 8, 9]);
27+
expect(autoTickVals(scale, ticksConfig)).toEqual([7, 8, 9]);
2828
expect(scale.ticks).toHaveBeenCalledWith(interval);
2929
});
3030

3131
it('returns empty array if interval is null', () => {
3232
const ticksConfig = { interval: null };
3333
const scale = { ticks: mockTicksFn } as any;
34-
expect(resolveTickVals(scale, ticksConfig)).toEqual([]);
34+
expect(autoTickVals(scale, ticksConfig)).toEqual([]);
3535
});
3636

3737
it('filters band scale domain with number ticks', () => {
3838
const scale = { domain: mockDomain, bandwidth: vi.fn() } as any;
39-
expect(resolveTickVals(scale, 2)).toEqual(['a', 'c', 'e']);
39+
expect(autoTickVals(scale, 2)).toEqual(['a', 'c', 'e']);
4040
});
4141

4242
it('returns full domain for band scale without ticks', () => {
4343
const scale = { domain: mockDomain, bandwidth: vi.fn() } as any;
44-
expect(resolveTickVals(scale)).toEqual(['a', 'b', 'c', 'd', 'e']);
44+
expect(autoTickVals(scale)).toEqual(['a', 'b', 'c', 'd', 'e']);
4545
});
4646

4747
it('uses undefined for non-left/right placement', () => {
4848
const scale = { domain: mockDomain, ticks: vi.fn(() => [1, 2]) } as any;
49-
expect(resolveTickVals(scale, undefined, undefined)).toEqual([1, 2]);
49+
expect(autoTickVals(scale, undefined, undefined)).toEqual([1, 2]);
5050
expect(scale.ticks).toHaveBeenCalledWith(undefined);
5151
});
5252

5353
it('passes number ticks to scale.ticks', () => {
5454
const scale = { domain: mockDomain, ticks: vi.fn(() => [10, 20]) } as any;
55-
expect(resolveTickVals(scale, 5)).toEqual([10, 20]);
55+
expect(autoTickVals(scale, 5)).toEqual([10, 20]);
5656
expect(scale.ticks).toHaveBeenCalledWith(5);
5757
});
5858

5959
it('returns empty array for scale without ticks', () => {
6060
const scale = { domain: mockDomain } as any;
61-
expect(resolveTickVals(scale, 5)).toEqual([]);
61+
expect(autoTickVals(scale, 5)).toEqual([]);
6262
});
6363

6464
it('handles null ticks with placement', () => {
6565
const scale = { domain: mockDomain, ticks: vi.fn(() => [1, 2, 3]) } as any;
66-
expect(resolveTickVals(scale, null, undefined)).toEqual([1, 2, 3]);
66+
expect(autoTickVals(scale, null, undefined)).toEqual([1, 2, 3]);
6767
expect(scale.ticks).toHaveBeenCalledWith(undefined);
6868
});
69-
70-
it('removes last tick when interval is provided', () => {
71-
const interval = { every: vi.fn() } as unknown as TimeInterval;
72-
const scale = { ticks: vi.fn(() => [1, 2, 3, 4]) } as any;
73-
expect(resolveTickVals(scale, undefined, undefined, interval)).toEqual([1, 2, 3]);
74-
});
7569
});

packages/layerchart/src/lib/utils/ticks.ts

Lines changed: 3 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -122,12 +122,7 @@ export type TicksConfig =
122122
| { interval: TimeInterval | null }
123123
| null;
124124

125-
export function resolveTickVals(
126-
scale: AnyScale,
127-
ticks?: TicksConfig,
128-
count?: number,
129-
interval?: TimeInterval | null
130-
): any[] {
125+
export function autoTickVals(scale: AnyScale, ticks?: TicksConfig, count?: number): any[] {
131126
// Explicit ticks
132127
if (Array.isArray(ticks)) return ticks;
133128

@@ -151,20 +146,13 @@ export function resolveTickVals(
151146

152147
// Ticks from scale
153148
if (scale.ticks && typeof scale.ticks === 'function') {
154-
const tickVals = scale.ticks(count ?? (typeof ticks === 'number' ? ticks : undefined));
155-
156-
if (interval) {
157-
// Remove last tick when interval is provided (such as for bar charts with center aligned (offset) ticks)
158-
tickVals.pop();
159-
}
160-
161-
return tickVals;
149+
return scale.ticks(count ?? (typeof ticks === 'number' ? ticks : undefined));
162150
}
163151

164152
return [];
165153
}
166154

167-
export function resolveTickFormat(options: {
155+
export function autoTickFormat(options: {
168156
scale: AnyScale;
169157
ticks?: TicksConfig;
170158
count?: number;

0 commit comments

Comments
 (0)