diff --git a/.changeset/explicit-d3-capabilities.md b/.changeset/explicit-d3-capabilities.md new file mode 100644 index 00000000..870f6613 --- /dev/null +++ b/.changeset/explicit-d3-capabilities.md @@ -0,0 +1,14 @@ +--- +'@tanstack/charts': patch +'@tanstack/charts-scales': patch +--- + +Remove incidental `d3-array` usage from nearest-point lookup, quantile legend +thresholds, and compact numeric ticks. These paths now use package-owned +implementations with D3 parity coverage, and compact scales no longer have a +production D3 dependency. + +Numeric-bin and stack transforms, polar and curve features, and geo features +continue to own their tree-shakable `d3-array`, `d3-shape`, or `d3-geo` +implementations as normal dependencies. No peer dependency, caller-supplied +capability, or public API migration is required. diff --git a/API-FRICTION.md b/API-FRICTION.md index b7383c2b..10343d2f 100644 --- a/API-FRICTION.md +++ b/API-FRICTION.md @@ -45,7 +45,7 @@ Each entry records: | F-007 | Runtime and adapters bypassed strict scales | API | resolved | | F-008 | D3 motion would currently burden every DOM host | API | resolved | | F-009 | Color semantics were overloaded onto grouping and paint | API | resolved | -| F-010 | D3 curves require one TanStack grammar bridge | API | monitoring | +| F-010 | D3 curves require one TanStack grammar bridge | API | resolved | | F-011 | Adapters performed dynamic preparation twice | API | resolved | | F-012 | Render callbacks omit diagnostic metrics | API | monitoring | | F-013 | Bar series identity also changed bar geometry | API | resolved | @@ -200,6 +200,7 @@ Each entry records: | F-162 | Focus styling required duplicate marks | API | resolved | | F-163 | Cross-row transforms lacked a public ownership boundary | API | resolved | | F-164 | Sankey widths required a custom scene renderer | API | resolved | +| F-165 | Incidental D3 utilities leaked into core paths | API/Tooling | resolved | ## Findings @@ -417,15 +418,18 @@ Each entry records: ### F-010 — D3 curves require one TanStack grammar bridge -- Status: monitoring +- Status: resolved - Severity: low - Observed in: optional curve integration - Friction: authors write `d3Curve(curveMonotoneX)` instead of supplying the D3 curve factory directly. -- Decision: keep the bridge while it prevents straight lines and areas from - importing `d3-shape` and gives one curve value both line and area semantics. -- Follow-up: measure authoring errors before considering a direct curve-factory - overload or a separate curved mark. +- Decision: keep the bridge while it gives one curve value both line and area + semantics. Keep the convenient root and universal exports alongside the exact + `@tanstack/charts/d3/shape` entry; supported bundlers remove the bridge and + its D3 line and area generators when unused. +- Verification: direct D3 curve entries, Cartesian curve tests, packed + declarations, and documentation examples cover both barrel and exact-subpath + forms. ### F-011 — Adapters performed dynamic preparation twice on mount @@ -3957,3 +3961,28 @@ Each entry records: graph passes its type, package, bundle, documentation, catalog, and framework gates. Browser conformance passes both Sankey cases at 320px and 640px with clean types and 98.3% mean frame-relative geometry similarity. + +### F-165 — Incidental D3 utilities leaked into core paths + +- Status: resolved +- Severity: high +- Owner: API/Tooling +- Observed in: auditing whether ordinary chart paths retain incidental D3 +- Friction: nearest-point lookup and legend thresholds used small `d3-array` + utilities, while compact linear scales imported D3 tick math. Those imports + leaked D3 into common paths despite being easy to own locally. Numeric-bin, + stack, polar, geo, and curve modules are deliberately D3-backed and expose + strict D3 semantics rather than credible interchangeable APIs. +- Decision: implement nearest-point, quantile, and compact tick math locally. + Keep `d3-array` inside numeric-bin transforms, `d3-shape` inside stack, + polar, and curve features, and `d3-geo` inside geo features as normal + dependencies, not peers or caller-supplied `use` capabilities. Keep the + tree-shakable D3 curve bridges available from the root, universal, and exact + barrels. Compact scales declare no production D3 dependency. +- Verification: core, geo, polar, curve, compact-scale, and type-contract tests + pass. The independently structured tick helpers match `d3-array` across + fixed edge cases and 2,000 deterministic generated domains. Bundle + retained-input gates reject D3 geometry from ordinary root consumers and + every `d3-*` module plus `internmap` from compact consumers, while selected + transform, polar, geo, and curve features retain their owned D3 + implementation. diff --git a/benchmarks/bundle-size/universal-baseline.json b/benchmarks/bundle-size/universal-baseline.json index 67749703..ec3e2747 100644 --- a/benchmarks/bundle-size/universal-baseline.json +++ b/benchmarks/bundle-size/universal-baseline.json @@ -4,35 +4,35 @@ "bundles": { "D3-scale line scene": { "bytes": 39086, - "gzip": 15178 + "gzip": 15175 }, "D3-scale line + static SVG": { "bytes": 42027, - "gzip": 16267 + "gzip": 16268 }, "Representative marks": { "bytes": 57688, - "gzip": 21392 + "gzip": 21428 }, "TanStack DOM host": { - "bytes": 39048, - "gzip": 14334 + "bytes": 38770, + "gzip": 14229 }, "React adapter": { - "bytes": 41216, - "gzip": 15037 + "bytes": 40937, + "gzip": 14924 }, "React line consumer": { - "bytes": 63725, - "gzip": 24054 + "bytes": 63516, + "gzip": 23973 }, "Compact-scale line scene": { - "bytes": 21523, - "gzip": 8112 + "bytes": 21550, + "gzip": 8118 }, "React compact-scale line consumer": { - "bytes": 46285, - "gzip": 16986 + "bytes": 46033, + "gzip": 16876 }, "Custom-scale line scene": { "bytes": 19734, @@ -40,7 +40,7 @@ }, "D3 linear-scale line scene": { "bytes": 39018, - "gzip": 15142 + "gzip": 15139 } } } diff --git a/benchmarks/comparison/bundle-baseline.json b/benchmarks/comparison/bundle-baseline.json index 31d0e5fd..584297c9 100644 --- a/benchmarks/comparison/bundle-baseline.json +++ b/benchmarks/comparison/bundle-baseline.json @@ -1,8 +1,8 @@ { "schemaVersion": 3, - "generatedAt": "2026-07-31T21:26:25.210Z", + "generatedAt": "2026-07-31T21:48:07.702Z", "packageVersions": { - "tanstack": "0.2.0", + "tanstack": "0.3.0", "chartjs": "4.5.1", "echarts": "6.1.0", "recharts": "3.10.1", @@ -11,7 +11,7 @@ "sources": { "tanstack": { "kind": "workspace", - "revision": "e997c89bb84d1d468c12e58e44bd45c6d50fd5c3" + "revision": "c422a2ce45799d4edd63fdbde7ecb31daa3dae31" }, "chartjs": { "kind": "package", @@ -44,88 +44,88 @@ }, "bundles": { "tanstack-line-basic": { - "minifiedBytes": 72320, - "gzipBytes": 27357, - "brotliBytes": 24261, - "incrementalGzipBytes": 27357, - "incrementalBrotliBytes": 24261 + "minifiedBytes": 72114, + "gzipBytes": 27318, + "brotliBytes": 24182, + "incrementalGzipBytes": 27318, + "incrementalBrotliBytes": 24182 }, "tanstack-line-interactive": { - "minifiedBytes": 77498, - "gzipBytes": 29075, - "brotliBytes": 25671, - "incrementalGzipBytes": 29075, - "incrementalBrotliBytes": 25671 + "minifiedBytes": 77285, + "gzipBytes": 28970, + "brotliBytes": 25539, + "incrementalGzipBytes": 28970, + "incrementalBrotliBytes": 25539 }, "tanstack-line-advanced": { - "minifiedBytes": 84679, - "gzipBytes": 31380, - "brotliBytes": 27671, - "incrementalGzipBytes": 31380, - "incrementalBrotliBytes": 27671 + "minifiedBytes": 84466, + "gzipBytes": 31307, + "brotliBytes": 27553, + "incrementalGzipBytes": 31307, + "incrementalBrotliBytes": 27553 }, "tanstack-bar-basic": { - "minifiedBytes": 79327, - "gzipBytes": 30044, - "brotliBytes": 26537, - "incrementalGzipBytes": 30044, - "incrementalBrotliBytes": 26537 + "minifiedBytes": 79117, + "gzipBytes": 29968, + "brotliBytes": 26390, + "incrementalGzipBytes": 29968, + "incrementalBrotliBytes": 26390 }, "tanstack-bar-interactive": { - "minifiedBytes": 83364, - "gzipBytes": 31284, - "brotliBytes": 27529, - "incrementalGzipBytes": 31284, - "incrementalBrotliBytes": 27529 + "minifiedBytes": 83147, + "gzipBytes": 31184, + "brotliBytes": 27466, + "incrementalGzipBytes": 31184, + "incrementalBrotliBytes": 27466 }, "tanstack-bar-advanced": { - "minifiedBytes": 83703, - "gzipBytes": 31434, - "brotliBytes": 27623, - "incrementalGzipBytes": 31434, - "incrementalBrotliBytes": 27623 + "minifiedBytes": 83486, + "gzipBytes": 31333, + "brotliBytes": 27583, + "incrementalGzipBytes": 31333, + "incrementalBrotliBytes": 27583 }, "tanstack-area-basic": { - "minifiedBytes": 76134, - "gzipBytes": 28817, - "brotliBytes": 25536, - "incrementalGzipBytes": 28817, - "incrementalBrotliBytes": 25536 + "minifiedBytes": 75923, + "gzipBytes": 28763, + "brotliBytes": 25453, + "incrementalGzipBytes": 28763, + "incrementalBrotliBytes": 25453 }, "tanstack-area-interactive": { - "minifiedBytes": 81316, - "gzipBytes": 30565, - "brotliBytes": 26893, - "incrementalGzipBytes": 30565, - "incrementalBrotliBytes": 26893 + "minifiedBytes": 81098, + "gzipBytes": 30458, + "brotliBytes": 26812, + "incrementalGzipBytes": 30458, + "incrementalBrotliBytes": 26812 }, "tanstack-area-advanced": { - "minifiedBytes": 88679, - "gzipBytes": 32946, - "brotliBytes": 28983, - "incrementalGzipBytes": 32946, - "incrementalBrotliBytes": 28983 + "minifiedBytes": 88465, + "gzipBytes": 32852, + "brotliBytes": 28903, + "incrementalGzipBytes": 32852, + "incrementalBrotliBytes": 28903 }, "tanstack-scatter-basic": { - "minifiedBytes": 72025, - "gzipBytes": 27255, - "brotliBytes": 24145, - "incrementalGzipBytes": 27255, - "incrementalBrotliBytes": 24145 + "minifiedBytes": 71819, + "gzipBytes": 27220, + "brotliBytes": 24141, + "incrementalGzipBytes": 27220, + "incrementalBrotliBytes": 24141 }, "tanstack-scatter-interactive": { - "minifiedBytes": 77203, - "gzipBytes": 28999, - "brotliBytes": 25586, - "incrementalGzipBytes": 28999, - "incrementalBrotliBytes": 25586 + "minifiedBytes": 76990, + "gzipBytes": 28892, + "brotliBytes": 25445, + "incrementalGzipBytes": 28892, + "incrementalBrotliBytes": 25445 }, "tanstack-scatter-advanced": { - "minifiedBytes": 77219, - "gzipBytes": 29004, - "brotliBytes": 25583, - "incrementalGzipBytes": 29004, - "incrementalBrotliBytes": 25583 + "minifiedBytes": 77006, + "gzipBytes": 28897, + "brotliBytes": 25468, + "incrementalGzipBytes": 28897, + "incrementalBrotliBytes": 25468 }, "chartjs-line-basic": { "minifiedBytes": 137909, diff --git a/docs/comparison.md b/docs/comparison.md index 533cc0ff..a7ddb4af 100644 --- a/docs/comparison.md +++ b/docs/comparison.md @@ -12,14 +12,14 @@ evidence without turning untested behavior into a checkmark. | Library | Package | Measured source | | -------------------------------------------------------------------------------------- | -------------------- | ------------------- | -| [TanStack Charts](./overview.md) | `@tanstack/charts` | workspace `e997c89` | +| [TanStack Charts](./overview.md) | `@tanstack/charts` | workspace `c422a2c` | | [Chart.js](https://www.chartjs.org/docs/latest/) | `chart.js` | npm `4.5.1` | | [Apache ECharts](https://echarts.apache.org/handbook/en/best-practices/canvas-vs-svg/) | `echarts` | npm `6.1.0` | | [Recharts](https://recharts.github.io/en-US/) | `recharts` | npm `3.10.1` | | [Observable Plot](https://observablehq.com/plot/features/plots) | `@observablehq/plot` | npm `0.6.17` | The competitor versions are exact package pins, not latest versions inferred -at page render time. The measured TanStack workspace revision is `e997c89`. +at page render time. The measured TanStack workspace revision is `c422a2c`. ## Capability matrix @@ -59,7 +59,7 @@ that lane externalizes React and React DOM. | Library | Full cold-page gzip | React externalized | | --------------- | ------------------: | -----------------: | -| TanStack Charts | 26.62–32.17 KiB | — | +| TanStack Charts | 26.58–32.08 KiB | — | | Chart.js | 44.70–58.21 KiB | — | | Apache ECharts | 153.10–173.18 KiB | — | | Recharts | 153.08–168.27 KiB | 94.96–109.96 KiB | diff --git a/docs/concepts/scales-and-d3.md b/docs/concepts/scales-and-d3.md index 876108bc..cdee1589 100644 --- a/docs/concepts/scales-and-d3.md +++ b/docs/concepts/scales-and-d3.md @@ -13,6 +13,12 @@ TanStack Charts uses an explicit algorithm layer: Both scale implementations use callable, copyable scale objects. There is no hidden D3 umbrella import. +`@tanstack/charts` declares `d3-array`, `d3-shape`, and `d3-geo` because its +numeric-bin and stack transforms, polar and D3 curve features, and geo features +own those implementations. They are not peers and require no `use` +configuration. Bundlers tree-shake unused algorithms and geometry, and exact +feature subpaths remain available when an application wants a narrower import. + ## Direct dependency ownership If application source imports a `d3-*` module, declare that module and its matching TypeScript package directly: diff --git a/docs/installation.md b/docs/installation.md index cb6b6db4..ece91f6c 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -69,6 +69,12 @@ the output of D3 transforms directly. Your application must declare every `d3-*` module that its source imports. Strict package managers do not expose transitive dependencies as an application import contract. +The core package declares the `d3-array`, `d3-shape`, and `d3-geo` +implementations owned by its numeric-bin and stack transforms, polar and D3 +curve features, and geo features. They are normal dependencies, not peer +requirements, and bundlers remove unused algorithms and geometry from +application bundles. + A typical cartesian chart uses: ```sh diff --git a/packages/charts-core/docs/comparison.md b/packages/charts-core/docs/comparison.md index 533cc0ff..a7ddb4af 100644 --- a/packages/charts-core/docs/comparison.md +++ b/packages/charts-core/docs/comparison.md @@ -12,14 +12,14 @@ evidence without turning untested behavior into a checkmark. | Library | Package | Measured source | | -------------------------------------------------------------------------------------- | -------------------- | ------------------- | -| [TanStack Charts](./overview.md) | `@tanstack/charts` | workspace `e997c89` | +| [TanStack Charts](./overview.md) | `@tanstack/charts` | workspace `c422a2c` | | [Chart.js](https://www.chartjs.org/docs/latest/) | `chart.js` | npm `4.5.1` | | [Apache ECharts](https://echarts.apache.org/handbook/en/best-practices/canvas-vs-svg/) | `echarts` | npm `6.1.0` | | [Recharts](https://recharts.github.io/en-US/) | `recharts` | npm `3.10.1` | | [Observable Plot](https://observablehq.com/plot/features/plots) | `@observablehq/plot` | npm `0.6.17` | The competitor versions are exact package pins, not latest versions inferred -at page render time. The measured TanStack workspace revision is `e997c89`. +at page render time. The measured TanStack workspace revision is `c422a2c`. ## Capability matrix @@ -59,7 +59,7 @@ that lane externalizes React and React DOM. | Library | Full cold-page gzip | React externalized | | --------------- | ------------------: | -----------------: | -| TanStack Charts | 26.62–32.17 KiB | — | +| TanStack Charts | 26.58–32.08 KiB | — | | Chart.js | 44.70–58.21 KiB | — | | Apache ECharts | 153.10–173.18 KiB | — | | Recharts | 153.08–168.27 KiB | 94.96–109.96 KiB | diff --git a/packages/charts-core/docs/concepts/scales-and-d3.md b/packages/charts-core/docs/concepts/scales-and-d3.md index 876108bc..cdee1589 100644 --- a/packages/charts-core/docs/concepts/scales-and-d3.md +++ b/packages/charts-core/docs/concepts/scales-and-d3.md @@ -13,6 +13,12 @@ TanStack Charts uses an explicit algorithm layer: Both scale implementations use callable, copyable scale objects. There is no hidden D3 umbrella import. +`@tanstack/charts` declares `d3-array`, `d3-shape`, and `d3-geo` because its +numeric-bin and stack transforms, polar and D3 curve features, and geo features +own those implementations. They are not peers and require no `use` +configuration. Bundlers tree-shake unused algorithms and geometry, and exact +feature subpaths remain available when an application wants a narrower import. + ## Direct dependency ownership If application source imports a `d3-*` module, declare that module and its matching TypeScript package directly: diff --git a/packages/charts-core/docs/installation.md b/packages/charts-core/docs/installation.md index cb6b6db4..ece91f6c 100644 --- a/packages/charts-core/docs/installation.md +++ b/packages/charts-core/docs/installation.md @@ -69,6 +69,12 @@ the output of D3 transforms directly. Your application must declare every `d3-*` module that its source imports. Strict package managers do not expose transitive dependencies as an application import contract. +The core package declares the `d3-array`, `d3-shape`, and `d3-geo` +implementations owned by its numeric-bin and stack transforms, polar and D3 +curve features, and geo features. They are normal dependencies, not peer +requirements, and bundlers remove unused algorithms and geometry from +application bundles. + A typical cartesian chart uses: ```sh diff --git a/packages/charts-core/src/exports.test.ts b/packages/charts-core/src/exports.test.ts index 45eb2239..c99e2ee7 100644 --- a/packages/charts-core/src/exports.test.ts +++ b/packages/charts-core/src/exports.test.ts @@ -55,4 +55,20 @@ describe('public package exports', () => { expect(tooltipModule.tooltip.id).toBe('tooltip') expect(portalModule.portal.id).toBe('portal') }) + + it('keeps D3 curve bridges available from barrels and exact subpaths', async () => { + const [root, universal, shape, areaX] = await Promise.all([ + import('@tanstack/charts'), + import('@tanstack/charts/universal'), + import('@tanstack/charts/d3/shape'), + import('@tanstack/charts/d3/area-x'), + ]) + + expect(root).toHaveProperty('d3Curve') + expect(root).toHaveProperty('d3AreaXCurve') + expect(universal).toHaveProperty('d3Curve') + expect(universal).toHaveProperty('d3AreaXCurve') + expect(shape).toHaveProperty('d3Curve') + expect(areaX).toHaveProperty('d3AreaXCurve') + }) }) diff --git a/packages/charts-core/src/legend.ts b/packages/charts-core/src/legend.ts index 3a5ea769..f69b16a1 100644 --- a/packages/charts-core/src/legend.ts +++ b/packages/charts-core/src/legend.ts @@ -1,4 +1,3 @@ -import { quantileSorted } from 'd3-array' import { valueKey } from './scales' import type { ChartColorLegend, @@ -225,15 +224,25 @@ function legendThresholds( ) } if (colors.kind === 'quantile') { - return Array.from( - { length: Math.max(0, count - 1) }, - (_value, index) => - quantileSorted(domain, (index + 1) / count) ?? Number.NaN, + return Array.from({ length: Math.max(0, count - 1) }, (_value, index) => + quantileSorted(domain, (index + 1) / count), ).filter(Number.isFinite) } return [] } +function quantileSorted(values: readonly number[], probability: number) { + const count = values.length + if (count === 0) return Number.NaN + if (probability <= 0 || count < 2) return values[0] ?? Number.NaN + if (probability >= 1) return values[count - 1] ?? Number.NaN + const position = (count - 1) * probability + const lowerIndex = Math.floor(position) + const lower = values[lowerIndex] ?? Number.NaN + const upper = values[lowerIndex + 1] ?? lower + return lower + (upper - lower) * (position - lowerIndex) +} + export function colorGradientLegend( options: ColorGradientLegendOptions = {}, ): ChartColorLegend { diff --git a/packages/charts-core/src/nearest.ts b/packages/charts-core/src/nearest.ts index 286f439a..c5ef91e6 100644 --- a/packages/charts-core/src/nearest.ts +++ b/packages/charts-core/src/nearest.ts @@ -1,4 +1,3 @@ -import { least } from 'd3-array' import type { ChartPoint, ChartValue } from './types' export function nearestPoint< @@ -11,13 +10,17 @@ export function nearestPoint< y: number, maxDistance: number, ): ChartPoint | null { - const result = least(points, (point) => { + let result: ChartPoint | undefined + let resultDistance = Infinity + for (const point of points) { const dx = point.x - x const dy = point.y - y - return dx * dx + dy * dy - }) + const distance = dx * dx + dy * dy + if (distance < resultDistance) { + result = point + resultDistance = distance + } + } if (!result) return null - const dx = result.x - x - const dy = result.y - y - return dx * dx + dy * dy <= Math.max(0, maxDistance) ** 2 ? result : null + return resultDistance <= Math.max(0, maxDistance) ** 2 ? result : null } diff --git a/packages/charts-scales/package.json b/packages/charts-scales/package.json index e80f05ba..d0ca948c 100644 --- a/packages/charts-scales/package.json +++ b/packages/charts-scales/package.json @@ -15,12 +15,10 @@ "LICENSE", "README.md" ], - "dependencies": { - "d3-array": "3.2.4" - }, "devDependencies": { "@types/d3-array": "^3.2.2", "@types/d3-scale": "^4.0.9", + "d3-array": "3.2.4", "d3-scale": "4.0.2" }, "exports": { diff --git a/packages/charts-scales/src/linear.ts b/packages/charts-scales/src/linear.ts index cad283e2..469d37e9 100644 --- a/packages/charts-scales/src/linear.ts +++ b/packages/charts-scales/src/linear.ts @@ -1,4 +1,4 @@ -import { tickIncrement, ticks as createTicks, tickStep } from 'd3-array' +import { tickIncrement, ticks as createTicks, tickStep } from './ticks' export interface LinearScale { (value: number | null | undefined): number | undefined diff --git a/packages/charts-scales/src/ticks.test.ts b/packages/charts-scales/src/ticks.test.ts new file mode 100644 index 00000000..28800d7b --- /dev/null +++ b/packages/charts-scales/src/ticks.test.ts @@ -0,0 +1,62 @@ +import { + tickIncrement as d3TickIncrement, + ticks as d3Ticks, + tickStep as d3TickStep, +} from 'd3-array' +import { describe, expect, it } from 'vitest' +import { tickIncrement, ticks, tickStep } from './ticks' + +const fixedCases = [ + [0, 10, 5], + [10, 0, 5], + [-1.2, 8.7, 5], + [0.000_12, 0.000_89, 6], + [-9e12, 4e12, 8], + [0.1, 0.2, 1], + [0, 1, 0.5], + [0, 1, 1], + [0, 1, 0], + [0, 1, -1], + [0, 1, Number.POSITIVE_INFINITY], + [0, 0, 5], + [Number.NaN, 1, 5], + [0, Number.POSITIVE_INFINITY, 5], + [Number.EPSILON, 1 - Number.EPSILON, 7], +] as const + +describe('compact tick helpers', () => { + it.each(fixedCases)( + 'matches D3 for %s..%s with count %s', + (start, stop, count) => { + expect(ticks(start, stop, count)).toEqual(d3Ticks(start, stop, count)) + expect(tickIncrement(start, stop, count)).toBe( + d3TickIncrement(start, stop, count), + ) + expect(tickStep(start, stop, count)).toBe(d3TickStep(start, stop, count)) + }, + ) + + it('matches D3 across deterministic generated domains', () => { + let state = 0x6d2b79f5 + const random = () => { + state = Math.imul(state ^ (state >>> 15), state | 1) + state ^= state + Math.imul(state ^ (state >>> 7), state | 61) + return ((state ^ (state >>> 14)) >>> 0) / 4_294_967_296 + } + + for (let index = 0; index < 2_000; index += 1) { + const magnitude = 10 ** Math.floor(random() * 25 - 12) + const first = (random() * 40 - 20) * magnitude + const second = (random() * 40 - 20) * magnitude + const count = random() < 0.1 ? random() * 2 : 2 + random() * 30 + + expect(ticks(first, second, count)).toEqual(d3Ticks(first, second, count)) + expect(tickIncrement(first, second, count)).toBe( + d3TickIncrement(first, second, count), + ) + expect(tickStep(first, second, count)).toBe( + d3TickStep(first, second, count), + ) + } + }) +}) diff --git a/packages/charts-scales/src/ticks.ts b/packages/charts-scales/src/ticks.ts new file mode 100644 index 00000000..431187b3 --- /dev/null +++ b/packages/charts-scales/src/ticks.ts @@ -0,0 +1,97 @@ +const preferredMultiples = [1, 2, 5, 10] as const + +interface TickPlan { + firstIndex: number + lastIndex: number + interval: number +} + +export function ticks(start: number, stop: number, count: number): number[] { + if (!(count > 0)) return [] + if (start === stop) return [start] + + const descending = stop < start + const plan = createTickPlan( + descending ? stop : start, + descending ? start : stop, + count, + ) + if (!(plan.lastIndex >= plan.firstIndex)) return [] + + return Array.from( + { length: plan.lastIndex - plan.firstIndex + 1 }, + (_value, offset) => + valueAtIndex( + descending ? plan.lastIndex - offset : plan.firstIndex + offset, + plan.interval, + ), + ) +} + +export function tickIncrement(start: number, stop: number, count: number) { + return createTickPlan(start, stop, count).interval +} + +export function tickStep(start: number, stop: number, count: number) { + const descending = stop < start + const interval = tickIncrement( + descending ? stop : start, + descending ? start : stop, + count, + ) + const magnitude = interval < 0 ? -1 / interval : interval + return descending ? -magnitude : magnitude +} + +function createTickPlan(start: number, stop: number, count: number): TickPlan { + let requestedCount = count + + while (true) { + const interval = chooseInterval(start, stop, requestedCount) + const firstIndex = indexAtOrAbove(start, interval) + const lastIndex = indexAtOrBelow(stop, interval) + + if ( + lastIndex >= firstIndex || + !(requestedCount >= 0.5 && requestedCount < 2) + ) { + return { firstIndex, lastIndex, interval } + } + requestedCount *= 2 + } +} + +function chooseInterval(start: number, stop: number, count: number): number { + const target = (stop - start) / Math.max(0, count) + const exponent = Math.floor(Math.log10(target)) + const decade = 10 ** exponent + const multiple = closestPreferredMultiple(target / decade) + + return exponent < 0 ? -(10 ** -exponent) / multiple : decade * multiple +} + +function closestPreferredMultiple(normalizedTarget: number): number { + let selected: number = preferredMultiples[0] + for (const candidate of preferredMultiples.slice(1)) { + const midpoint = Math.sqrt(selected * candidate) + if (!(normalizedTarget >= midpoint)) break + selected = candidate + } + return selected +} + +function indexAtOrAbove(value: number, interval: number): number { + const position = interval < 0 ? value * -interval : value / interval + const nearest = Math.round(position) + return nearest < position ? nearest + 1 : nearest +} + +function indexAtOrBelow(value: number, interval: number): number { + const position = interval < 0 ? value * -interval : value / interval + const nearest = Math.round(position) + return nearest > position ? nearest - 1 : nearest +} + +function valueAtIndex(index: number, interval: number): number { + return interval < 0 ? index / -interval : index * interval +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4fb39baf..6b46f0e9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -590,10 +590,6 @@ importers: version: 3.1.8 packages/charts-scales: - dependencies: - d3-array: - specifier: 3.2.4 - version: 3.2.4 devDependencies: '@types/d3-array': specifier: ^3.2.2 @@ -601,6 +597,9 @@ importers: '@types/d3-scale': specifier: ^4.0.9 version: 4.0.9 + d3-array: + specifier: 3.2.4 + version: 3.2.4 d3-scale: specifier: 4.0.2 version: 4.0.2 diff --git a/scripts/check-packed-consumers.mjs b/scripts/check-packed-consumers.mjs index 1bd0744f..62c084f3 100644 --- a/scripts/check-packed-consumers.mjs +++ b/scripts/check-packed-consumers.mjs @@ -1263,13 +1263,8 @@ async function verifyProductionBundles() { tooltipExtension: ['/@tanstack/charts/dist/tooltip.js'], tooltipPortal: ['/@tanstack/charts/dist/tooltip-portal.js'], reactTooltip: ['/@tanstack/react-charts/dist/tooltip.js'], - d3ScaleRuntime: [ - '/d3-scale/', - '/d3-format/', - '/d3-interpolate/', - '/d3-color/', - '/internmap/', - ], + d3GeometryRuntime: ['/d3-geo/', '/d3-shape/'], + d3Runtime: ['/d3-', '/internmap/'], } const entries = [ { @@ -1288,6 +1283,7 @@ async function verifyProductionBundles() { filename: 'core.ts', external: [], rendererBoundary: 'svg', + inputBoundary: { forbid: ['d3GeometryRuntime'] }, source: ` import { createChartScene, @@ -1335,7 +1331,12 @@ async function verifyProductionBundles() { external: ['react', 'react/jsx-runtime', 'react-dom'], rendererBoundary: 'svg', inputBoundary: { - forbid: ['tooltip', 'tooltipPortal', 'reactTooltip'], + forbid: [ + 'tooltip', + 'tooltipPortal', + 'reactTooltip', + 'd3GeometryRuntime', + ], }, source: ` import { createElement } from 'react' @@ -1391,7 +1392,7 @@ async function verifyProductionBundles() { 'compactPoint', 'compactBandKernel', 'compactOrdinal', - 'd3ScaleRuntime', + 'd3Runtime', ], }, source: ` @@ -1408,7 +1409,7 @@ async function verifyProductionBundles() { 'compactLinear', 'compactPoint', 'compactOrdinal', - 'd3ScaleRuntime', + 'd3Runtime', ], }, source: ` @@ -1421,12 +1422,7 @@ async function verifyProductionBundles() { external: [], inputBoundary: { require: ['compactPoint', 'compactBandKernel'], - forbid: [ - 'compactLinear', - 'compactBand', - 'compactOrdinal', - 'd3ScaleRuntime', - ], + forbid: ['compactLinear', 'compactBand', 'compactOrdinal', 'd3Runtime'], }, source: ` export { scalePoint } from '@tanstack/charts-scales/point' @@ -1443,7 +1439,7 @@ async function verifyProductionBundles() { 'compactBand', 'compactPoint', 'compactBandKernel', - 'd3ScaleRuntime', + 'd3Runtime', ], }, source: ` diff --git a/scripts/measure-bundles.mjs b/scripts/measure-bundles.mjs index 4eb6310f..aa042d31 100644 --- a/scripts/measure-bundles.mjs +++ b/scripts/measure-bundles.mjs @@ -85,6 +85,8 @@ const retainedInputGroups = { /(?:^|\/)node_modules\/d3-color\//u, /(?:^|\/)node_modules\/internmap\//u, ], + d3GeometryRuntime: [/(?:^|\/)node_modules\/(?:d3-geo|d3-shape)\//u], + d3Runtime: [/(?:^|\/)node_modules\/(?:d3-[^/]+|internmap)\//u], } const granularTransformInputGroups = [ 'transformBin', @@ -100,7 +102,9 @@ const granularTransformInputGroups = [ 'transformReduce', ] const entries = [ - measured('Core host', 'benchmarks/entries/core.ts'), + measured('Core host', 'benchmarks/entries/core.ts', { + inputBoundary: { forbid: ['d3GeometryRuntime'] }, + }), budgeted( 'Granular data transform suite', 'benchmarks/entries/charts-transform-suite.ts', @@ -192,10 +196,13 @@ const entries = [ { inputBoundary: granularTransformBoundary('transformReduce') }, ), locked('D3-scale line scene', 'benchmarks/entries/charts-core.ts', { - inputBoundary: { forbid: ['transformRuntime', 'tooltipRuntime'] }, + inputBoundary: { + forbid: ['transformRuntime', 'tooltipRuntime', 'd3GeometryRuntime'], + }, }), locked('D3-scale line + static SVG', 'benchmarks/entries/charts-svg.ts', { rendererBoundary: 'svg', + inputBoundary: { forbid: ['d3GeometryRuntime'] }, }), budgeted( 'D3-scale UTC line + static SVG', @@ -290,7 +297,7 @@ const entries = [ locked('TanStack DOM host', 'benchmarks/entries/charts-dom.ts', { rendererBoundary: 'svg', inputBoundary: { - forbid: ['tooltipRuntime', 'tooltipPortal'], + forbid: ['tooltipRuntime', 'tooltipPortal', 'd3GeometryRuntime'], }, }), measured( @@ -316,11 +323,12 @@ const entries = [ external: ['react', 'react/jsx-runtime', 'react-dom'], rendererBoundary: 'svg', inputBoundary: { - forbid: ['tooltipRuntime', 'tooltipPortal'], + forbid: ['tooltipRuntime', 'tooltipPortal', 'd3GeometryRuntime'], }, }), locked('React line consumer', 'benchmarks/entries/charts-react-line.ts', { external: ['react', 'react/jsx-runtime', 'react-dom'], + inputBoundary: { forbid: ['d3GeometryRuntime'] }, }), lockedBudgeted( 'Compact-scale line scene', @@ -336,7 +344,7 @@ const entries = [ 'tooltipRuntime', 'tooltipPortal', 'transformRuntime', - 'd3ScaleRuntime', + 'd3Runtime', ], }, }, @@ -357,7 +365,7 @@ const entries = [ 'tooltipRuntime', 'tooltipPortal', 'transformRuntime', - 'd3ScaleRuntime', + 'd3Runtime', ], }, }, @@ -378,8 +386,7 @@ const entries = [ 'compactOrdinal', 'reactTooltipBridge', 'transformRuntime', - 'd3Array', - 'd3ScaleRuntime', + 'd3Runtime', ], }, }, @@ -400,8 +407,7 @@ const entries = [ 'compactOrdinal', 'reactTooltipBridge', 'transformRuntime', - 'd3Array', - 'd3ScaleRuntime', + 'd3Runtime', ], }, }, @@ -422,7 +428,7 @@ const entries = [ 'compactBandKernel', 'tooltipPortal', 'reactTooltipBridge', - 'd3ScaleRuntime', + 'd3Runtime', ], addedFrom: 'React compact-scale line consumer', allowAdded: ['coreTooltipRuntime'], @@ -444,7 +450,7 @@ const entries = [ 'compactPointEntry', 'compactBandKernel', 'reactTooltipBridge', - 'd3ScaleRuntime', + 'd3Runtime', ], addedFrom: 'React compact-scale line + tooltip', allowAdded: ['tooltipPortal'], @@ -468,7 +474,7 @@ const entries = [ 'compactBandKernel', 'tooltipRuntime', 'tooltipPortal', - 'd3ScaleRuntime', + 'd3Runtime', ], }, }, @@ -556,7 +562,7 @@ const entries = [ 'compactPointEntry', 'compactBandKernel', 'compactOrdinal', - 'd3ScaleRuntime', + 'd3Runtime', ], }, }, @@ -572,8 +578,7 @@ const entries = [ 'compactLinear', 'compactPointEntry', 'compactOrdinal', - 'd3Array', - 'd3ScaleRuntime', + 'd3Runtime', ], }, }, @@ -589,8 +594,7 @@ const entries = [ 'compactLinear', 'compactBandEntry', 'compactOrdinal', - 'd3Array', - 'd3ScaleRuntime', + 'd3Runtime', ], }, }, @@ -607,8 +611,7 @@ const entries = [ 'compactBandEntry', 'compactPointEntry', 'compactBandKernel', - 'd3Array', - 'd3ScaleRuntime', + 'd3Runtime', ], }, }, @@ -626,7 +629,7 @@ const entries = [ 'compactBandKernel', 'compactOrdinal', ], - forbid: ['d3ScaleRuntime'], + forbid: ['d3Runtime'], }, }, ),