Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/brown-pears-invent.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'layerchart': patch
---

fix(Axis): Support UTC scales (`xScale={scaleUtc()}`) by filtering and formatting ticks on the same boundaries as the scale
2 changes: 1 addition & 1 deletion docs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
"@layerstack/svelte-state": "0.1.0-next.23",
"@layerstack/svelte-table": "1.0.1-next.18",
"@layerstack/tailwind": "2.0.0-next.21",
"@layerstack/utils": "2.0.0-next.18",
"@layerstack/utils": "2.0.0-next.19",
"@napi-rs/canvas": "^0.1.97",
"@sveltejs/adapter-cloudflare": "^7.2.8",
"@sveltejs/kit": "^2.62.0",
Expand Down
36 changes: 32 additions & 4 deletions docs/src/content/components/Axis.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,42 @@ Controls the number of pixels allotted for each tick (higher => fewer ticks). Wo
Default: `80` for horizontal axes (top/bottom/angle) and `50` for vertical axes (left/right/radius).
::

:example{ name="linechart-tickspacing" showCode }
:example{ name="linechart-tickspacing" }

:example{ name="barchart-tickspacing" showCode }
:example{ name="barchart-tickspacing" }

::tip
See also: time scale [auto](/docs/components/Axis/time-scale-auto), [multiline](/docs/components/Axis/time-scale-auto-multiline), and [brush](/docs/components/Axis/time-scale-brush-multiline) examples
See [time scales](#time-scales) for how tick labels are chosen, and [brush](/docs/components/Axis/time-scale-brush-multiline) for tick spacing while zooming.
::

### time scales

With no `format`, tick labels are chosen automatically from the duration between ticks — a domain spanning years is labelled with years, one spanning a minute with seconds. Resizing the chart (or changing `tickSpacing`) changes the tick density, and the labels follow.

:example{ name="time-scale-auto" }

Passing an explicit `format` does two things: it labels the ticks, and it **filters** them to that boundary. A `day` format keeps only ticks landing exactly on a day, so a denser tick set never repeats the same label.

:example{ name="time-scale-auto-format-filtering" }

::note
Because the filter is what keeps labels unique, an explicit `format` can yield fewer ticks than `tickSpacing` alone would produce. If an axis renders no ticks, check that the format's boundary matches the values — see [UTC](#utc) for the common case.
::

`tickMultiline` splits the automatic labels across two lines (ex. day above, month below), fitting more context into the same width.

:example{ name="time-scale-auto-multiline" }

#### UTC

Ticks are filtered and labelled on the same boundaries the scale uses, so pass `xScale={scaleUtc()}` when your values are keyed on a UTC calendar date (ex. daily partitions) rather than on an instant. The default `scaleTime()` floors on _local_ boundaries, which sits one UTC offset away from each UTC day for every viewer outside UTC.

::note
This applies to the tick _labels_ as well — a `scaleUtc()` axis formats its ticks in UTC, so a tick at UTC midnight is never labelled with the previous local day.
::

:example{ name="utc-scale" }

### band scales

When creating time-series bar charts, it can be useful to use a time scale axis instead of a bar scale axis. This helps show gaps in data (such as on [weekends](/docs/components/BarChart/time-scale-interval)) and provides improved axis ticks.
Expand All @@ -33,7 +61,7 @@ To enable this, you must define the interval (daily, hourly, etc) of your data u

Since band padding is not available when not using a band scale, you can leverage `xInset={...}` to add padding between bars.

:example{ name="barchart-xinterval-xinset" showCode }
:example{ name="barchart-xinterval-xinset" }

<!-- ## Examples

Expand Down
37 changes: 37 additions & 0 deletions docs/src/examples/components/Axis/utc-scale.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<script lang="ts">
import { Axis, Chart, Layer } from 'layerchart';
import { scaleUtc } from 'd3-scale';
import { utcDay } from 'd3-time';

// A domain on UTC day boundaries — typical of values keyed on a UTC calendar date
// (ex. daily partitions) rather than on an instant.
const start = utcDay.floor(new Date());
const xDomain: [Date, Date] = [start, utcDay.offset(start, 7)];
</script>

<div class="grid gap-4">
<div>
<div class="text-sm font-semibold">UTC</div>
<div class="text-xs text-surface-content/50">
`scaleUtc()` floors and labels ticks on UTC boundaries, matching the data.
</div>
<Chart xScale={scaleUtc()} {xDomain} padding={24} height={48}>
<Layer>
<Axis placement="bottom" format={{ type: 'day', options: { variant: 'short' } }} rule />
</Layer>
</Chart>
</div>

<div>
<div class="text-sm font-semibold">Local time</div>
<div class="text-xs text-surface-content/50">
The default `scaleTime()` floors on local boundaries, so over the same domain its ticks sit
one UTC offset away from each UTC day.
</div>
<Chart {xDomain} padding={24} height={48}>
<Layer>
<Axis placement="bottom" format={{ type: 'day', options: { variant: 'short' } }} rule />
</Layer>
</Chart>
</div>
</div>
18 changes: 9 additions & 9 deletions packages/layerchart/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,15 @@
"prepublishOnly": "svelte-package",
"check": "svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-check --tsconfig ./tsconfig.json --watch",
"test:unit": "TZ=UTC-5 vitest",
"test:ui": "TZ=UTC-5 vitest --ui",
"test:unit": "vitest",
"test:ui": "vitest --ui",
"bench": "pnpm bench:primitives && pnpm bench:linechart && pnpm bench:composable && pnpm bench:svg-vs-canvas",
"bench:primitives": "TZ=UTC-5 vitest bench --project bench src/lib/bench/primitives.svelte.bench.ts",
"bench:linechart": "TZ=UTC-5 vitest bench --project bench src/lib/components/charts/LineChart.svelte.bench.ts",
"bench:composable": "TZ=UTC-5 vitest bench --project bench src/lib/bench/composable-vs-linechart.svelte.bench.ts",
"bench:svg-vs-canvas": "TZ=UTC-5 vitest bench --project bench src/lib/bench/svg-vs-canvas.svelte.bench.ts",
"bench:save": "TZ=UTC-5 vitest bench --project bench --outputJson bench-results/latest.json",
"bench:compare": "TZ=UTC-5 vitest bench --project bench --compare bench-results/latest.json",
"bench:primitives": "vitest bench --project bench src/lib/bench/primitives.svelte.bench.ts",
"bench:linechart": "vitest bench --project bench src/lib/components/charts/LineChart.svelte.bench.ts",
"bench:composable": "vitest bench --project bench src/lib/bench/composable-vs-linechart.svelte.bench.ts",
"bench:svg-vs-canvas": "vitest bench --project bench src/lib/bench/svg-vs-canvas.svelte.bench.ts",
"bench:save": "vitest bench --project bench --outputJson bench-results/latest.json",
"bench:compare": "vitest bench --project bench --compare bench-results/latest.json",
"lint": "prettier --check .",
"format": "prettier --write .",
"prepare": "svelte-kit sync"
Expand Down Expand Up @@ -79,7 +79,7 @@
"@layerstack/svelte-actions": "1.0.1-next.18",
"@layerstack/svelte-state": "0.1.0-next.23",
"@layerstack/tailwind": "2.0.0-next.21",
"@layerstack/utils": "2.0.0-next.18",
"@layerstack/utils": "2.0.0-next.19",
"@types/d3-contour": "^3.0.6",
"d3-array": "^3.2.4",
"d3-chord": "^3.0.1",
Expand Down
38 changes: 10 additions & 28 deletions packages/layerchart/src/lib/components/Axis/Axis.shared.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import type { SVGAttributes } from 'svelte/elements';

import { extent } from 'd3-array';
import { pointRadial } from 'd3-shape';
import { timeDay, timeHour, timeMillisecond, timeMinute, timeSecond, timeYear } from 'd3-time';

import { type FormatType, type FormatConfig, unique, PeriodType } from '@layerstack/utils';
import { cls } from '@layerstack/tailwind';
Expand All @@ -12,11 +11,16 @@ import type { Transition, TransitionParams, Without } from '$lib/utils/types.js'
import type { GroupProps } from '../Group/Group.shared.svelte.js';
import type { TextProps } from '../Text/Text.shared.svelte.js';
import type Rule from '../Rule/Rule.svelte';
import { isScaleBand } from '$lib/utils/scales.svelte.js';
import { isScaleBand, isScaleUtc } from '$lib/utils/scales.svelte.js';
import { getChartContext } from '$lib/contexts/chart.js';
import type { ChartState } from '$lib/states/chart.svelte.js';
import { type MotionProp } from '$lib/utils/motion.svelte.js';
import { autoTickVals, autoTickFormat, type TicksConfig } from '$lib/utils/ticks.js';
import {
autoTickVals,
autoTickFormat,
filterTicksByFormat,
type TicksConfig,
} from '$lib/utils/ticks.js';

export type AxisPropsWithoutHTML<In extends Transition = Transition> = {
/**
Expand Down Expand Up @@ -292,31 +296,9 @@ export class AxisState {
tickVals.pop();
}

// Use format to filter ticks (helpful to keep ticks above a threshold for wide charts or short durations)
const formatType =
typeof this.resolvedFormat === 'object' ? this.resolvedFormat?.type : this.resolvedFormat;

if (formatType === 'integer') {
tickVals = tickVals.filter(Number.isInteger);
} else if (formatType === 'year' || formatType === PeriodType.CalendarYear) {
tickVals = tickVals.filter((val) => +timeYear.floor(val) === +val);
} else if (
formatType === 'month' ||
formatType === PeriodType.Month ||
formatType === PeriodType.MonthYear
) {
tickVals = tickVals.filter((val) => val.getDate() < 7); // first week of the month
} else if (formatType === 'day' || formatType === PeriodType.Day) {
tickVals = tickVals.filter((val) => +timeDay.floor(val) === +val);
} else if (formatType === 'hour' || formatType === PeriodType.Hour) {
tickVals = tickVals.filter((val) => +timeHour.floor(val) === +val);
} else if (formatType === 'minute' || formatType === PeriodType.Minute) {
tickVals = tickVals.filter((val) => +timeMinute.floor(val) === +val);
} else if (formatType === 'second' || formatType === PeriodType.Second) {
tickVals = tickVals.filter((val) => +timeSecond.floor(val) === +val);
} else if (formatType === 'millisecond' || formatType === PeriodType.Millisecond) {
tickVals = tickVals.filter((val) => +timeMillisecond.floor(val) === +val);
}
// Filter to the boundary implied by the format, matching the scale's own (local vs UTC)
// day/hour/... boundaries — see `filterTicksByFormat`.
tickVals = filterTicksByFormat(tickVals, this.resolvedFormat, { utc: isScaleUtc(this.scale) });

return unique(tickVals);
});
Expand Down
30 changes: 28 additions & 2 deletions packages/layerchart/src/lib/utils/scales.svelte.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { describe, it, expect } from 'vitest';
import { scaleBand } from 'd3-scale';
import { scaleBand, scaleLinear, scaleTime, scaleUtc } from 'd3-scale';

import { scaleBandInvert, scaleInvert } from './scales.svelte.js';
import { isScaleUtc, scaleBandInvert, scaleInvert } from './scales.svelte.js';

describe('scaleBandInvert', () => {
const domain = ['A', 'B', 'C', 'D', 'E'];
Expand Down Expand Up @@ -83,3 +83,29 @@ describe('scaleInvert', () => {
expect(scaleInvert(scale, center)).toBe('B');
});
});

// The suite runs under a fixed non-zero offset (`TEST_TIMEZONE` in `vite.config.js`), so local and
// UTC boundaries differ.
describe('isScaleUtc', () => {
const domain = [new Date('2024-01-01T00:00:00Z'), new Date('2024-01-08T00:00:00Z')];

it('is true for scaleUtc', () => {
expect(isScaleUtc(scaleUtc().domain(domain) as any)).toBe(true);
});

it('is false for scaleTime', () => {
expect(isScaleUtc(scaleTime().domain(domain) as any)).toBe(false);
});

it('is false for non-time scales', () => {
expect(isScaleUtc(scaleLinear().domain([0, 10]) as any)).toBe(false);
expect(isScaleUtc(scaleBand().domain(['a', 'b']) as any)).toBe(false);
});

it('does not disturb the scale it probes', () => {
const scale = scaleUtc().domain(domain).range([0, 100]);
isScaleUtc(scale as any);
expect(scale.domain()).toEqual(domain);
expect(scale.range()).toEqual([0, 100]);
});
});
26 changes: 26 additions & 0 deletions packages/layerchart/src/lib/utils/scales.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,32 @@ export function isScaleTime(scale: AnyScale<any, any>): scale is ScaleTime<any,
return domain[0] instanceof Date || domain[1] instanceof Date;
}

/**
* Whether a time scale floors on UTC boundaries (`scaleUtc()`) rather than local ones
* (`scaleTime()`).
*
* d3 exposes no marker distinguishing the two, so probe the scale's own tick generator: ask a
* copy for daily ticks over a fixed multi-day window and check whether they land on UTC
* midnight. A local scale returns local midnights, which are only UTC midnight when the
* ambient offset is 0 — and there the distinction doesn't matter anyway.
*/
export function isScaleUtc(scale: AnyScale<any, any>): boolean {
if (!isScaleTime(scale)) return false;
if (typeof scale.ticks !== 'function' || typeof scale.copy !== 'function') return false;

const probe = scale
.copy()
.domain([new Date(Date.UTC(2024, 0, 1)), new Date(Date.UTC(2024, 0, 4))]);
const ticks: Date[] = probe.ticks(3);

return (
ticks.length > 0 &&
ticks.every(
(tick) => tick.getUTCHours() === 0 && tick.getUTCMinutes() === 0 && tick.getUTCSeconds() === 0
)
);
}

export function isScaleNumeric(scale: AnyScale<any, any>): scale is ScaleTime<any, any> {
const domain = scale.domain();
return typeof domain[0] === 'number' || typeof domain[1] === 'number';
Expand Down
65 changes: 64 additions & 1 deletion packages/layerchart/src/lib/utils/ticks.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { describe, it, expect, vi } from 'vitest';
import { autoTickVals } from './ticks.js';
import { scaleTime, scaleUtc } from 'd3-scale';
import { utcDay } from 'd3-time';
import { PeriodType } from '@layerstack/utils';

import { autoTickFormat, autoTickVals, filterTicksByFormat } from './ticks.js';
import type { TimeInterval } from 'd3-time';

// Mock helpers
Expand Down Expand Up @@ -79,3 +83,62 @@ describe('autoTickVals', () => {
expect(scale.ticks).toHaveBeenCalledWith(undefined);
});
});

// The suite runs under a fixed non-zero offset (`TZ=UTC-5`), so local and UTC boundaries differ.
describe('filterTicksByFormat', () => {
const utcMidnights = [
new Date('2024-01-01T00:00:00Z'),
new Date('2024-01-02T00:00:00Z'),
new Date('2024-01-03T00:00:00Z'),
];

it('keeps UTC-midnight ticks for a day format when utc is set', () => {
expect(filterTicksByFormat(utcMidnights, 'day', { utc: true })).toEqual(utcMidnights);
expect(filterTicksByFormat(utcMidnights, PeriodType.Day, { utc: true })).toEqual(utcMidnights);
});

it('drops UTC-midnight ticks for a day format when utc is not set', () => {
// Regression: filtering a `scaleUtc()` axis with local intervals removed every tick, so a
// day-formatted axis rendered no labels at all.
expect(filterTicksByFormat(utcMidnights, 'day')).toEqual([]);
});

it('keeps local-midnight ticks for a day format without utc', () => {
const localMidnights = [new Date(2024, 0, 1), new Date(2024, 0, 2)];
expect(filterTicksByFormat(localMidnights, 'day')).toEqual(localMidnights);
expect(filterTicksByFormat(localMidnights, 'day', { utc: true })).toEqual([]);
});

it('honours utc for month/year boundaries', () => {
const firstOfMonth = [new Date('2024-01-01T00:00:00Z'), new Date('2024-02-01T00:00:00Z')];
expect(filterTicksByFormat(firstOfMonth, 'month', { utc: true })).toEqual(firstOfMonth);
expect(filterTicksByFormat(firstOfMonth, 'year', { utc: true })).toEqual([firstOfMonth[0]]);
});

it('passes through unknown/absent format types', () => {
expect(filterTicksByFormat(utcMidnights, undefined)).toEqual(utcMidnights);
});

it('filters integers', () => {
expect(filterTicksByFormat([1, 1.5, 2], 'integer')).toEqual([1, 2]);
});
});

describe('autoTickFormat with a UTC scale', () => {
const start = new Date('2024-01-02T00:00:00Z');
const domain: [Date, Date] = [start, utcDay.offset(start, 3)];

it('labels a UTC-midnight tick with its UTC day', () => {
const scale = scaleUtc().domain(domain).range([0, 100]);
const fmt = autoTickFormat({ scale: scale as any, formatType: 'day', count: 3 });
expect(fmt(start, 0)).toContain('2');
// Under TZ=UTC-5 the local day of this instant is Jan 1 — the label must not say that.
expect(fmt(start, 0)).not.toContain('1/1');
});

it('still labels a local scale in local time', () => {
const scale = scaleTime().domain(domain).range([0, 100]);
const fmt = autoTickFormat({ scale: scale as any, formatType: 'day', count: 3 });
expect(typeof fmt(start, 0)).toBe('string');
});
});
Loading
Loading