@pond-ts/charts 0.58.0 → 0.60.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/API.md +580 -0
- package/CHANGELOG.md +339 -1
- package/dist/AreaChart.d.ts +53 -1
- package/dist/AreaChart.js +16 -3
- package/dist/BarChart.d.ts +56 -7
- package/dist/BarChart.js +88 -73
- package/dist/BarList.d.ts +22 -0
- package/dist/BarList.js +42 -9
- package/dist/ChartContainer.d.ts +175 -3
- package/dist/ChartContainer.js +190 -11
- package/dist/ChartRow.js +92 -2
- package/dist/Layers.js +14 -4
- package/dist/XAxis.js +19 -14
- package/dist/YAxis.d.ts +58 -2
- package/dist/YAxis.js +5 -3
- package/dist/area.d.ts +43 -1
- package/dist/area.js +122 -5
- package/dist/bars.d.ts +10 -3
- package/dist/bars.js +13 -9
- package/dist/context.d.ts +46 -8
- package/dist/data.d.ts +38 -0
- package/dist/data.js +43 -0
- package/dist/format.d.ts +16 -1
- package/dist/format.js +17 -2
- package/dist/index.d.ts +5 -2
- package/dist/index.js +11 -0
- package/dist/range.d.ts +14 -1
- package/dist/range.js +24 -3
- package/dist/theme.d.ts +80 -4
- package/dist/theme.js +3 -0
- package/dist/use-band-ladder.d.ts +30 -0
- package/dist/use-band-ladder.js +81 -0
- package/dist/useChartFrame.d.ts +122 -0
- package/dist/useChartFrame.js +155 -0
- package/dist/useChartLegend.d.ts +8 -0
- package/dist/viewport.d.ts +35 -2
- package/dist/viewport.js +53 -6
- package/dist/yticks.d.ts +8 -1
- package/dist/yticks.js +109 -1
- package/package.json +6 -5
package/dist/range.d.ts
CHANGED
|
@@ -10,6 +10,19 @@
|
|
|
10
10
|
* A span that the gap would invert (narrower than `minWidthPx` after the inset)
|
|
11
11
|
* collapses to a `minWidthPx` mark centred in the slot, so a too-thin bucket
|
|
12
12
|
* stays visible and the bar never flips inside-out.
|
|
13
|
+
*
|
|
14
|
+
* `maxWidthPx` caps the **ink** and is applied *after* the inset, centred in the
|
|
15
|
+
* slot ([PND-BARWIDTH]). It is the missing half of the width vocabulary: `gapPx`
|
|
16
|
+
* is a *relative* inset, so on its own bar width is always `slot - gap` and
|
|
17
|
+
* fattens with the slot. Two independent sizes — slots spreading to fill the
|
|
18
|
+
* plot, ink pinned to N px — is what makes a measure comparable **between**
|
|
19
|
+
* panes, since a bar that widens with its pane reads as a different weight of
|
|
20
|
+
* the same thing. Expressing that with the relative knob alone requires
|
|
21
|
+
* predicting the slot width and back-solving the gap, which re-derives this
|
|
22
|
+
* function's arithmetic in consumer code.
|
|
23
|
+
*
|
|
24
|
+
* `minWidthPx` still wins: a cap below the floor yields the floor, so the two
|
|
25
|
+
* bounds can never invert the rect.
|
|
13
26
|
*/
|
|
14
|
-
export declare function barSpanPx(beginMs: number, endMs: number, xScale: (value: number) => number, gapPx?: number, minWidthPx?: number): [number, number];
|
|
27
|
+
export declare function barSpanPx(beginMs: number, endMs: number, xScale: (value: number) => number, gapPx?: number, minWidthPx?: number, maxWidthPx?: number): [number, number];
|
|
15
28
|
//# sourceMappingURL=range.d.ts.map
|
package/dist/range.js
CHANGED
|
@@ -10,15 +10,36 @@
|
|
|
10
10
|
* A span that the gap would invert (narrower than `minWidthPx` after the inset)
|
|
11
11
|
* collapses to a `minWidthPx` mark centred in the slot, so a too-thin bucket
|
|
12
12
|
* stays visible and the bar never flips inside-out.
|
|
13
|
+
*
|
|
14
|
+
* `maxWidthPx` caps the **ink** and is applied *after* the inset, centred in the
|
|
15
|
+
* slot ([PND-BARWIDTH]). It is the missing half of the width vocabulary: `gapPx`
|
|
16
|
+
* is a *relative* inset, so on its own bar width is always `slot - gap` and
|
|
17
|
+
* fattens with the slot. Two independent sizes — slots spreading to fill the
|
|
18
|
+
* plot, ink pinned to N px — is what makes a measure comparable **between**
|
|
19
|
+
* panes, since a bar that widens with its pane reads as a different weight of
|
|
20
|
+
* the same thing. Expressing that with the relative knob alone requires
|
|
21
|
+
* predicting the slot width and back-solving the gap, which re-derives this
|
|
22
|
+
* function's arithmetic in consumer code.
|
|
23
|
+
*
|
|
24
|
+
* `minWidthPx` still wins: a cap below the floor yields the floor, so the two
|
|
25
|
+
* bounds can never invert the rect.
|
|
13
26
|
*/
|
|
14
|
-
export function barSpanPx(beginMs, endMs, xScale, gapPx = 0, minWidthPx = 1) {
|
|
27
|
+
export function barSpanPx(beginMs, endMs, xScale, gapPx = 0, minWidthPx = 1, maxWidthPx) {
|
|
15
28
|
const a = xScale(beginMs);
|
|
16
29
|
const b = xScale(endMs);
|
|
17
30
|
const lo = Math.min(a, b);
|
|
18
31
|
const hi = Math.max(a, b);
|
|
19
32
|
const inset = gapPx / 2;
|
|
20
|
-
|
|
21
|
-
|
|
33
|
+
let x0 = lo + inset;
|
|
34
|
+
let x1 = hi - inset;
|
|
35
|
+
// The cap reads on the INSET span, not the raw slot, so `gap` keeps its
|
|
36
|
+
// meaning as the minimum breathing room: a bar is never wider than the gap
|
|
37
|
+
// allows, and never wider than the cap, whichever binds first.
|
|
38
|
+
if (maxWidthPx !== undefined && maxWidthPx > 0 && x1 - x0 > maxWidthPx) {
|
|
39
|
+
const mid = (lo + hi) / 2;
|
|
40
|
+
x0 = mid - maxWidthPx / 2;
|
|
41
|
+
x1 = mid + maxWidthPx / 2;
|
|
42
|
+
}
|
|
22
43
|
if (x1 - x0 >= minWidthPx)
|
|
23
44
|
return [x0, x1];
|
|
24
45
|
const mid = (lo + hi) / 2;
|
package/dist/theme.d.ts
CHANGED
|
@@ -296,10 +296,14 @@ export interface ChartTheme {
|
|
|
296
296
|
*
|
|
297
297
|
* So the two values here are the ones with no canvas counterpart: the row
|
|
298
298
|
* **band** tints. Everything else resolves from tokens that already exist
|
|
299
|
-
* and are per-metric where they should be — a selected fill takes
|
|
300
|
-
*
|
|
301
|
-
*
|
|
302
|
-
*
|
|
299
|
+
* and are per-metric where they should be — a selected glyph fill takes
|
|
300
|
+
* `bar[as].highlight` and a dimmed one `bar[as].dimmed` — so a consumer who
|
|
301
|
+
* themes their bars gets a coherent list without theming it twice.
|
|
302
|
+
*
|
|
303
|
+
* **To be unambiguous, because this reads as a list of fields on this
|
|
304
|
+
* block:** `highlight` / `dimmed` are **`BarStyle`** tokens, resolved through
|
|
305
|
+
* `theme.bar[as]`. They are not fields of `theme.list` and there is no
|
|
306
|
+
* `list.dimmed` — this register carries exactly the five values below.
|
|
303
307
|
*
|
|
304
308
|
* **The rail is deliberately NOT per-metric.** There is one rail per row
|
|
305
309
|
* and a row may carry several metrics, so it cannot resolve through
|
|
@@ -716,6 +720,28 @@ export interface AreaStyle {
|
|
|
716
720
|
/** Ink for a swept window's emphasised portion (edge + fill).
|
|
717
721
|
* **Omitted ⇒ the area keeps its own colours and only strengthens.** */
|
|
718
722
|
readonly spanColor?: string;
|
|
723
|
+
/**
|
|
724
|
+
* The **threshold-band ladder** — ordered fills for an area coloured *along
|
|
725
|
+
* its height* against `<AreaChart thresholds>`: `bands[0]` up to the first
|
|
726
|
+
* threshold, `bands[1]` between the first and second, and so on. A ladder of
|
|
727
|
+
* `n` thresholds reads `n + 1` entries. The fill **and** the outline take
|
|
728
|
+
* the band hues (one hard-stop gradient in pixel space), and the grade to
|
|
729
|
+
* transparent is dropped: the fade encoded distance-from-the-baseline, which
|
|
730
|
+
* is exactly what the ladder now states discretely — two encodings of one
|
|
731
|
+
* thing would fight.
|
|
732
|
+
*
|
|
733
|
+
* Lives on `AreaStyle` for {@link BarStyle.bands}' reason: `theme.area` is a
|
|
734
|
+
* semantic **map**, so a top-level key would collide with a role of that
|
|
735
|
+
* name — and per-role is the more useful shape (`area.default.bands` and a
|
|
736
|
+
* capacity role's ladder can differ).
|
|
737
|
+
*
|
|
738
|
+
* **Overridden by `<AreaChart bandColors>`** at the call site. If neither
|
|
739
|
+
* resolves enough entries for the ladder, the shortfall falls back to the
|
|
740
|
+
* flat {@link fill} and (in dev) warns — the same contract as the bar
|
|
741
|
+
* ladder, because a silently-unbanded chart is the failure mode the feature
|
|
742
|
+
* exists to remove.
|
|
743
|
+
*/
|
|
744
|
+
readonly bands?: readonly string[];
|
|
719
745
|
}
|
|
720
746
|
/**
|
|
721
747
|
* A resolved bar style: the flat `fill` (scaled by `opacity`, 0–1) plus the
|
|
@@ -730,6 +756,28 @@ export interface BarStyle {
|
|
|
730
756
|
readonly highlight: string;
|
|
731
757
|
readonly gap: number;
|
|
732
758
|
readonly minWidth: number;
|
|
759
|
+
/**
|
|
760
|
+
* Cap on a bar's **ink** width in px, applied after the `gap` inset and
|
|
761
|
+
* centred in the slot ([PND-BARWIDTH]). **Omitted ⇒ uncapped** — a bar is
|
|
762
|
+
* `slot - gap` wide, as it always was.
|
|
763
|
+
*
|
|
764
|
+
* It is the missing half of the width vocabulary, and the reason it cannot be
|
|
765
|
+
* spelled with `gap` alone: `gap` is a *relative* inset, so bar width tracks
|
|
766
|
+
* the slot and fattens as the plot widens. A **fixed** ink width is what makes
|
|
767
|
+
* a measure comparable *between* panes — bars that widen with their pane read
|
|
768
|
+
* as different weights of the same thing. Wanting both (slots spreading to
|
|
769
|
+
* fill, ink pinned) needs two independent knobs; with one, a consumer has to
|
|
770
|
+
* predict the slot width and back-solve the gap, re-deriving pond's own layout
|
|
771
|
+
* arithmetic in their code.
|
|
772
|
+
*
|
|
773
|
+
* Pairs with `<ChartContainer maxBandWidth>`, which caps the **slot**: that
|
|
774
|
+
* one decides how far the bars spread, this one how wide the ink is inside
|
|
775
|
+
* whatever slot results. `minWidth` still wins if the two would invert.
|
|
776
|
+
*
|
|
777
|
+
* `<BarChart maxBarWidth>` overrides this per layer, the same relationship
|
|
778
|
+
* `gap` has.
|
|
779
|
+
*/
|
|
780
|
+
readonly maxWidth?: number;
|
|
733
781
|
readonly outlineWidth: number;
|
|
734
782
|
/**
|
|
735
783
|
* Optional distinct **hover** fill, so a bar can read a three-step emphasis —
|
|
@@ -757,6 +805,11 @@ export interface BarStyle {
|
|
|
757
805
|
* both states so a red/green volume bar keeps its meaning while live —
|
|
758
806
|
* the one *design* exclusion rather than a path consequence.
|
|
759
807
|
*
|
|
808
|
+
* **Scope note: that `binColors` exclusion is about the LIVE states only.**
|
|
809
|
+
* It does not carry over to {@link dimmed}, which *replaces* a per-bar fill
|
|
810
|
+
* on an unselected bar — see that token, which spells out the asymmetry and
|
|
811
|
+
* why emphasis preserves a per-bar colour while recession suppresses it.
|
|
812
|
+
*
|
|
760
813
|
* The **decimated** dense-bar pass also draws the flat fill only, as it
|
|
761
814
|
* already did for `highlight`.
|
|
762
815
|
*/
|
|
@@ -859,6 +912,29 @@ export interface BarStyle {
|
|
|
859
912
|
* component, and drifted immediately — one consumer had three charts using
|
|
860
913
|
* `color-mix` at 22%, 28% and 30% for the same concept, in the same week, for
|
|
861
914
|
* no reason. One theme value fixes that permanently.
|
|
915
|
+
*
|
|
916
|
+
* **It OVERRIDES a per-bar fill, unlike the live states.** This is the one
|
|
917
|
+
* place `dimmed` and {@link hover} deliberately disagree, and the asymmetry
|
|
918
|
+
* is easy to read the wrong way round:
|
|
919
|
+
*
|
|
920
|
+
* - **{@link binColors} / {@link binFills}:** an unselected bar paints
|
|
921
|
+
* `dimmed`, discarding its own colour. (Hover and selection do the
|
|
922
|
+
* opposite — they keep the per-bar colour and pop the alpha, so a
|
|
923
|
+
* red/green volume bar stays red/green while live.)
|
|
924
|
+
* - **{@link bands} / thresholds:** an unselected banded bar draws **flat**
|
|
925
|
+
* in `dimmed`, discarding the ladder entirely rather than dimming each
|
|
926
|
+
* band.
|
|
927
|
+
* - **A multi-group stack** resolves per group through
|
|
928
|
+
* {@link StackStyle.dimmedFills} first, falling back to this flat value —
|
|
929
|
+
* a stack dimmed to one colour stops reading as a stack.
|
|
930
|
+
*
|
|
931
|
+
* The rule behind all three: a per-bar or per-band colour encodes *what the
|
|
932
|
+
* value is*, and a receded bar's whole job is to stop competing over that.
|
|
933
|
+
* Emphasis preserves meaning; recession suppresses it. So a chart that keeps
|
|
934
|
+
* `binColors` or `thresholds` for reasons unrelated to selection still gets
|
|
935
|
+
* a visible de-emphasis for free, and does **not** need to dim inside its own
|
|
936
|
+
* colour arrays. (Asked by a consumer who reasonably generalized `hover`'s
|
|
937
|
+
* `binColors` exclusion to this token; the exclusion is live-states-only.)
|
|
862
938
|
*/
|
|
863
939
|
readonly dimmed?: string;
|
|
864
940
|
}
|
package/dist/theme.js
CHANGED
|
@@ -78,6 +78,9 @@ export const defaultTheme = {
|
|
|
78
78
|
selectedFillOpacity: 0.55,
|
|
79
79
|
dimmedOpacity: 0.32,
|
|
80
80
|
spanColor: '#3F5BE0',
|
|
81
|
+
// The same ok → warning → alarm ladder `bar.default.bands` carries, so a
|
|
82
|
+
// banded area and a banded bar over one dataset read as one system.
|
|
83
|
+
bands: ['#2A9D8F', '#e8a13c', '#d64545'],
|
|
81
84
|
},
|
|
82
85
|
in: { color: '#0284c7', width: 1.5, fill: '#0284c7', fillOpacity: 0.3 },
|
|
83
86
|
out: { color: '#e8836b', width: 1.5, fill: '#e8836b', fillOpacity: 0.3 },
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { type BandLadder } from './bars.js';
|
|
2
|
+
/**
|
|
3
|
+
* Resolve a component's `thresholds` / `bandColors` props against its theme
|
|
4
|
+
* role's band ramp into a {@link BandLadder} — or `undefined` when there is no
|
|
5
|
+
* usable ladder, so the caller keeps its flat path.
|
|
6
|
+
*
|
|
7
|
+
* Extracted from `<BarChart>`'s [PND-BANDBAR2] block verbatim when
|
|
8
|
+
* `<AreaChart thresholds>` arrived ([PND-BANDAREA]): the resolution rules and
|
|
9
|
+
* every dev warning are one contract across banded marks, differing only in
|
|
10
|
+
* the component named by the warning text.
|
|
11
|
+
*
|
|
12
|
+
* Resolved once here rather than per mark per frame: normalize the breakpoints
|
|
13
|
+
* (sort, drop non-finite / non-positive), then pair them with `bandColors` →
|
|
14
|
+
* the role's `bands`. Everything that can go wrong with the pairing is a
|
|
15
|
+
* *silent* wrong-looking chart, so each case dev-warns — this feature exists
|
|
16
|
+
* because a quietly-unbanded mark was the workaround's failure mode.
|
|
17
|
+
*
|
|
18
|
+
* The two array props are **value-compared** rather than identity-compared:
|
|
19
|
+
* `thresholds={[1, 2]}` inline is the documented usage and the shape every
|
|
20
|
+
* story and doc example uses — and a fresh array each render would rebuild
|
|
21
|
+
* the ladder, hence the caller's layer entry, hence a `registerLayer` call
|
|
22
|
+
* **every render**. That is a repaint treadmill, not just a noisy warning.
|
|
23
|
+
* The same value-compare-on-registration reasoning `<YAxis ticks>` applies.
|
|
24
|
+
*
|
|
25
|
+
* A short colour supply pads with `styleFill` (the role's flat fill) so the
|
|
26
|
+
* draw path can index freely; `undefined` comes back only when there are no
|
|
27
|
+
* usable breakpoints or no colours at all.
|
|
28
|
+
*/
|
|
29
|
+
export declare function useBandLadder(component: 'BarChart' | 'AreaChart', thresholds: readonly number[] | undefined, bandColors: readonly string[] | undefined, styleBands: readonly string[] | undefined, styleFill: string): BandLadder | undefined;
|
|
30
|
+
//# sourceMappingURL=use-band-ladder.d.ts.map
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { useMemo } from 'react';
|
|
2
|
+
import { normalizeThresholds } from './bars.js';
|
|
3
|
+
import { isDev } from './dev.js';
|
|
4
|
+
/**
|
|
5
|
+
* Resolve a component's `thresholds` / `bandColors` props against its theme
|
|
6
|
+
* role's band ramp into a {@link BandLadder} — or `undefined` when there is no
|
|
7
|
+
* usable ladder, so the caller keeps its flat path.
|
|
8
|
+
*
|
|
9
|
+
* Extracted from `<BarChart>`'s [PND-BANDBAR2] block verbatim when
|
|
10
|
+
* `<AreaChart thresholds>` arrived ([PND-BANDAREA]): the resolution rules and
|
|
11
|
+
* every dev warning are one contract across banded marks, differing only in
|
|
12
|
+
* the component named by the warning text.
|
|
13
|
+
*
|
|
14
|
+
* Resolved once here rather than per mark per frame: normalize the breakpoints
|
|
15
|
+
* (sort, drop non-finite / non-positive), then pair them with `bandColors` →
|
|
16
|
+
* the role's `bands`. Everything that can go wrong with the pairing is a
|
|
17
|
+
* *silent* wrong-looking chart, so each case dev-warns — this feature exists
|
|
18
|
+
* because a quietly-unbanded mark was the workaround's failure mode.
|
|
19
|
+
*
|
|
20
|
+
* The two array props are **value-compared** rather than identity-compared:
|
|
21
|
+
* `thresholds={[1, 2]}` inline is the documented usage and the shape every
|
|
22
|
+
* story and doc example uses — and a fresh array each render would rebuild
|
|
23
|
+
* the ladder, hence the caller's layer entry, hence a `registerLayer` call
|
|
24
|
+
* **every render**. That is a repaint treadmill, not just a noisy warning.
|
|
25
|
+
* The same value-compare-on-registration reasoning `<YAxis ticks>` applies.
|
|
26
|
+
*
|
|
27
|
+
* A short colour supply pads with `styleFill` (the role's flat fill) so the
|
|
28
|
+
* draw path can index freely; `undefined` comes back only when there are no
|
|
29
|
+
* usable breakpoints or no colours at all.
|
|
30
|
+
*/
|
|
31
|
+
export function useBandLadder(component, thresholds, bandColors, styleBands, styleFill) {
|
|
32
|
+
const thresholdKey = thresholds === undefined ? '' : thresholds.join(',');
|
|
33
|
+
const bandColorKey = bandColors === undefined ? '' : bandColors.join(',');
|
|
34
|
+
return useMemo(() => {
|
|
35
|
+
const steps = normalizeThresholds(thresholds);
|
|
36
|
+
if (steps === null) {
|
|
37
|
+
if (isDev && thresholds !== undefined && thresholds.length > 0) {
|
|
38
|
+
console.warn(`<${component} thresholds>: no usable breakpoints, so no banding ` +
|
|
39
|
+
'was applied — each must be finite and greater than zero. The ' +
|
|
40
|
+
'chart draws in the flat fill.');
|
|
41
|
+
}
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
// Some, but not all, entries dropped. Silently banding on a subset of what
|
|
45
|
+
// the caller wrote is exactly the class of quiet wrongness this feature is
|
|
46
|
+
// meant to remove, so say so.
|
|
47
|
+
if (isDev && thresholds !== undefined && steps.length < thresholds.length) {
|
|
48
|
+
console.warn(`<${component} thresholds>: dropped ${thresholds.length - steps.length} ` +
|
|
49
|
+
'breakpoint(s) that were not finite and greater than zero. The ' +
|
|
50
|
+
'ladder is walked on the magnitude and mirrored onto whichever side ' +
|
|
51
|
+
`of zero the value is on, so a negative breakpoint has no meaning; ` +
|
|
52
|
+
`banding on [${steps.join(', ')}].`);
|
|
53
|
+
}
|
|
54
|
+
const want = steps.length + 1;
|
|
55
|
+
const supplied = bandColors ?? styleBands;
|
|
56
|
+
if (supplied === undefined || supplied.length === 0) {
|
|
57
|
+
if (isDev) {
|
|
58
|
+
console.warn(`<${component} thresholds>: ${steps.length} breakpoint(s) need ` +
|
|
59
|
+
`${want} band colours, but neither \`bandColors\` nor the theme ` +
|
|
60
|
+
`role’s \`bands\` supplies any. The chart draws in the flat fill.`);
|
|
61
|
+
}
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
if (supplied.length < want && isDev) {
|
|
65
|
+
console.warn(`<${component} thresholds>: ${steps.length} breakpoint(s) need ` +
|
|
66
|
+
`${want} band colours but only ${supplied.length} were supplied; ` +
|
|
67
|
+
'bands above the last colour fall back to the flat fill.');
|
|
68
|
+
}
|
|
69
|
+
// Pad a short ladder with the flat fill so the draw path can index freely.
|
|
70
|
+
const resolved = supplied.length >= want
|
|
71
|
+
? supplied.slice(0, want)
|
|
72
|
+
: [
|
|
73
|
+
...supplied,
|
|
74
|
+
...Array.from({ length: want - supplied.length }, () => styleFill),
|
|
75
|
+
];
|
|
76
|
+
return { thresholds: steps, colors: resolved };
|
|
77
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- `thresholdKey` /
|
|
78
|
+
// `bandColorKey` are the value-compared stand-ins for the array props.
|
|
79
|
+
}, [component, thresholdKey, bandColorKey, styleBands, styleFill]);
|
|
80
|
+
}
|
|
81
|
+
//# sourceMappingURL=use-band-ladder.js.map
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { type ChartXScale, type YScale } from './context.js';
|
|
2
|
+
/**
|
|
3
|
+
* One ordinal slot on a category axis — its pixel span within the plot, its
|
|
4
|
+
* centre (where a mark is drawn and a tick is labelled), and its name.
|
|
5
|
+
*
|
|
6
|
+
* `x0`/`x1`/`center` are **plot-relative** (`0 … plot.width`); add
|
|
7
|
+
* {@link ChartFrame.plot}`.x` for container-relative DOM placement.
|
|
8
|
+
*/
|
|
9
|
+
export interface ChartBand {
|
|
10
|
+
/** The slot's left edge in px. */
|
|
11
|
+
readonly x0: number;
|
|
12
|
+
/** The slot's right edge in px. `x1 - x0` is {@link ChartBands.pitch}. */
|
|
13
|
+
readonly x1: number;
|
|
14
|
+
/** The slot's centre in px — where a bar centres and a tick labels. */
|
|
15
|
+
readonly center: number;
|
|
16
|
+
/** The category name at this slot. */
|
|
17
|
+
readonly label: string;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* The ordinal slot geometry of a `'category'` x axis — `null` on a `'time'`
|
|
21
|
+
* or `'value'` axis, which has no slots.
|
|
22
|
+
*
|
|
23
|
+
* **The pitch is not `plot.width / count`.** `<ChartContainer maxBandWidth>`
|
|
24
|
+
* caps it and `bandAlign` places the resulting narrower block within the
|
|
25
|
+
* plot, so the packed band run can be inset from both plot edges. Reading
|
|
26
|
+
* `pitch` and `at(i)` rather than recomputing is the difference between
|
|
27
|
+
* chrome that tracks that packing and chrome that ignores it.
|
|
28
|
+
*/
|
|
29
|
+
export interface ChartBands {
|
|
30
|
+
/** Number of slots — the category count. */
|
|
31
|
+
readonly count: number;
|
|
32
|
+
/** One slot's width in px (the pitch; slots are contiguous and equal). */
|
|
33
|
+
readonly pitch: number;
|
|
34
|
+
/** The ordered category names, index-aligned with the slots. */
|
|
35
|
+
readonly labels: readonly string[];
|
|
36
|
+
/** The slot at `index`, or `null` when `index` is not a real slot. */
|
|
37
|
+
at(index: number): ChartBand | null;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* The **row-scoped** half of the frame: y geometry and the row's value
|
|
41
|
+
* scales. `null` on {@link ChartFrame.row} when the hook is called outside a
|
|
42
|
+
* `<ChartRow>`.
|
|
43
|
+
*/
|
|
44
|
+
export interface ChartFrameRow {
|
|
45
|
+
/**
|
|
46
|
+
* The plot's top inset within the row's box in px — the band reserved by a
|
|
47
|
+
* `labelPlacement="top"` axis title, `0` when no axis draws one. An overlay
|
|
48
|
+
* that ignores it sits under the title.
|
|
49
|
+
*/
|
|
50
|
+
readonly topInset: number;
|
|
51
|
+
/**
|
|
52
|
+
* The plot's drawable height in px, below {@link topInset}. The row's own
|
|
53
|
+
* `height` prop is `topInset + height`, and the y-scales' pixel range is
|
|
54
|
+
* `[topInset + height, topInset]` (inverted — pixels grow downward).
|
|
55
|
+
*/
|
|
56
|
+
readonly height: number;
|
|
57
|
+
/**
|
|
58
|
+
* One value→pixel scale per `<YAxis id>`, each mapping into
|
|
59
|
+
* `[topInset + height, topInset]`. A row with no explicit `<YAxis>` has one
|
|
60
|
+
* entry under the implicit default id.
|
|
61
|
+
*/
|
|
62
|
+
readonly yScales: ReadonlyMap<string, YScale>;
|
|
63
|
+
/** Which gutter each axis id sits in — so chrome hugs the right edge. */
|
|
64
|
+
readonly axisSides: ReadonlyMap<string, 'left' | 'right'>;
|
|
65
|
+
}
|
|
66
|
+
/** The resolved geometry of a chart, as published by {@link useChartFrame}. */
|
|
67
|
+
export interface ChartFrame {
|
|
68
|
+
/**
|
|
69
|
+
* The plot's **x** geometry in px, relative to the container's own box:
|
|
70
|
+
* `x` is the left gutter (where the plot starts) and `width` is the plot's
|
|
71
|
+
* width after both gutters. Shared by every row.
|
|
72
|
+
*/
|
|
73
|
+
readonly plot: {
|
|
74
|
+
readonly x: number;
|
|
75
|
+
readonly width: number;
|
|
76
|
+
};
|
|
77
|
+
/**
|
|
78
|
+
* The reserved axis gutters in px — how far the plot is inset on each side.
|
|
79
|
+
* `left` equals {@link plot}`.x`; both are published because chrome above
|
|
80
|
+
* the plot pads by `left` while chrome sized to the container subtracts
|
|
81
|
+
* both.
|
|
82
|
+
*/
|
|
83
|
+
readonly gutters: {
|
|
84
|
+
readonly left: number;
|
|
85
|
+
readonly right: number;
|
|
86
|
+
};
|
|
87
|
+
/**
|
|
88
|
+
* The shared x→pixel scale, mapping into `[0, plot.width]`. Callable
|
|
89
|
+
* (`value → px`) with `invert` / `ticks` / `tickFormat`, whichever kind the
|
|
90
|
+
* container resolved (see {@link ChartXScale}).
|
|
91
|
+
*
|
|
92
|
+
* **Not the same thing as `<ChartContainer xScale>`**, which is a string
|
|
93
|
+
* naming how a *value* axis spaces itself (`'linear' | 'log' | 'symlog'`).
|
|
94
|
+
* This is the built scale object that choice — along with the data's kind,
|
|
95
|
+
* `origin`, `discontinuities` and `categories` — resolves to.
|
|
96
|
+
*/
|
|
97
|
+
readonly xScale: ChartXScale;
|
|
98
|
+
/**
|
|
99
|
+
* Which **kind** of x axis resolved: `'time'`, `'value'` or `'category'`.
|
|
100
|
+
* `'category'` is exactly when {@link bands} is non-null.
|
|
101
|
+
*
|
|
102
|
+
* Distinct from `<ChartContainer xScale>` again: that picks the spacing
|
|
103
|
+
* *within* a value axis, this says whether the axis is a value axis at all.
|
|
104
|
+
*/
|
|
105
|
+
readonly xKind: 'time' | 'value' | 'category';
|
|
106
|
+
/** Ordinal slot geometry on a `'category'` axis; `null` otherwise. */
|
|
107
|
+
readonly bands: ChartBands | null;
|
|
108
|
+
/** Row-scoped y geometry — `null` outside a `<ChartRow>`. */
|
|
109
|
+
readonly row: ChartFrameRow | null;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Read the container's resolved plot geometry — the plot rect, the axis
|
|
113
|
+
* gutters, the shared x scale, the row's y scales, and (on a category axis)
|
|
114
|
+
* the ordinal slot edges. See the module docblock for the x/y split, the
|
|
115
|
+
* placement-scoped `row` half, and which box each pixel value is relative to.
|
|
116
|
+
*
|
|
117
|
+
* Must be called under a `<ChartContainer>`; throws otherwise. To render the
|
|
118
|
+
* markup *outside* the chart's box, portal it out (`createPortal`) — context
|
|
119
|
+
* flows through portals.
|
|
120
|
+
*/
|
|
121
|
+
export declare function useChartFrame(): ChartFrame;
|
|
122
|
+
//# sourceMappingURL=useChartFrame.d.ts.map
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `useChartFrame()` — **the resolved plot geometry, published**.
|
|
3
|
+
*
|
|
4
|
+
* A consumer whose chrome has to line up with the plot (a per-slot header
|
|
5
|
+
* table above it, a column summary strip below it, a card pinned over one
|
|
6
|
+
* band, a colour ramp keyed to the plot's own scale) needs the numbers the
|
|
7
|
+
* container already resolved: where the plot starts, how wide it is, and the
|
|
8
|
+
* scales that map data to pixels inside it. Before this hook none of that was
|
|
9
|
+
* reachable, so consumers re-derived it — pin every axis gutter to a fixed
|
|
10
|
+
* width so it stops depending on label content, measure the outer box,
|
|
11
|
+
* subtract, and re-implement the band packing.
|
|
12
|
+
*
|
|
13
|
+
* **That duplicate is not merely verbose, it is wrong over time.** It holds
|
|
14
|
+
* only until the library changes how a gutter is sized or how bands are
|
|
15
|
+
* packed, at which point the consumer's chrome slides out of alignment with
|
|
16
|
+
* the plot it labels — with no type error and no failing test. Reading the
|
|
17
|
+
* frame converts a silent drift hazard into a version-checked API.
|
|
18
|
+
*
|
|
19
|
+
* ## The x / y split is the library's own
|
|
20
|
+
*
|
|
21
|
+
* The shape mirrors the architecture rather than flattening it: **the
|
|
22
|
+
* container owns x** (one shared scale, so every row's plot left-aligns under
|
|
23
|
+
* one time axis) and **rows own y** (row-local data, one scale per axis id).
|
|
24
|
+
* So {@link ChartFrame.plot} carries x only, and y lives on
|
|
25
|
+
* {@link ChartFrame.row} — which is `null` when the hook is called outside a
|
|
26
|
+
* `<ChartRow>`.
|
|
27
|
+
*
|
|
28
|
+
* That `null` is the point. The common case (a header strip above the plot,
|
|
29
|
+
* a sibling of the rows) genuinely has no y geometry, and the alternative —
|
|
30
|
+
* reporting `height: 0` — is the same silent-misalignment failure this hook
|
|
31
|
+
* exists to remove. A consumer that needs y must be inside a row, and the
|
|
32
|
+
* type says so.
|
|
33
|
+
*
|
|
34
|
+
* ## Scope follows placement
|
|
35
|
+
*
|
|
36
|
+
* Exactly as {@link useChartLegend} does: at the container level you get the
|
|
37
|
+
* shared x frame and `row: null`; inside a `<ChartRow>` you additionally get
|
|
38
|
+
* that row's y scales. No prop selects the scope — placement does.
|
|
39
|
+
*
|
|
40
|
+
* ## Pixel origins
|
|
41
|
+
*
|
|
42
|
+
* Two different boxes, because the DOM has two:
|
|
43
|
+
*
|
|
44
|
+
* - `plot.x` / `plot.width` are relative to the **container's** box, so a
|
|
45
|
+
* `<div>` sibling of the rows pads by `plot.x` to align.
|
|
46
|
+
* - `xScale(v)` and `bands.at(i)` are relative to the **plot**, i.e. `0 …
|
|
47
|
+
* plot.width` — the coordinate system the canvas draws in. Add `plot.x` to
|
|
48
|
+
* place DOM chrome in container space.
|
|
49
|
+
* - `row.topInset` / `row.height` are relative to the **row's** box.
|
|
50
|
+
*
|
|
51
|
+
* @example Align a per-slot header strip above a categorical plot
|
|
52
|
+
* ```tsx
|
|
53
|
+
* function SlotHeader() {
|
|
54
|
+
* const { plot, bands } = useChartFrame();
|
|
55
|
+
* if (bands === null) return null;
|
|
56
|
+
* return (
|
|
57
|
+
* <div style={{ position: 'relative', height: 22, marginLeft: plot.x, width: plot.width }}>
|
|
58
|
+
* {bands.labels.map((label, i) => {
|
|
59
|
+
* const b = bands.at(i)!;
|
|
60
|
+
* return (
|
|
61
|
+
* <div key={label} style={{ position: 'absolute', left: b.x0, width: b.x1 - b.x0 }}>
|
|
62
|
+
* {label}
|
|
63
|
+
* </div>
|
|
64
|
+
* );
|
|
65
|
+
* })}
|
|
66
|
+
* </div>
|
|
67
|
+
* );
|
|
68
|
+
* }
|
|
69
|
+
*
|
|
70
|
+
* <ChartContainer categories={tickers} width="auto">
|
|
71
|
+
* <SlotHeader />
|
|
72
|
+
* <ChartRow height={200}>…</ChartRow>
|
|
73
|
+
* </ChartContainer>
|
|
74
|
+
* ```
|
|
75
|
+
*
|
|
76
|
+
* @packageDocumentation
|
|
77
|
+
*/
|
|
78
|
+
import { useContext, useMemo } from 'react';
|
|
79
|
+
import { ContainerContext, RowContext, } from './context.js';
|
|
80
|
+
/** Whether the container resolved an ordinal scale (which alone carries `label`). */
|
|
81
|
+
function asBandScale(scale, kind) {
|
|
82
|
+
return kind === 'category' ? scale : null;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Read the container's resolved plot geometry — the plot rect, the axis
|
|
86
|
+
* gutters, the shared x scale, the row's y scales, and (on a category axis)
|
|
87
|
+
* the ordinal slot edges. See the module docblock for the x/y split, the
|
|
88
|
+
* placement-scoped `row` half, and which box each pixel value is relative to.
|
|
89
|
+
*
|
|
90
|
+
* Must be called under a `<ChartContainer>`; throws otherwise. To render the
|
|
91
|
+
* markup *outside* the chart's box, portal it out (`createPortal`) — context
|
|
92
|
+
* flows through portals.
|
|
93
|
+
*/
|
|
94
|
+
export function useChartFrame() {
|
|
95
|
+
const container = useContext(ContainerContext);
|
|
96
|
+
if (container === null) {
|
|
97
|
+
throw new Error('useChartFrame() must be used inside a <ChartContainer>');
|
|
98
|
+
}
|
|
99
|
+
const row = useContext(RowContext);
|
|
100
|
+
const { leftGutter, rightGutter, plotWidth, xScale, xKind } = container;
|
|
101
|
+
const bands = useMemo(() => {
|
|
102
|
+
const band = asBandScale(xScale, xKind);
|
|
103
|
+
if (band === null)
|
|
104
|
+
return null;
|
|
105
|
+
// The slot count is the scale's own domain width, not the label count:
|
|
106
|
+
// `scaleBand` is built with `domain([0, n])` from the container's resolved
|
|
107
|
+
// category list, so the domain is the authority on geometry (the labels
|
|
108
|
+
// ride alongside for naming). They agree today; reading the domain means
|
|
109
|
+
// they cannot disagree here if that ever stops being true.
|
|
110
|
+
const [d0, d1] = band.domain();
|
|
111
|
+
const count = Math.max(0, Math.round(d1 - d0));
|
|
112
|
+
const pitch = band.step();
|
|
113
|
+
// Slot `i` is the domain value `d0 + i`; the container always sets
|
|
114
|
+
// `domain([0, n])` so `d0` is 0 today, but every read goes through it so
|
|
115
|
+
// an offset domain could never silently shift the labels off the slots.
|
|
116
|
+
const labels = [];
|
|
117
|
+
for (let i = 0; i < count; i++)
|
|
118
|
+
labels.push(band.label(d0 + i + 0.5));
|
|
119
|
+
return {
|
|
120
|
+
count,
|
|
121
|
+
pitch,
|
|
122
|
+
labels,
|
|
123
|
+
at(index) {
|
|
124
|
+
if (!Number.isInteger(index) || index < 0 || index >= count) {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
return {
|
|
128
|
+
x0: band(d0 + index),
|
|
129
|
+
x1: band(d0 + index + 1),
|
|
130
|
+
center: band(d0 + index + 0.5),
|
|
131
|
+
label: labels[index] ?? '',
|
|
132
|
+
};
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
}, [xScale, xKind]);
|
|
136
|
+
const rowHalf = useMemo(() => {
|
|
137
|
+
if (row === null)
|
|
138
|
+
return null;
|
|
139
|
+
return {
|
|
140
|
+
topInset: row.topInset,
|
|
141
|
+
height: Math.max(0, row.height - row.topInset),
|
|
142
|
+
yScales: row.yScales,
|
|
143
|
+
axisSides: row.axisSides,
|
|
144
|
+
};
|
|
145
|
+
}, [row]);
|
|
146
|
+
return useMemo(() => ({
|
|
147
|
+
plot: { x: leftGutter, width: plotWidth },
|
|
148
|
+
gutters: { left: leftGutter, right: rightGutter },
|
|
149
|
+
xScale,
|
|
150
|
+
xKind,
|
|
151
|
+
bands,
|
|
152
|
+
row: rowHalf,
|
|
153
|
+
}), [leftGutter, rightGutter, plotWidth, xScale, xKind, bands, rowHalf]);
|
|
154
|
+
}
|
|
155
|
+
//# sourceMappingURL=useChartFrame.js.map
|
package/dist/useChartLegend.d.ts
CHANGED
|
@@ -32,6 +32,14 @@ export interface ChartLegend {
|
|
|
32
32
|
* inset from the chart box on each side. A custom legend laid out above /
|
|
33
33
|
* below the chart pads by `gutters.left` (and `gutters.right`) to align
|
|
34
34
|
* with the plot instead of the y-axis column.
|
|
35
|
+
*
|
|
36
|
+
* The same two numbers {@link useChartFrame} publishes as
|
|
37
|
+
* `ChartFrame.gutters`, kept here so a legend needs one hook rather than
|
|
38
|
+
* two. **Reach for `useChartFrame()` instead** when the chrome is not a
|
|
39
|
+
* legend, or when aligning needs more than the gutters — the plot width,
|
|
40
|
+
* the x scale, the per-slot band edges, or a row's y scales. This field
|
|
41
|
+
* predates that hook and is the reason it exists: the geometry was
|
|
42
|
+
* published for exactly one consumer, on a hook named for something else.
|
|
35
43
|
*/
|
|
36
44
|
readonly gutters: {
|
|
37
45
|
readonly left: number;
|
package/dist/viewport.d.ts
CHANGED
|
@@ -24,13 +24,46 @@ export type TimeRange = readonly [number, number];
|
|
|
24
24
|
* range untouched) so a mis-specified extent can't collapse the view.
|
|
25
25
|
*/
|
|
26
26
|
export declare function clampToBounds(range: TimeRange, bounds: TimeRange): [number, number];
|
|
27
|
+
/**
|
|
28
|
+
* How a viewport gesture should treat the domain it is moving.
|
|
29
|
+
*
|
|
30
|
+
* Both flags exist because {@link roundRange} was written for a **millisecond**
|
|
31
|
+
* axis and silently assumed every axis was one.
|
|
32
|
+
*/
|
|
33
|
+
export interface ViewportOptions {
|
|
34
|
+
/**
|
|
35
|
+
* Snap the result to whole integers. **Default `true`** — right for a time
|
|
36
|
+
* axis, where a fractional domain is meaningless and the 1 ms floor is the
|
|
37
|
+
* finest real view.
|
|
38
|
+
*
|
|
39
|
+
* Pass `false` on a **value** axis, where the units are not milliseconds and
|
|
40
|
+
* the fractions are the data: a power–duration curve over `[0.5, 10800]`
|
|
41
|
+
* seconds would otherwise snap its floor to `0`, and a `[0.001, 1]` domain
|
|
42
|
+
* would collapse to `[0, 1]`.
|
|
43
|
+
*
|
|
44
|
+
* The axis kind is a caller's fact, not something to infer from magnitude — a
|
|
45
|
+
* 0.2 ms span and a 0.2-unit value span are indistinguishable by size, and
|
|
46
|
+
* guessing breaks whichever one you guessed against.
|
|
47
|
+
*/
|
|
48
|
+
readonly snap?: boolean;
|
|
49
|
+
/**
|
|
50
|
+
* Do the arithmetic in **log space**, where a log axis is linear. Implies
|
|
51
|
+
* `snap: false`.
|
|
52
|
+
*
|
|
53
|
+
* Without it a log axis zooms additively, which drags the value under the
|
|
54
|
+
* cursor sideways — the one thing zoom must never do — and pans by an offset,
|
|
55
|
+
* so a drag near the low end walks off the plot while the same drag near the
|
|
56
|
+
* high end barely moves.
|
|
57
|
+
*/
|
|
58
|
+
readonly log?: boolean;
|
|
59
|
+
}
|
|
27
60
|
/**
|
|
28
61
|
* Shift a range by `dt` ms (drag-pan). The caller signs `dt` from the gesture —
|
|
29
62
|
* dragging the plot right reveals earlier data, i.e. a negative `dt`. The result
|
|
30
63
|
* is snapped to whole milliseconds ({@link roundRange}) — `dt` comes from a pixel
|
|
31
64
|
* delta through `xScale.invert()`, so it is fractional by construction.
|
|
32
65
|
*/
|
|
33
|
-
export declare function panRange(range: TimeRange, dt: number): [number, number];
|
|
66
|
+
export declare function panRange(range: TimeRange, dt: number, options?: ViewportOptions): [number, number];
|
|
34
67
|
/**
|
|
35
68
|
* Zoom `range` around `pivot` (ms) by `factor` — `< 1` zooms in, `> 1` out, with
|
|
36
69
|
* the pivot held fixed (the time under the cursor stays put). Clamped so the
|
|
@@ -43,7 +76,7 @@ export declare function panRange(range: TimeRange, dt: number): [number, number]
|
|
|
43
76
|
* lands on the 1 ms floor the snap guarantees, which is the finest view this
|
|
44
77
|
* model has.
|
|
45
78
|
*/
|
|
46
|
-
export declare function zoomRange(range: TimeRange, pivot: number, factor: number, minDuration?: number): [number, number];
|
|
79
|
+
export declare function zoomRange(range: TimeRange, pivot: number, factor: number, minDuration?: number, options?: ViewportOptions): [number, number];
|
|
47
80
|
/**
|
|
48
81
|
* The slice of a discontinuity provider the trading-time viewport math needs —
|
|
49
82
|
* a structural subset of the charts `DiscontinuityProvider` (so `viewport.ts`
|