@pond-ts/charts 0.43.0 → 0.44.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/CHANGELOG.md +82 -1
- package/dist/BarChart.js +24 -1
- package/dist/BoxPlot.d.ts +80 -28
- package/dist/BoxPlot.js +67 -40
- package/dist/ChartContainer.d.ts +16 -8
- package/dist/ChartContainer.js +44 -21
- package/dist/Layers.js +19 -15
- package/dist/ScatterChart.d.ts +33 -6
- package/dist/ScatterChart.js +36 -16
- package/dist/box.d.ts +26 -9
- package/dist/box.js +86 -42
- package/dist/context.d.ts +28 -9
- package/dist/data.d.ts +73 -30
- package/dist/data.js +79 -21
- package/dist/scatter.d.ts +2 -2
- package/dist/scatter.js +8 -5
- package/package.json +3 -3
package/dist/data.d.ts
CHANGED
|
@@ -30,19 +30,30 @@ export interface BandSeries {
|
|
|
30
30
|
readonly length: number;
|
|
31
31
|
}
|
|
32
32
|
/**
|
|
33
|
-
* A chart-ready view of a box-and-whisker series ({@link BoxPlot}):
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
* A key is drawn only where
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
* `
|
|
45
|
-
*
|
|
33
|
+
* A chart-ready view of a box-and-whisker series ({@link BoxPlot}): a horizontal
|
|
34
|
+
* span (`x`/`xEnd`) per key plus its quantile edges — the required
|
|
35
|
+
* `lower`/`upper` (whisker reach) and the optional `q1`/`median`/`q3` (box body +
|
|
36
|
+
* centre line). The quantiles are pre-computed columns (a `rolling`/`aggregate`
|
|
37
|
+
* percentile pass upstream); the chart only reads them.
|
|
38
|
+
*
|
|
39
|
+
* A key is drawn only where the quantiles it **carries** are all finite (a full
|
|
40
|
+
* box needs all five; a range-only box just `lower`/`upper`); any present one
|
|
41
|
+
* `NaN` is a gap (the box draws nothing — same gap contract as {@link BandSeries}).
|
|
42
|
+
*
|
|
43
|
+
* `x` and `xEnd` are the box's horizontal span. An **interval**-keyed
|
|
44
|
+
* `TimeSeries` uses the key's own `[begin, end)`; a **point**-keyed `TimeSeries`
|
|
45
|
+
* (or a `ValueSeries`, always point-keyed on its value axis) synthesizes the span
|
|
46
|
+
* from **neighbour spacing** (each box centred on its key, reaching halfway to
|
|
47
|
+
* each neighbour — the same rule as bars / candles), so a point series still gets
|
|
48
|
+
* real box width instead of collapsing to the 1px floor.
|
|
49
|
+
*
|
|
50
|
+
* **Range-only boxes.** `q1`/`median`/`q3` are optional at the source (a bid→ask
|
|
51
|
+
* IV segment is a degenerate box — whiskers only, no body). `hasBox` is `false`
|
|
52
|
+
* when `q1`/`q3` were omitted (no box body; the whisker runs the full
|
|
53
|
+
* `lower→upper`), and `hasMedian` is `false` when `median` was omitted (no centre
|
|
54
|
+
* line). Absent quantile buffers are all-`NaN`; the flags — not the buffers —
|
|
55
|
+
* decide what draws, so an absent quantile isn't confused with a per-row gap.
|
|
56
|
+
* Both default to `true` (a full five-number box) when unset.
|
|
46
57
|
*/
|
|
47
58
|
export interface BoxSeries {
|
|
48
59
|
readonly x: Float64Array;
|
|
@@ -53,6 +64,10 @@ export interface BoxSeries {
|
|
|
53
64
|
readonly q3: Float64Array;
|
|
54
65
|
readonly upper: Float64Array;
|
|
55
66
|
readonly length: number;
|
|
67
|
+
/** `false` ⇒ `q1`/`q3` absent (range-only box, no body). Default `true`. */
|
|
68
|
+
readonly hasBox?: boolean;
|
|
69
|
+
/** `false` ⇒ `median` absent (no centre line). Default `true`. */
|
|
70
|
+
readonly hasMedian?: boolean;
|
|
56
71
|
}
|
|
57
72
|
/**
|
|
58
73
|
* A chart-ready view of an OHLC series ({@link Candlestick}): the candle's
|
|
@@ -131,17 +146,23 @@ export interface StackedBarSeries {
|
|
|
131
146
|
*/
|
|
132
147
|
readonly marks?: readonly string[];
|
|
133
148
|
}
|
|
134
|
-
/**
|
|
149
|
+
/**
|
|
150
|
+
* The quantile column names a {@link boxFromTimeSeries} / {@link boxFromValueSeries}
|
|
151
|
+
* reads. `lower`/`upper` (the whisker reach) are required; `q1`/`q3` (the box
|
|
152
|
+
* body) and `median` (the centre line) are **optional** — omit them for a
|
|
153
|
+
* range-only box (a bid→ask IV segment: whiskers only, no body). Omitting exactly
|
|
154
|
+
* one of `q1`/`q3` is a data error (a box needs both edges or neither).
|
|
155
|
+
*/
|
|
135
156
|
export interface BoxColumns {
|
|
136
|
-
/** Lower whisker end (e.g. `p5` / `min`). */
|
|
157
|
+
/** Lower whisker end (e.g. `p5` / `min`). Required. */
|
|
137
158
|
readonly lower: string;
|
|
138
|
-
/** Box bottom — first quartile (e.g. `p25`). */
|
|
139
|
-
readonly q1
|
|
140
|
-
/** Median line inside the box (e.g. `p50`). */
|
|
141
|
-
readonly median
|
|
142
|
-
/** Box top — third quartile (e.g. `p75`). */
|
|
143
|
-
readonly q3
|
|
144
|
-
/** Upper whisker end (e.g. `p95` / `max`). */
|
|
159
|
+
/** Box bottom — first quartile (e.g. `p25`). Omit with `q3` for a range-only box. */
|
|
160
|
+
readonly q1?: string | undefined;
|
|
161
|
+
/** Median line inside the box (e.g. `p50`). Omit for no centre line. */
|
|
162
|
+
readonly median?: string | undefined;
|
|
163
|
+
/** Box top — third quartile (e.g. `p75`). Omit with `q1` for a range-only box. */
|
|
164
|
+
readonly q3?: string | undefined;
|
|
165
|
+
/** Upper whisker end (e.g. `p95` / `max`). Required. */
|
|
145
166
|
readonly upper: string;
|
|
146
167
|
}
|
|
147
168
|
/** The four OHLC column names {@link ohlcFromTimeSeries} reads. */
|
|
@@ -205,16 +226,38 @@ export declare function bandFromTimeSeries<S extends SeriesSchema>(series: TimeS
|
|
|
205
226
|
*/
|
|
206
227
|
export declare function bandFromValueSeries<VS extends ValueSeriesSchema>(series: ValueSeries<VS>, lower: string, upper: string): BandSeries;
|
|
207
228
|
/**
|
|
208
|
-
* Build a {@link BoxSeries} from a pond `TimeSeries`
|
|
209
|
-
*
|
|
210
|
-
*
|
|
211
|
-
* typically `rolling`/`aggregate` percentiles
|
|
212
|
-
*
|
|
213
|
-
*
|
|
214
|
-
*
|
|
215
|
-
*
|
|
229
|
+
* Build a {@link BoxSeries} from a pond `TimeSeries`. `lower`/`upper` (the whisker
|
|
230
|
+
* reach) are required; `q1`/`q3` (the box body) and `median` (the centre line)
|
|
231
|
+
* are optional — omit them for a **range-only** box (a bid→ask segment). The
|
|
232
|
+
* quantile columns are typically `rolling`/`aggregate` percentiles; a key with
|
|
233
|
+
* any **present** quantile missing reads as a gap (the box draws nothing).
|
|
234
|
+
*
|
|
235
|
+
* **Key-shape aware, like {@link ohlcFromTimeSeries}.** An **interval /
|
|
236
|
+
* timeRange**-keyed series uses the key's own `[begin, end)` as the box span; a
|
|
237
|
+
* **point**-keyed (`time`) series synthesizes the span from neighbour spacing
|
|
238
|
+
* (each box centred on its timestamp, halfway to each neighbour), so a raw
|
|
239
|
+
* percentile-per-timestamp feed renders as contiguous boxes instead of collapsing
|
|
240
|
+
* to the 1px floor.
|
|
241
|
+
*
|
|
242
|
+
* @throws RangeError if any named quantile column does not exist, or if exactly
|
|
243
|
+
* one of `q1`/`q3` is given.
|
|
244
|
+
* @throws TypeError if any named quantile column is not a numeric column.
|
|
216
245
|
*/
|
|
217
246
|
export declare function boxFromTimeSeries<S extends SeriesSchema>(series: TimeSeries<S>, columns: BoxColumns): BoxSeries;
|
|
247
|
+
/**
|
|
248
|
+
* Build a {@link BoxSeries} from a pond `ValueSeries` — the value-axis sibling of
|
|
249
|
+
* {@link boxFromTimeSeries} (a volatility smile's per-strike bid/ask IV segments,
|
|
250
|
+
* a per-strike intraday IV distribution). A `ValueSeries` is **point-keyed** on
|
|
251
|
+
* its value axis, so the box span comes from **neighbour spacing** on
|
|
252
|
+
* `axisValues()` (each box centred on its axis value, halfway to each neighbour —
|
|
253
|
+
* the same rule as {@link barsFromValueSeries}), instead of collapsing to a point.
|
|
254
|
+
* Same optional-quantile / range-only contract as the time reader.
|
|
255
|
+
*
|
|
256
|
+
* @throws RangeError if any named quantile column does not exist, or if exactly
|
|
257
|
+
* one of `q1`/`q3` is given.
|
|
258
|
+
* @throws TypeError if any named quantile column is not a numeric column.
|
|
259
|
+
*/
|
|
260
|
+
export declare function boxFromValueSeries<VS extends ValueSeriesSchema>(series: ValueSeries<VS>, columns: BoxColumns): BoxSeries;
|
|
218
261
|
/**
|
|
219
262
|
* Build an {@link OhlcSeries} from a pond `TimeSeries` — four numeric price
|
|
220
263
|
* columns (`open`/`high`/`low`/`close`) plus the candle's horizontal slot.
|
package/dist/data.js
CHANGED
|
@@ -65,14 +65,6 @@ function timeAxis(series) {
|
|
|
65
65
|
// it lines up with the value arrays.
|
|
66
66
|
return series.keyColumn().begin.subarray(0, series.length);
|
|
67
67
|
}
|
|
68
|
-
/**
|
|
69
|
-
* The key column's `end` buffer aligned to the logical length (zero-copy). For a
|
|
70
|
-
* point-in-time key the column sets `end === begin`, so this returns the same
|
|
71
|
-
* timestamps as {@link timeAxis} — an interval key gives a distinct span.
|
|
72
|
-
*/
|
|
73
|
-
function timeEndAxis(series) {
|
|
74
|
-
return series.keyColumn().end.subarray(0, series.length);
|
|
75
|
-
}
|
|
76
68
|
/**
|
|
77
69
|
* Build a {@link ChartSeries} from a pond `TimeSeries` by reading its columnar
|
|
78
70
|
* buffers directly — no per-event materialization. `column` names a numeric
|
|
@@ -150,25 +142,91 @@ export function bandFromValueSeries(series, lower, upper) {
|
|
|
150
142
|
};
|
|
151
143
|
}
|
|
152
144
|
/**
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
145
|
+
* Reject a half-specified box body — `q1`/`q3` are both-or-neither (a box needs
|
|
146
|
+
* two edges or none). Called by both box readers before they build.
|
|
147
|
+
*/
|
|
148
|
+
function validateBoxColumns(columns) {
|
|
149
|
+
if ((columns.q1 === undefined) !== (columns.q3 === undefined)) {
|
|
150
|
+
throw new RangeError(`BoxPlot: 'q1' and 'q3' are both-or-neither — a box body needs both edges ` +
|
|
151
|
+
`(got q1=${columns.q1 ?? 'undefined'}, q3=${columns.q3 ?? 'undefined'}). ` +
|
|
152
|
+
`Omit both for a range-only box (whiskers lower→upper).`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
/** An all-`NaN` buffer of length `n` — the value channel of an absent quantile. */
|
|
156
|
+
function nanBuffer(n) {
|
|
157
|
+
return new Float64Array(n).fill(NaN);
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Build a {@link BoxSeries} from a pond `TimeSeries`. `lower`/`upper` (the whisker
|
|
161
|
+
* reach) are required; `q1`/`q3` (the box body) and `median` (the centre line)
|
|
162
|
+
* are optional — omit them for a **range-only** box (a bid→ask segment). The
|
|
163
|
+
* quantile columns are typically `rolling`/`aggregate` percentiles; a key with
|
|
164
|
+
* any **present** quantile missing reads as a gap (the box draws nothing).
|
|
158
165
|
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
166
|
+
* **Key-shape aware, like {@link ohlcFromTimeSeries}.** An **interval /
|
|
167
|
+
* timeRange**-keyed series uses the key's own `[begin, end)` as the box span; a
|
|
168
|
+
* **point**-keyed (`time`) series synthesizes the span from neighbour spacing
|
|
169
|
+
* (each box centred on its timestamp, halfway to each neighbour), so a raw
|
|
170
|
+
* percentile-per-timestamp feed renders as contiguous boxes instead of collapsing
|
|
171
|
+
* to the 1px floor.
|
|
172
|
+
*
|
|
173
|
+
* @throws RangeError if any named quantile column does not exist, or if exactly
|
|
174
|
+
* one of `q1`/`q3` is given.
|
|
175
|
+
* @throws TypeError if any named quantile column is not a numeric column.
|
|
161
176
|
*/
|
|
162
177
|
export function boxFromTimeSeries(series, columns) {
|
|
178
|
+
validateBoxColumns(columns);
|
|
179
|
+
const n = series.length;
|
|
180
|
+
const hasBox = columns.q1 !== undefined;
|
|
181
|
+
const hasMedian = columns.median !== undefined;
|
|
182
|
+
const { begin, end } = series.keyColumn().kind !== 'time'
|
|
183
|
+
? keyBeginEnd(series) // interval / timeRange: the key's own span
|
|
184
|
+
: neighbourSpans(series.keyColumn().begin, n); // point: neighbour spacing
|
|
163
185
|
return {
|
|
164
|
-
x:
|
|
165
|
-
xEnd:
|
|
186
|
+
x: begin,
|
|
187
|
+
xEnd: end,
|
|
166
188
|
lower: readNumericColumn(series, columns.lower),
|
|
167
|
-
q1: readNumericColumn(series, columns.q1),
|
|
168
|
-
median:
|
|
169
|
-
|
|
189
|
+
q1: hasBox ? readNumericColumn(series, columns.q1) : nanBuffer(n),
|
|
190
|
+
median: hasMedian
|
|
191
|
+
? readNumericColumn(series, columns.median)
|
|
192
|
+
: nanBuffer(n),
|
|
193
|
+
q3: hasBox ? readNumericColumn(series, columns.q3) : nanBuffer(n),
|
|
170
194
|
upper: readNumericColumn(series, columns.upper),
|
|
171
|
-
length:
|
|
195
|
+
length: n,
|
|
196
|
+
hasBox,
|
|
197
|
+
hasMedian,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Build a {@link BoxSeries} from a pond `ValueSeries` — the value-axis sibling of
|
|
202
|
+
* {@link boxFromTimeSeries} (a volatility smile's per-strike bid/ask IV segments,
|
|
203
|
+
* a per-strike intraday IV distribution). A `ValueSeries` is **point-keyed** on
|
|
204
|
+
* its value axis, so the box span comes from **neighbour spacing** on
|
|
205
|
+
* `axisValues()` (each box centred on its axis value, halfway to each neighbour —
|
|
206
|
+
* the same rule as {@link barsFromValueSeries}), instead of collapsing to a point.
|
|
207
|
+
* Same optional-quantile / range-only contract as the time reader.
|
|
208
|
+
*
|
|
209
|
+
* @throws RangeError if any named quantile column does not exist, or if exactly
|
|
210
|
+
* one of `q1`/`q3` is given.
|
|
211
|
+
* @throws TypeError if any named quantile column is not a numeric column.
|
|
212
|
+
*/
|
|
213
|
+
export function boxFromValueSeries(series, columns) {
|
|
214
|
+
validateBoxColumns(columns);
|
|
215
|
+
const n = series.length;
|
|
216
|
+
const hasBox = columns.q1 !== undefined;
|
|
217
|
+
const hasMedian = columns.median !== undefined;
|
|
218
|
+
const { begin, end } = neighbourSpans(series.axisValues(), n);
|
|
219
|
+
return {
|
|
220
|
+
x: begin,
|
|
221
|
+
xEnd: end,
|
|
222
|
+
lower: readValueColumn(series, columns.lower),
|
|
223
|
+
q1: hasBox ? readValueColumn(series, columns.q1) : nanBuffer(n),
|
|
224
|
+
median: hasMedian ? readValueColumn(series, columns.median) : nanBuffer(n),
|
|
225
|
+
q3: hasBox ? readValueColumn(series, columns.q3) : nanBuffer(n),
|
|
226
|
+
upper: readValueColumn(series, columns.upper),
|
|
227
|
+
length: n,
|
|
228
|
+
hasBox,
|
|
229
|
+
hasMedian,
|
|
172
230
|
};
|
|
173
231
|
}
|
|
174
232
|
/**
|
package/dist/scatter.d.ts
CHANGED
|
@@ -52,7 +52,7 @@ export declare function scatterExtent(cs: ChartSeries): [number, number] | null;
|
|
|
52
52
|
export declare function drawScatter(ctx: CanvasRenderingContext2D, cs: ChartSeries, xScale: Scale, yScale: Scale, style: ScatterStyle, encoding: ResolvedEncoding, keyAt: (i: number) => number, labelAt: ((i: number) => string | undefined) | undefined, font: {
|
|
53
53
|
readonly family: string;
|
|
54
54
|
readonly size: number;
|
|
55
|
-
}, selected: SelectInfo | null, seriesId: string | undefined): void;
|
|
55
|
+
}, selected: SelectInfo | null, seriesId: string | undefined, offsetPx?: number): void;
|
|
56
56
|
/**
|
|
57
57
|
* Hit-test plot-pixel `(qx, qy)` against the scatter's points — the topmost
|
|
58
58
|
* point whose circle contains the click, or `null`. "Topmost" = the
|
|
@@ -68,5 +68,5 @@ export declare function drawScatter(ctx: CanvasRenderingContext2D, cs: ChartSeri
|
|
|
68
68
|
* Pure: takes the same `xScale`/`yScale` the row hands to `draw`, so it
|
|
69
69
|
* unit-tests without a DOM (mirrors the `sampleAt` / `resolveSelection` split).
|
|
70
70
|
*/
|
|
71
|
-
export declare function hitTestScatter(cs: ChartSeries, qx: number, qy: number, xScale: Scale, yScale: Scale, encoding: ResolvedEncoding, keyAt: (i: number) => number, id: string, seriesLabel: string): SelectInfo | null;
|
|
71
|
+
export declare function hitTestScatter(cs: ChartSeries, qx: number, qy: number, xScale: Scale, yScale: Scale, encoding: ResolvedEncoding, keyAt: (i: number) => number, id: string, seriesLabel: string, offsetPx?: number): SelectInfo | null;
|
|
72
72
|
//# sourceMappingURL=scatter.d.ts.map
|
package/dist/scatter.js
CHANGED
|
@@ -114,7 +114,7 @@ export function scatterExtent(cs) {
|
|
|
114
114
|
* of the selection match. A point lights only when the selection's
|
|
115
115
|
* `id` matches, keyed to the sample by its `key`.
|
|
116
116
|
*/
|
|
117
|
-
export function drawScatter(ctx, cs, xScale, yScale, style, encoding, keyAt, labelAt, font, selected, seriesId) {
|
|
117
|
+
export function drawScatter(ctx, cs, xScale, yScale, style, encoding, keyAt, labelAt, font, selected, seriesId, offsetPx = 0) {
|
|
118
118
|
ctx.save();
|
|
119
119
|
// The selection only lights up a point of *this* series; resolve the key once.
|
|
120
120
|
// A no-id (non-selectable) layer passes `undefined` and never matches.
|
|
@@ -126,7 +126,9 @@ export function drawScatter(ctx, cs, xScale, yScale, style, encoding, keyAt, lab
|
|
|
126
126
|
for (let i = 0; i < cs.length; i += 1) {
|
|
127
127
|
if (!isPoint(cs, i))
|
|
128
128
|
continue;
|
|
129
|
-
|
|
129
|
+
// `offsetPx` nudges the whole scatter in pixel space (zoom-stable) — for
|
|
130
|
+
// pairing same-key marks (call/put at one strike) beside each other.
|
|
131
|
+
const px = xScale(cs.x[i]) + offsetPx;
|
|
130
132
|
const py = yScale(cs.y[i]);
|
|
131
133
|
const r = encoding.radiusAt(i);
|
|
132
134
|
ctx.beginPath();
|
|
@@ -166,7 +168,7 @@ export function drawScatter(ctx, cs, xScale, yScale, style, encoding, keyAt, lab
|
|
|
166
168
|
const text = labelAt(i);
|
|
167
169
|
if (text === undefined || text === '')
|
|
168
170
|
continue;
|
|
169
|
-
const px = xScale(cs.x[i]);
|
|
171
|
+
const px = xScale(cs.x[i]) + offsetPx;
|
|
170
172
|
const py = yScale(cs.y[i]);
|
|
171
173
|
const r = encoding.radiusAt(i);
|
|
172
174
|
// Sit the label just right of the point (past its radius), vertically
|
|
@@ -193,11 +195,12 @@ const LABEL_GAP = 4;
|
|
|
193
195
|
* Pure: takes the same `xScale`/`yScale` the row hands to `draw`, so it
|
|
194
196
|
* unit-tests without a DOM (mirrors the `sampleAt` / `resolveSelection` split).
|
|
195
197
|
*/
|
|
196
|
-
export function hitTestScatter(cs, qx, qy, xScale, yScale, encoding, keyAt, id, seriesLabel) {
|
|
198
|
+
export function hitTestScatter(cs, qx, qy, xScale, yScale, encoding, keyAt, id, seriesLabel, offsetPx = 0) {
|
|
197
199
|
for (let i = cs.length - 1; i >= 0; i -= 1) {
|
|
198
200
|
if (!isPoint(cs, i))
|
|
199
201
|
continue;
|
|
200
|
-
|
|
202
|
+
// Match the drawn position (offset in px space) so the click target aligns.
|
|
203
|
+
const px = xScale(cs.x[i]) + offsetPx;
|
|
201
204
|
const py = yScale(cs.y[i]);
|
|
202
205
|
const r = encoding.radiusAt(i);
|
|
203
206
|
const dx = qx - px;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pond-ts/charts",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.44.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Canvas-rendered, streaming-first time-series charts for pond-ts",
|
|
6
6
|
"license": "MIT",
|
|
@@ -38,8 +38,8 @@
|
|
|
38
38
|
"perf": "PERF_BENCH=1 playwright test perf.spec.ts --workers=1"
|
|
39
39
|
},
|
|
40
40
|
"peerDependencies": {
|
|
41
|
-
"@pond-ts/react": "^0.
|
|
42
|
-
"pond-ts": "^0.
|
|
41
|
+
"@pond-ts/react": "^0.44.0",
|
|
42
|
+
"pond-ts": "^0.44.0",
|
|
43
43
|
"react": "^18.0.0 || ^19.0.0"
|
|
44
44
|
},
|
|
45
45
|
"devDependencies": {
|