@startsimpli/ui 0.4.138 → 0.4.139
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/package.json +6 -4
- package/src/components/charts/TimeSeriesChart.tsx +305 -0
- package/src/components/charts/__tests__/TimeSeriesChart.test.tsx +141 -0
- package/src/components/charts/__tests__/format.test.ts +159 -0
- package/src/components/charts/__tests__/series.test.ts +168 -0
- package/src/components/charts/index.ts +13 -0
- package/src/components/charts/lib/format.ts +259 -0
- package/src/components/charts/lib/series.ts +183 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@startsimpli/ui",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.139",
|
|
4
4
|
"description": "Shared UI components package for StartSimpli applications",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"types": "./src/index.ts",
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
"./foundry": "./src/foundry/index.ts",
|
|
12
12
|
"./gantt": "./src/components/gantt/index.ts",
|
|
13
13
|
"./gantt/styles": "./src/components/gantt/gantt.css",
|
|
14
|
+
"./charts": "./src/components/charts/index.ts",
|
|
14
15
|
"./components": "./src/components/index.ts",
|
|
15
16
|
"./error": "./src/components/error/index.ts",
|
|
16
17
|
"./i18n": "./src/components/i18n/index.ts",
|
|
@@ -48,8 +49,8 @@
|
|
|
48
49
|
"next": "^14.0.0 || ^15.0.0 || ^16.0.0",
|
|
49
50
|
"react": "^18.0.0 || ^19.0.0",
|
|
50
51
|
"react-dom": "^18.0.0 || ^19.0.0",
|
|
51
|
-
"@startsimpli/auth": "^0.4.38",
|
|
52
52
|
"@startsimpli/api": "^0.5.66",
|
|
53
|
+
"@startsimpli/auth": "^0.4.38",
|
|
53
54
|
"@startsimpli/i18n": "^0.1.3"
|
|
54
55
|
},
|
|
55
56
|
"peerDependenciesMeta": {
|
|
@@ -101,6 +102,7 @@
|
|
|
101
102
|
"react-day-picker": "^9.14.0",
|
|
102
103
|
"react-markdown": "^10.1.0",
|
|
103
104
|
"react-syntax-highlighter": "^16.1.0",
|
|
105
|
+
"recharts": "^3.10.1",
|
|
104
106
|
"remark-gfm": "^4.0.1",
|
|
105
107
|
"tailwind-merge": "^2.6.1",
|
|
106
108
|
"tailwindcss-animate": "^1.0.7",
|
|
@@ -124,8 +126,8 @@
|
|
|
124
126
|
"tailwindcss": "^3.4.19",
|
|
125
127
|
"tsup": "^8.5.1",
|
|
126
128
|
"typescript": "^6.0.3",
|
|
127
|
-
"@startsimpli/
|
|
128
|
-
"@startsimpli/
|
|
129
|
+
"@startsimpli/realtime": "0.0.2",
|
|
130
|
+
"@startsimpli/i18n": "0.1.3"
|
|
129
131
|
},
|
|
130
132
|
"keywords": [
|
|
131
133
|
"ui",
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import {
|
|
5
|
+
CartesianGrid,
|
|
6
|
+
Line,
|
|
7
|
+
LineChart,
|
|
8
|
+
ReferenceLine,
|
|
9
|
+
ResponsiveContainer,
|
|
10
|
+
Tooltip,
|
|
11
|
+
XAxis,
|
|
12
|
+
YAxis,
|
|
13
|
+
} from 'recharts'
|
|
14
|
+
import { cn } from '../../lib/utils'
|
|
15
|
+
import {
|
|
16
|
+
buildChartModel,
|
|
17
|
+
valueKey,
|
|
18
|
+
type ChartModel,
|
|
19
|
+
type ChartRow,
|
|
20
|
+
type TimeSeries,
|
|
21
|
+
type TimeSeriesMode,
|
|
22
|
+
type TimeSeriesUnit,
|
|
23
|
+
} from './lib/series'
|
|
24
|
+
import {
|
|
25
|
+
axisValueFormatter,
|
|
26
|
+
formatTick,
|
|
27
|
+
formatTooltipTime,
|
|
28
|
+
formatValue,
|
|
29
|
+
isDateOnly,
|
|
30
|
+
timeTicks,
|
|
31
|
+
valueTicks,
|
|
32
|
+
} from './lib/format'
|
|
33
|
+
|
|
34
|
+
export interface TimeSeriesReferenceLine {
|
|
35
|
+
/** Where the line sits, in the plotted unit (a drawdown % in drawdown mode). */
|
|
36
|
+
y: number
|
|
37
|
+
label?: string
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface TimeSeriesChartProps {
|
|
41
|
+
series: TimeSeries[]
|
|
42
|
+
unit: TimeSeriesUnit
|
|
43
|
+
referenceLine?: TimeSeriesReferenceLine
|
|
44
|
+
/** `'drawdown'` plots each series as the % below its running peak. */
|
|
45
|
+
mode?: TimeSeriesMode
|
|
46
|
+
/** Plot height in px. The legend, when there is one, sits below it. */
|
|
47
|
+
height?: number
|
|
48
|
+
/** Overrides the generated description. */
|
|
49
|
+
ariaLabel?: string
|
|
50
|
+
className?: string
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Series colours, in order, from the theme contract (theme/contract.css) — so
|
|
55
|
+
* they follow the app's brand and flip with `.dark` like every other surface.
|
|
56
|
+
* `--success` carries the same fallback channels as the Tailwind preset, for
|
|
57
|
+
* apps that predate the token.
|
|
58
|
+
*/
|
|
59
|
+
export const SERIES_COLORS = [
|
|
60
|
+
'hsl(var(--primary))',
|
|
61
|
+
'hsl(var(--muted-foreground))',
|
|
62
|
+
'hsl(var(--success, 142.1 76.2% 36.3%))',
|
|
63
|
+
'hsl(var(--destructive))',
|
|
64
|
+
'hsl(var(--foreground))',
|
|
65
|
+
] as const
|
|
66
|
+
|
|
67
|
+
/** After the palette runs out, series repeat its colours with a dash. */
|
|
68
|
+
const DASHES = [undefined, '6 4', '2 3'] as const
|
|
69
|
+
|
|
70
|
+
export function seriesStroke(index: number): { color: string; dash: string | undefined } {
|
|
71
|
+
const n = SERIES_COLORS.length
|
|
72
|
+
return {
|
|
73
|
+
color: SERIES_COLORS[index % n],
|
|
74
|
+
dash: DASHES[Math.floor(index / n) % DASHES.length],
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const AXIS_TICK = { fill: 'hsl(var(--muted-foreground))', fontSize: 12 }
|
|
79
|
+
const GRID_STROKE = 'hsl(var(--border))'
|
|
80
|
+
|
|
81
|
+
function describe(model: ChartModel, mode: TimeSeriesMode, utc: boolean): string {
|
|
82
|
+
const kind = mode === 'drawdown' ? 'Drawdown chart' : 'Line chart'
|
|
83
|
+
if (!model.hasData) return `${kind}: no data`
|
|
84
|
+
const first = formatTooltipTime(model.times[0], utc, false)
|
|
85
|
+
const last = formatTooltipTime(model.times[model.times.length - 1], utc, false)
|
|
86
|
+
return `${kind} of ${model.names.join(', ')} from ${first} to ${last}`
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
interface TooltipBodyProps {
|
|
90
|
+
active?: boolean
|
|
91
|
+
label?: number | string
|
|
92
|
+
rowsByTime: Map<number, ChartRow>
|
|
93
|
+
names: string[]
|
|
94
|
+
unit: TimeSeriesUnit
|
|
95
|
+
utc: boolean
|
|
96
|
+
withTime: boolean
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function TooltipBody({ active, label, rowsByTime, names, unit, utc, withTime }: TooltipBodyProps) {
|
|
100
|
+
if (!active || label === undefined) return null
|
|
101
|
+
const t = Number(label)
|
|
102
|
+
const row = rowsByTime.get(t)
|
|
103
|
+
if (!row) return null
|
|
104
|
+
|
|
105
|
+
// Every series with a sample here — a gap reads as a dash, a series with no
|
|
106
|
+
// sample at this instant is left out rather than shown as missing.
|
|
107
|
+
const entries = names
|
|
108
|
+
.map((name, i) => ({ name, i, v: row[valueKey(i)] }))
|
|
109
|
+
.filter((e) => e.v !== undefined)
|
|
110
|
+
|
|
111
|
+
return (
|
|
112
|
+
<div className="rounded-md border bg-popover px-3 py-2 text-xs text-popover-foreground shadow-md">
|
|
113
|
+
<div className="mb-1 font-medium">{formatTooltipTime(t, utc, withTime)}</div>
|
|
114
|
+
<ul className="space-y-0.5">
|
|
115
|
+
{entries.map((e) => (
|
|
116
|
+
<li key={e.i} className="flex items-center justify-between gap-4">
|
|
117
|
+
<span className="flex min-w-0 items-center gap-1.5">
|
|
118
|
+
<span
|
|
119
|
+
className="h-2 w-2 shrink-0 rounded-full"
|
|
120
|
+
style={{ backgroundColor: seriesStroke(e.i).color }}
|
|
121
|
+
aria-hidden="true"
|
|
122
|
+
/>
|
|
123
|
+
<span className="truncate text-muted-foreground">{e.name}</span>
|
|
124
|
+
</span>
|
|
125
|
+
<span className="font-medium tabular-nums">
|
|
126
|
+
{typeof e.v === 'number' ? formatValue(e.v, unit) : '—'}
|
|
127
|
+
</span>
|
|
128
|
+
</li>
|
|
129
|
+
))}
|
|
130
|
+
</ul>
|
|
131
|
+
</div>
|
|
132
|
+
)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function SeriesLegend({ names }: { names: string[] }) {
|
|
136
|
+
return (
|
|
137
|
+
<ul className="flex flex-wrap items-center gap-x-4 gap-y-1 px-2 pt-2 text-xs text-muted-foreground">
|
|
138
|
+
{names.map((name, i) => {
|
|
139
|
+
const { color, dash } = seriesStroke(i)
|
|
140
|
+
return (
|
|
141
|
+
<li key={`${name}-${i}`} className="flex items-center gap-1.5">
|
|
142
|
+
<svg width="16" height="8" aria-hidden="true" className="shrink-0">
|
|
143
|
+
<line x1="0" y1="4" x2="16" y2="4" stroke={color} strokeWidth="2" strokeDasharray={dash} />
|
|
144
|
+
</svg>
|
|
145
|
+
<span>{name}</span>
|
|
146
|
+
</li>
|
|
147
|
+
)
|
|
148
|
+
})}
|
|
149
|
+
</ul>
|
|
150
|
+
)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* One or more time series on a date axis. Values are formatted for `unit`;
|
|
155
|
+
* `null` points break the line instead of being joined across. With
|
|
156
|
+
* `mode="drawdown"` every series is shown as the % below its own running peak.
|
|
157
|
+
* Colours come from the theme tokens, so it follows light/dark with the app.
|
|
158
|
+
*/
|
|
159
|
+
export function TimeSeriesChart({
|
|
160
|
+
series,
|
|
161
|
+
unit,
|
|
162
|
+
referenceLine,
|
|
163
|
+
mode = 'line',
|
|
164
|
+
height = 300,
|
|
165
|
+
ariaLabel,
|
|
166
|
+
className,
|
|
167
|
+
}: TimeSeriesChartProps) {
|
|
168
|
+
const model = React.useMemo(() => buildChartModel(series, mode, unit), [series, mode, unit])
|
|
169
|
+
const rowsByTime = React.useMemo(
|
|
170
|
+
() => new Map(model.rows.map((r) => [r.t, r] as const)),
|
|
171
|
+
[model],
|
|
172
|
+
)
|
|
173
|
+
const utc = isDateOnly(model.times)
|
|
174
|
+
const withTime = !utc && model.times.some((t) => {
|
|
175
|
+
const d = new Date(t)
|
|
176
|
+
return d.getHours() !== 0 || d.getMinutes() !== 0
|
|
177
|
+
})
|
|
178
|
+
const minT = model.times[0]
|
|
179
|
+
const maxT = model.times[model.times.length - 1]
|
|
180
|
+
const { ticks, step } = React.useMemo(
|
|
181
|
+
() => timeTicks(minT, maxT, utc),
|
|
182
|
+
[minT, maxT, utc],
|
|
183
|
+
)
|
|
184
|
+
const refY = referenceLine?.y
|
|
185
|
+
const yAxis = React.useMemo(() => {
|
|
186
|
+
let lo = model.min
|
|
187
|
+
let hi = model.max
|
|
188
|
+
if (typeof refY === 'number' && Number.isFinite(refY)) {
|
|
189
|
+
lo = Math.min(lo, refY)
|
|
190
|
+
hi = Math.max(hi, refY)
|
|
191
|
+
}
|
|
192
|
+
// A drawdown axis always tops out at 0 — "at the peak".
|
|
193
|
+
if (mode === 'drawdown') hi = 0
|
|
194
|
+
const { ticks: yTicks, step: yStep } = valueTicks(lo, hi)
|
|
195
|
+
return { ticks: yTicks, format: axisValueFormatter(model.unit, yTicks, yStep) }
|
|
196
|
+
}, [model, refY, mode])
|
|
197
|
+
const showLegend = series.length >= 2
|
|
198
|
+
const safeHeight = Math.max(height, 80)
|
|
199
|
+
const label = ariaLabel ?? describe(model, mode, utc)
|
|
200
|
+
|
|
201
|
+
if (!model.hasData) {
|
|
202
|
+
return (
|
|
203
|
+
<div
|
|
204
|
+
role="img"
|
|
205
|
+
aria-label={label}
|
|
206
|
+
className={cn(
|
|
207
|
+
'flex w-full items-center justify-center rounded-md border border-dashed text-sm text-muted-foreground',
|
|
208
|
+
className,
|
|
209
|
+
)}
|
|
210
|
+
style={{ height: safeHeight }}
|
|
211
|
+
>
|
|
212
|
+
No data
|
|
213
|
+
</div>
|
|
214
|
+
)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return (
|
|
218
|
+
<div role="figure" aria-label={label} className={cn('w-full', className)}>
|
|
219
|
+
<ResponsiveContainer width="100%" height={safeHeight}>
|
|
220
|
+
<LineChart data={model.rows} margin={{ top: 8, right: 16, bottom: 0, left: 0 }}>
|
|
221
|
+
<CartesianGrid stroke={GRID_STROKE} strokeDasharray="3 3" vertical={false} />
|
|
222
|
+
<XAxis
|
|
223
|
+
dataKey="t"
|
|
224
|
+
type="number"
|
|
225
|
+
scale="time"
|
|
226
|
+
domain={[minT, maxT]}
|
|
227
|
+
ticks={ticks}
|
|
228
|
+
tickFormatter={(t: number) => formatTick(t, step, utc)}
|
|
229
|
+
tick={AXIS_TICK}
|
|
230
|
+
stroke={GRID_STROKE}
|
|
231
|
+
tickLine={false}
|
|
232
|
+
minTickGap={16}
|
|
233
|
+
/>
|
|
234
|
+
<YAxis
|
|
235
|
+
type="number"
|
|
236
|
+
ticks={yAxis.ticks}
|
|
237
|
+
domain={[yAxis.ticks[0], yAxis.ticks[yAxis.ticks.length - 1]]}
|
|
238
|
+
tickFormatter={(v: number) => yAxis.format(v)}
|
|
239
|
+
tick={AXIS_TICK}
|
|
240
|
+
stroke={GRID_STROKE}
|
|
241
|
+
tickLine={false}
|
|
242
|
+
axisLine={false}
|
|
243
|
+
width={64}
|
|
244
|
+
interval={0}
|
|
245
|
+
/>
|
|
246
|
+
<Tooltip
|
|
247
|
+
cursor={{ stroke: GRID_STROKE }}
|
|
248
|
+
isAnimationActive={false}
|
|
249
|
+
content={(props) => (
|
|
250
|
+
<TooltipBody
|
|
251
|
+
active={props.active}
|
|
252
|
+
label={props.label as number | string | undefined}
|
|
253
|
+
rowsByTime={rowsByTime}
|
|
254
|
+
names={model.names}
|
|
255
|
+
unit={model.unit}
|
|
256
|
+
utc={utc}
|
|
257
|
+
withTime={withTime}
|
|
258
|
+
/>
|
|
259
|
+
)}
|
|
260
|
+
/>
|
|
261
|
+
{referenceLine && (
|
|
262
|
+
<ReferenceLine
|
|
263
|
+
y={referenceLine.y}
|
|
264
|
+
stroke="hsl(var(--muted-foreground))"
|
|
265
|
+
strokeDasharray="4 4"
|
|
266
|
+
ifOverflow="extendDomain"
|
|
267
|
+
label={
|
|
268
|
+
referenceLine.label
|
|
269
|
+
? {
|
|
270
|
+
value: referenceLine.label,
|
|
271
|
+
position: 'insideTopRight',
|
|
272
|
+
fill: 'hsl(var(--muted-foreground))',
|
|
273
|
+
fontSize: 12,
|
|
274
|
+
}
|
|
275
|
+
: undefined
|
|
276
|
+
}
|
|
277
|
+
/>
|
|
278
|
+
)}
|
|
279
|
+
{model.segments.map((seg) => {
|
|
280
|
+
const { color, dash } = seriesStroke(seg.seriesIndex)
|
|
281
|
+
return (
|
|
282
|
+
<Line
|
|
283
|
+
key={seg.dataKey}
|
|
284
|
+
dataKey={seg.dataKey}
|
|
285
|
+
name={model.names[seg.seriesIndex]}
|
|
286
|
+
type="linear"
|
|
287
|
+
stroke={color}
|
|
288
|
+
strokeWidth={2}
|
|
289
|
+
strokeDasharray={dash}
|
|
290
|
+
// Within a run, step over instants this series has no sample
|
|
291
|
+
// at; runs themselves never join, which is what makes a null a gap.
|
|
292
|
+
connectNulls
|
|
293
|
+
dot={seg.single ? { r: 2.5, fill: color, stroke: color } : false}
|
|
294
|
+
activeDot={{ r: 3.5, fill: color, stroke: 'hsl(var(--background))' }}
|
|
295
|
+
isAnimationActive={false}
|
|
296
|
+
legendType="none"
|
|
297
|
+
/>
|
|
298
|
+
)
|
|
299
|
+
})}
|
|
300
|
+
</LineChart>
|
|
301
|
+
</ResponsiveContainer>
|
|
302
|
+
{showLegend && <SeriesLegend names={model.names} />}
|
|
303
|
+
</div>
|
|
304
|
+
)
|
|
305
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react'
|
|
2
|
+
import { TimeSeriesChart, SERIES_COLORS, seriesStroke } from '../TimeSeriesChart'
|
|
3
|
+
import type { TimeSeries } from '../lib/series'
|
|
4
|
+
|
|
5
|
+
// jsdom lays nothing out: recharts' ResponsiveContainer renders no chart at a
|
|
6
|
+
// 0x0 size, and every tick label would measure 0 wide. Give the container a
|
|
7
|
+
// real box and text a plausible one (8px a character), so recharts' own
|
|
8
|
+
// overlap culling behaves as it does in a browser.
|
|
9
|
+
function rect(width: number, height: number): DOMRect {
|
|
10
|
+
return { width, height, top: 0, left: 0, right: width, bottom: height, x: 0, y: 0, toJSON: () => ({}) } as DOMRect
|
|
11
|
+
}
|
|
12
|
+
let rectSpy: jest.SpyInstance
|
|
13
|
+
beforeAll(() => {
|
|
14
|
+
rectSpy = jest
|
|
15
|
+
.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
|
|
16
|
+
.mockImplementation(function (this: HTMLElement) {
|
|
17
|
+
if (this.classList.contains('recharts-responsive-container')) return rect(800, 300)
|
|
18
|
+
return rect((this.textContent ?? '').length * 8, 14)
|
|
19
|
+
})
|
|
20
|
+
})
|
|
21
|
+
afterAll(() => rectSpy.mockRestore())
|
|
22
|
+
|
|
23
|
+
/** Tick labels on one axis; recharts splits a label into one tspan per word. */
|
|
24
|
+
function tickLabels(container: HTMLElement, axis: 'x' | 'y'): string[] {
|
|
25
|
+
return Array.from(
|
|
26
|
+
container.querySelectorAll(`.recharts-${axis}Axis-tick-labels .recharts-cartesian-axis-tick-value`),
|
|
27
|
+
).map((n) =>
|
|
28
|
+
Array.from(n.querySelectorAll('tspan'))
|
|
29
|
+
.map((s) => s.textContent)
|
|
30
|
+
.join(' '),
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const strategy: TimeSeries = {
|
|
35
|
+
name: 'Strategy',
|
|
36
|
+
points: [
|
|
37
|
+
{ t: '2026-01-05', v: 100_000 },
|
|
38
|
+
{ t: '2026-01-06', v: 104_000 },
|
|
39
|
+
{ t: '2026-01-07', v: null },
|
|
40
|
+
{ t: '2026-01-08', v: 98_000 },
|
|
41
|
+
{ t: '2026-01-09', v: 110_000 },
|
|
42
|
+
],
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const holding: TimeSeries = {
|
|
46
|
+
name: 'Buy & hold',
|
|
47
|
+
points: [
|
|
48
|
+
{ t: '2026-01-05', v: 100_000 },
|
|
49
|
+
{ t: '2026-01-06', v: 101_000 },
|
|
50
|
+
{ t: '2026-01-07', v: 99_000 },
|
|
51
|
+
{ t: '2026-01-08', v: 97_000 },
|
|
52
|
+
{ t: '2026-01-09', v: 102_000 },
|
|
53
|
+
],
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function linePaths(container: HTMLElement) {
|
|
57
|
+
return Array.from(container.querySelectorAll('path.recharts-line-curve'))
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
describe('TimeSeriesChart', () => {
|
|
61
|
+
it('draws one stroke per unbroken run, so a null leaves a gap', () => {
|
|
62
|
+
const { container } = render(<TimeSeriesChart series={[strategy]} unit="$" />)
|
|
63
|
+
// Strategy breaks at Jan 7: two runs, two strokes, nothing joining them.
|
|
64
|
+
expect(linePaths(container)).toHaveLength(2)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('colours series from theme tokens, never a fixed palette', () => {
|
|
68
|
+
const { container } = render(<TimeSeriesChart series={[strategy, holding]} unit="$" />)
|
|
69
|
+
const strokes = linePaths(container).map((p) => p.getAttribute('stroke'))
|
|
70
|
+
expect(strokes.length).toBeGreaterThan(0)
|
|
71
|
+
for (const stroke of strokes) {
|
|
72
|
+
expect(stroke).toMatch(/^hsl\(var\(--[a-z-]+/)
|
|
73
|
+
}
|
|
74
|
+
expect(new Set(strokes)).toEqual(new Set([SERIES_COLORS[0], SERIES_COLORS[1]]))
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('shows a legend for two or more series and none for one', () => {
|
|
78
|
+
const { rerender } = render(<TimeSeriesChart series={[strategy, holding]} unit="$" />)
|
|
79
|
+
expect(screen.getByText('Strategy')).toBeInTheDocument()
|
|
80
|
+
expect(screen.getByText('Buy & hold')).toBeInTheDocument()
|
|
81
|
+
|
|
82
|
+
rerender(<TimeSeriesChart series={[strategy]} unit="$" />)
|
|
83
|
+
expect(screen.queryByText('Strategy')).not.toBeInTheDocument()
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('labels the date axis in calendar terms and the value axis in the unit', () => {
|
|
87
|
+
const { container } = render(<TimeSeriesChart series={[strategy]} unit="$" />)
|
|
88
|
+
expect(tickLabels(container, 'x')).toContain('Jan 5')
|
|
89
|
+
const y = tickLabels(container, 'y')
|
|
90
|
+
expect(y.length).toBeGreaterThan(1)
|
|
91
|
+
expect(y.every((t) => /^\$\d+(\.\d+)?K$/.test(t))).toBe(true)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('draws a labelled reference line', () => {
|
|
95
|
+
const { container } = render(
|
|
96
|
+
<TimeSeriesChart
|
|
97
|
+
series={[strategy]}
|
|
98
|
+
unit="$"
|
|
99
|
+
referenceLine={{ y: 100_000, label: 'Start' }}
|
|
100
|
+
/>,
|
|
101
|
+
)
|
|
102
|
+
expect(container.querySelector('.recharts-reference-line')).not.toBeNull()
|
|
103
|
+
expect(screen.getByText('Start')).toBeInTheDocument()
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('plots drawdown in % on an axis that tops out at 0', () => {
|
|
107
|
+
const { container } = render(
|
|
108
|
+
<TimeSeriesChart series={[strategy]} unit="$" mode="drawdown" />,
|
|
109
|
+
)
|
|
110
|
+
expect(screen.getByRole('figure')).toHaveAccessibleName(/^Drawdown chart of Strategy/)
|
|
111
|
+
const yTicks = tickLabels(container, 'y')
|
|
112
|
+
|
|
113
|
+
expect(yTicks.length).toBeGreaterThan(0)
|
|
114
|
+
expect(yTicks.every((t) => t.endsWith('%'))).toBe(true)
|
|
115
|
+
expect(yTicks).toContain('0%')
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('describes itself for assistive tech', () => {
|
|
119
|
+
render(<TimeSeriesChart series={[strategy, holding]} unit="$" />)
|
|
120
|
+
expect(screen.getByRole('figure')).toHaveAccessibleName(
|
|
121
|
+
'Line chart of Strategy, Buy & hold from Jan 5, 2026 to Jan 9, 2026',
|
|
122
|
+
)
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('renders an empty state when there is nothing to plot', () => {
|
|
126
|
+
render(
|
|
127
|
+
<TimeSeriesChart series={[{ name: 'Empty', points: [{ t: '2026-01-05', v: null }] }]} unit="%" />,
|
|
128
|
+
)
|
|
129
|
+
expect(screen.getByRole('img')).toHaveAccessibleName('Line chart: no data')
|
|
130
|
+
expect(screen.getByText('No data')).toBeInTheDocument()
|
|
131
|
+
})
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
describe('seriesStroke', () => {
|
|
135
|
+
it('cycles the token palette and dashes the repeats', () => {
|
|
136
|
+
const n = SERIES_COLORS.length
|
|
137
|
+
expect(seriesStroke(0)).toEqual({ color: SERIES_COLORS[0], dash: undefined })
|
|
138
|
+
expect(seriesStroke(n)).toEqual({ color: SERIES_COLORS[0], dash: '6 4' })
|
|
139
|
+
expect(seriesStroke(n + 1).color).toBe(SERIES_COLORS[1])
|
|
140
|
+
})
|
|
141
|
+
})
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import {
|
|
2
|
+
axisValueFormatter,
|
|
3
|
+
formatTick,
|
|
4
|
+
formatTooltipTime,
|
|
5
|
+
formatValue,
|
|
6
|
+
isDateOnly,
|
|
7
|
+
timeTicks,
|
|
8
|
+
valueTicks,
|
|
9
|
+
} from '../lib/format'
|
|
10
|
+
|
|
11
|
+
describe('formatValue', () => {
|
|
12
|
+
it('formats dollars with cents below $1,000 and whole dollars above', () => {
|
|
13
|
+
expect(formatValue(12.5, '$')).toBe('$12.50')
|
|
14
|
+
expect(formatValue(319517.4, '$')).toBe('$319,517')
|
|
15
|
+
expect(formatValue(-1234, '$')).toBe('-$1,234')
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('formats % values as percentage points, not fractions', () => {
|
|
19
|
+
expect(formatValue(12.345, '%')).toBe('12.3%')
|
|
20
|
+
expect(formatValue(-3.14159, '%')).toBe('-3.14%')
|
|
21
|
+
expect(formatValue(250.4, '%')).toBe('250%')
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('formats plain numbers with grouping', () => {
|
|
25
|
+
expect(formatValue(1234.567, 'number')).toBe('1,235')
|
|
26
|
+
expect(formatValue(0.12345, 'number')).toBe('0.12')
|
|
27
|
+
})
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
describe('formatValue at zero', () => {
|
|
31
|
+
it('never renders negative zero', () => {
|
|
32
|
+
expect(formatValue(-0.0001, '%')).toBe('0%')
|
|
33
|
+
expect(formatValue(-0.001, '$')).toBe('$0.00')
|
|
34
|
+
})
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
describe('valueTicks', () => {
|
|
38
|
+
it('covers the range with round multiples of a nice step', () => {
|
|
39
|
+
const { ticks, step } = valueTicks(-8, 190)
|
|
40
|
+
expect(step.value).toBe(50)
|
|
41
|
+
expect(ticks).toEqual([-50, 0, 50, 100, 150, 200])
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('keeps fractional steps exact', () => {
|
|
45
|
+
const { ticks, step } = valueTicks(0.1, 0.9)
|
|
46
|
+
expect(step).toEqual({ value: 0.2, decimals: 1 })
|
|
47
|
+
expect(ticks).toEqual([0, 0.2, 0.4, 0.6, 0.8, 1])
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('needs one more decimal for a 2.5 step', () => {
|
|
51
|
+
expect(valueTicks(30.3, 31.3).step).toEqual({ value: 0.25, decimals: 2 })
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('opens a band around a flat series', () => {
|
|
55
|
+
const { ticks } = valueTicks(100, 100)
|
|
56
|
+
expect(ticks[0]).toBeLessThan(100)
|
|
57
|
+
expect(ticks[ticks.length - 1]).toBeGreaterThan(100)
|
|
58
|
+
})
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
describe('axisValueFormatter', () => {
|
|
62
|
+
const labels = (unit: '$' | '%' | 'number', min: number, max: number) => {
|
|
63
|
+
const { ticks, step } = valueTicks(min, max)
|
|
64
|
+
const format = axisValueFormatter(unit, ticks, step)
|
|
65
|
+
return ticks.map(format)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
it('compacts by the largest tick and keeps one precision across labels', () => {
|
|
69
|
+
expect(labels('$', 60_000, 300_000)).toEqual(['$0', '$100K', '$200K', '$300K'])
|
|
70
|
+
expect(labels('$', 30.3, 31.3)).toEqual(['$30.25', '$30.50', '$30.75', '$31.00', '$31.25', '$31.50'])
|
|
71
|
+
expect(labels('number', 0, 2_500_000)).toEqual(['0', '1M', '2M', '3M'])
|
|
72
|
+
expect(labels('number', 0, 1_800_000)).toEqual(['0', '0.5M', '1.0M', '1.5M', '2.0M'])
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('keeps cents on small dollar axes', () => {
|
|
76
|
+
expect(labels('$', 1, 5)).toEqual(['$1.00', '$2.00', '$3.00', '$4.00', '$5.00'])
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('suffixes percentages', () => {
|
|
80
|
+
expect(labels('%', -24, 0)).toEqual(['-30%', '-20%', '-10%', '0%'])
|
|
81
|
+
expect(labels('%', 0, 25_000)).toEqual(['0%', '10K%', '20K%', '30K%'])
|
|
82
|
+
})
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
describe('isDateOnly', () => {
|
|
86
|
+
it('is true only when every timestamp is midnight UTC', () => {
|
|
87
|
+
expect(isDateOnly([Date.UTC(2026, 0, 1), Date.UTC(2026, 0, 2)])).toBe(true)
|
|
88
|
+
expect(isDateOnly([Date.UTC(2026, 0, 1), Date.UTC(2026, 0, 2, 14, 30)])).toBe(false)
|
|
89
|
+
expect(isDateOnly([])).toBe(false)
|
|
90
|
+
})
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
describe('timeTicks', () => {
|
|
94
|
+
it('puts a month of daily data on Mondays', () => {
|
|
95
|
+
const { ticks, step } = timeTicks(Date.UTC(2026, 0, 1), Date.UTC(2026, 0, 31), true)
|
|
96
|
+
expect(step).toEqual({ unit: 'day', count: 7 })
|
|
97
|
+
// 2026-01-05 is a Monday.
|
|
98
|
+
expect(ticks[0]).toBe(Date.UTC(2026, 0, 5))
|
|
99
|
+
expect(ticks.every((t) => new Date(t).getUTCDay() === 1)).toBe(true)
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('puts two years on quarter starts', () => {
|
|
103
|
+
const { ticks, step } = timeTicks(Date.UTC(2024, 1, 10), Date.UTC(2026, 1, 10), true)
|
|
104
|
+
expect(step.unit).toBe('month')
|
|
105
|
+
expect(ticks.length).toBeLessThanOrEqual(6)
|
|
106
|
+
for (const t of ticks) {
|
|
107
|
+
const d = new Date(t)
|
|
108
|
+
expect(d.getUTCDate()).toBe(1)
|
|
109
|
+
expect(d.getUTCMonth() % step.count).toBe(0)
|
|
110
|
+
}
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('puts a decade on year starts', () => {
|
|
114
|
+
const { ticks, step } = timeTicks(Date.UTC(2016, 3, 1), Date.UTC(2026, 3, 1), true)
|
|
115
|
+
expect(step.unit).toBe('month')
|
|
116
|
+
expect(step.count % 12).toBe(0)
|
|
117
|
+
expect(ticks.every((t) => new Date(t).getUTCMonth() === 0)).toBe(true)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('puts an intraday session on round local times', () => {
|
|
121
|
+
const open = new Date(2026, 0, 5, 9, 30).getTime()
|
|
122
|
+
const close = new Date(2026, 0, 5, 16, 0).getTime()
|
|
123
|
+
const { ticks, step } = timeTicks(open, close, false)
|
|
124
|
+
expect(step.unit).toBe('minute')
|
|
125
|
+
expect(ticks.length).toBeGreaterThan(1)
|
|
126
|
+
expect(ticks.length).toBeLessThanOrEqual(6)
|
|
127
|
+
for (const t of ticks) {
|
|
128
|
+
const d = new Date(t)
|
|
129
|
+
expect((d.getHours() * 60 + d.getMinutes()) % step.count).toBe(0)
|
|
130
|
+
expect(t).toBeGreaterThanOrEqual(open)
|
|
131
|
+
expect(t).toBeLessThanOrEqual(close)
|
|
132
|
+
}
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('handles a single instant', () => {
|
|
136
|
+
expect(timeTicks(5, 5, true).ticks).toEqual([5])
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('respects maxTicks', () => {
|
|
140
|
+
const { ticks } = timeTicks(Date.UTC(2020, 0, 1), Date.UTC(2026, 0, 1), true, 4)
|
|
141
|
+
expect(ticks.length).toBeLessThanOrEqual(4)
|
|
142
|
+
expect(ticks.length).toBeGreaterThan(0)
|
|
143
|
+
})
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
describe('formatTick / formatTooltipTime', () => {
|
|
147
|
+
it('labels a tick as finely as its step', () => {
|
|
148
|
+
const t = Date.UTC(2026, 2, 1)
|
|
149
|
+
expect(formatTick(t, { unit: 'day', count: 7 }, true)).toBe('Mar 1')
|
|
150
|
+
expect(formatTick(t, { unit: 'month', count: 3 }, true)).toBe('Mar 2026')
|
|
151
|
+
expect(formatTick(t, { unit: 'month', count: 12 }, true)).toBe('2026')
|
|
152
|
+
expect(formatTick(t, { unit: 'minute', count: 60 }, true)).toBe('Mar 1')
|
|
153
|
+
expect(formatTick(Date.UTC(2026, 2, 1, 14, 30), { unit: 'minute', count: 30 }, true)).toBe('2:30 PM')
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('labels date-only data in UTC so it never slips to the previous day', () => {
|
|
157
|
+
expect(formatTooltipTime(Date.UTC(2026, 0, 5), true, false)).toBe('Jan 5, 2026')
|
|
158
|
+
})
|
|
159
|
+
})
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import {
|
|
2
|
+
buildChartModel,
|
|
3
|
+
normalizePoints,
|
|
4
|
+
toDrawdown,
|
|
5
|
+
toEpochMs,
|
|
6
|
+
valueKey,
|
|
7
|
+
} from '../lib/series'
|
|
8
|
+
|
|
9
|
+
const day = (d: number) => Date.UTC(2026, 0, d)
|
|
10
|
+
|
|
11
|
+
describe('toEpochMs', () => {
|
|
12
|
+
it('reads Dates, epoch ms and ISO strings to the same instant', () => {
|
|
13
|
+
const ms = Date.UTC(2026, 0, 5)
|
|
14
|
+
expect(toEpochMs(new Date(ms))).toBe(ms)
|
|
15
|
+
expect(toEpochMs(ms)).toBe(ms)
|
|
16
|
+
expect(toEpochMs('2026-01-05')).toBe(ms)
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('returns null for an unreadable timestamp', () => {
|
|
20
|
+
expect(toEpochMs('not a date')).toBeNull()
|
|
21
|
+
expect(toEpochMs(Number.NaN)).toBeNull()
|
|
22
|
+
expect(toEpochMs(new Date('nope'))).toBeNull()
|
|
23
|
+
})
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
describe('normalizePoints', () => {
|
|
27
|
+
it('sorts by time, drops unreadable timestamps, and lets the later duplicate win', () => {
|
|
28
|
+
const out = normalizePoints([
|
|
29
|
+
{ t: day(3), v: 3 },
|
|
30
|
+
{ t: 'garbage', v: 99 },
|
|
31
|
+
{ t: day(1), v: 1 },
|
|
32
|
+
{ t: day(3), v: 30 },
|
|
33
|
+
])
|
|
34
|
+
expect(out).toEqual([
|
|
35
|
+
{ t: day(1), v: 1 },
|
|
36
|
+
{ t: day(3), v: 30 },
|
|
37
|
+
])
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('treats a non-finite value as a gap', () => {
|
|
41
|
+
expect(normalizePoints([{ t: day(1), v: Number.NaN }])).toEqual([{ t: day(1), v: null }])
|
|
42
|
+
})
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
describe('toDrawdown', () => {
|
|
46
|
+
it('is 0 at each new high and the % below the running peak otherwise', () => {
|
|
47
|
+
const out = toDrawdown(
|
|
48
|
+
[
|
|
49
|
+
{ t: 1, v: 100 },
|
|
50
|
+
{ t: 2, v: 120 },
|
|
51
|
+
{ t: 3, v: 90 },
|
|
52
|
+
{ t: 4, v: 130 },
|
|
53
|
+
],
|
|
54
|
+
'$',
|
|
55
|
+
)
|
|
56
|
+
expect(out.map((p) => p.v)).toEqual([0, 0, -25, 0])
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('never yields -0 at a new high', () => {
|
|
60
|
+
const [p] = toDrawdown([{ t: 1, v: 50 }], 'number')
|
|
61
|
+
expect(Object.is(p.v, -0)).toBe(false)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('reads % values as cumulative returns: +50% -> +20% is a 20% drawdown', () => {
|
|
65
|
+
const out = toDrawdown(
|
|
66
|
+
[
|
|
67
|
+
{ t: 1, v: 50 },
|
|
68
|
+
{ t: 2, v: 20 },
|
|
69
|
+
],
|
|
70
|
+
'%',
|
|
71
|
+
)
|
|
72
|
+
expect(out[1].v).toBeCloseTo(-20, 10)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('keeps a gap a gap and does not reset the peak across it', () => {
|
|
76
|
+
const out = toDrawdown(
|
|
77
|
+
[
|
|
78
|
+
{ t: 1, v: 200 },
|
|
79
|
+
{ t: 2, v: null },
|
|
80
|
+
{ t: 3, v: 100 },
|
|
81
|
+
],
|
|
82
|
+
'$',
|
|
83
|
+
)
|
|
84
|
+
expect(out.map((p) => p.v)).toEqual([0, null, -50])
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('has no % below a non-positive peak, so those points become gaps', () => {
|
|
88
|
+
const out = toDrawdown(
|
|
89
|
+
[
|
|
90
|
+
{ t: 1, v: -5 },
|
|
91
|
+
{ t: 2, v: -10 },
|
|
92
|
+
{ t: 3, v: 10 },
|
|
93
|
+
{ t: 4, v: 5 },
|
|
94
|
+
],
|
|
95
|
+
'number',
|
|
96
|
+
)
|
|
97
|
+
expect(out.map((p) => p.v)).toEqual([null, null, 0, -50])
|
|
98
|
+
})
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
describe('buildChartModel', () => {
|
|
102
|
+
it('merges series onto one row per distinct timestamp', () => {
|
|
103
|
+
const model = buildChartModel(
|
|
104
|
+
[
|
|
105
|
+
{ name: 'A', points: [{ t: day(1), v: 1 }, { t: day(2), v: 2 }] },
|
|
106
|
+
{ name: 'B', points: [{ t: day(2), v: 20 }, { t: day(3), v: 30 }] },
|
|
107
|
+
],
|
|
108
|
+
'line',
|
|
109
|
+
'number',
|
|
110
|
+
)
|
|
111
|
+
expect(model.times).toEqual([day(1), day(2), day(3)])
|
|
112
|
+
expect(model.rows[1][valueKey(0)]).toBe(2)
|
|
113
|
+
expect(model.rows[1][valueKey(1)]).toBe(20)
|
|
114
|
+
// A has no sample on day 3 — absent, not a gap.
|
|
115
|
+
expect(model.rows[2][valueKey(0)]).toBeUndefined()
|
|
116
|
+
expect(model.names).toEqual(['A', 'B'])
|
|
117
|
+
expect(model.hasData).toBe(true)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('splits a series into separate runs at every null so the gap is not bridged', () => {
|
|
121
|
+
const model = buildChartModel(
|
|
122
|
+
[
|
|
123
|
+
{
|
|
124
|
+
name: 'A',
|
|
125
|
+
points: [
|
|
126
|
+
{ t: day(1), v: 1 },
|
|
127
|
+
{ t: day(2), v: 2 },
|
|
128
|
+
{ t: day(3), v: null },
|
|
129
|
+
{ t: day(4), v: 4 },
|
|
130
|
+
{ t: day(5), v: 5 },
|
|
131
|
+
],
|
|
132
|
+
},
|
|
133
|
+
],
|
|
134
|
+
'line',
|
|
135
|
+
'number',
|
|
136
|
+
)
|
|
137
|
+
expect(model.segments.map((s) => s.dataKey)).toEqual(['s0_0', 's0_1'])
|
|
138
|
+
// The first run holds no value past the gap and the second none before it.
|
|
139
|
+
expect(model.rows.map((r) => r.s0_0)).toEqual([1, 2, undefined, undefined, undefined])
|
|
140
|
+
expect(model.rows.map((r) => r.s0_1)).toEqual([undefined, undefined, undefined, 4, 5])
|
|
141
|
+
expect(model.rows[2][valueKey(0)]).toBeNull()
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('marks a one-point run so it can be drawn as a dot', () => {
|
|
145
|
+
const model = buildChartModel(
|
|
146
|
+
[{ name: 'A', points: [{ t: day(1), v: null }, { t: day(2), v: 7 }, { t: day(3), v: null }] }],
|
|
147
|
+
'line',
|
|
148
|
+
'$',
|
|
149
|
+
)
|
|
150
|
+
expect(model.segments).toEqual([{ dataKey: 's0_0', seriesIndex: 0, single: true }])
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it('reports no data when nothing is finite', () => {
|
|
154
|
+
const model = buildChartModel([{ name: 'A', points: [{ t: day(1), v: null }] }], 'line', '$')
|
|
155
|
+
expect(model.hasData).toBe(false)
|
|
156
|
+
expect(model.segments).toEqual([])
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('shows drawdown in % whatever the input unit', () => {
|
|
160
|
+
const model = buildChartModel(
|
|
161
|
+
[{ name: 'Equity', points: [{ t: day(1), v: 100 }, { t: day(2), v: 80 }] }],
|
|
162
|
+
'drawdown',
|
|
163
|
+
'$',
|
|
164
|
+
)
|
|
165
|
+
expect(model.unit).toBe('%')
|
|
166
|
+
expect(model.rows[1][valueKey(0)]).toBeCloseTo(-20, 10)
|
|
167
|
+
})
|
|
168
|
+
})
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Charts live behind their own subpath (`@startsimpli/ui/charts`) so recharts
|
|
2
|
+
// is only bundled by apps that draw one.
|
|
3
|
+
export { TimeSeriesChart, SERIES_COLORS, seriesStroke } from './TimeSeriesChart'
|
|
4
|
+
export type { TimeSeriesChartProps, TimeSeriesReferenceLine } from './TimeSeriesChart'
|
|
5
|
+
export type {
|
|
6
|
+
TimeSeries,
|
|
7
|
+
TimeSeriesPoint,
|
|
8
|
+
TimeSeriesUnit,
|
|
9
|
+
TimeSeriesMode,
|
|
10
|
+
TimeValue,
|
|
11
|
+
} from './lib/series'
|
|
12
|
+
export { buildChartModel, normalizePoints, toDrawdown, toEpochMs } from './lib/series'
|
|
13
|
+
export { formatValue } from './lib/format'
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Value + time formatting for TimeSeriesChart. Pure, en-US like the rest of the
|
|
3
|
+
* package's formatters (utils/index.ts), so output is the same on every
|
|
4
|
+
* machine.
|
|
5
|
+
*/
|
|
6
|
+
import type { TimeSeriesUnit } from './series'
|
|
7
|
+
|
|
8
|
+
const LOCALE = 'en-US'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* `v` in the unit with exactly `digits` decimals. A value that rounds to zero
|
|
12
|
+
* prints as zero — never `-0` or `-$0.00`.
|
|
13
|
+
*/
|
|
14
|
+
function fixed(v: number, unit: TimeSeriesUnit, digits: number, minDigits = digits): string {
|
|
15
|
+
const value = Math.abs(v) < 0.5 * 10 ** -digits ? 0 : v
|
|
16
|
+
const text = new Intl.NumberFormat(LOCALE, {
|
|
17
|
+
minimumFractionDigits: minDigits,
|
|
18
|
+
maximumFractionDigits: digits,
|
|
19
|
+
...(unit === '$' ? { style: 'currency', currency: 'USD' } : {}),
|
|
20
|
+
}).format(value)
|
|
21
|
+
return unit === '%' ? `${text}%` : text
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Full value, for the tooltip: `$1,234`, `$12.50`, `12.5%`, `1,234.57`. */
|
|
25
|
+
export function formatValue(v: number, unit: TimeSeriesUnit): string {
|
|
26
|
+
const abs = Math.abs(v)
|
|
27
|
+
if (unit === '$') return fixed(v, unit, abs >= 1000 ? 0 : 2)
|
|
28
|
+
if (unit === '%') return fixed(v, unit, abs >= 100 ? 0 : abs >= 10 ? 1 : 2, 0)
|
|
29
|
+
return fixed(v, unit, abs >= 1000 ? 0 : 2, 0)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** A "nice" tick step: 1, 2, 2.5 or 5 times a power of ten. */
|
|
33
|
+
interface NiceStep {
|
|
34
|
+
value: number
|
|
35
|
+
/** Decimals needed to print every multiple of `value` exactly. */
|
|
36
|
+
decimals: number
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function niceStep(rough: number): NiceStep {
|
|
40
|
+
const exp = Math.floor(Math.log10(rough))
|
|
41
|
+
const base = 10 ** exp
|
|
42
|
+
const m = [1, 2, 2.5, 5, 10].find((c) => c * base >= rough * (1 - 1e-9)) ?? 10
|
|
43
|
+
const k = m === 10 ? exp + 1 : exp
|
|
44
|
+
return {
|
|
45
|
+
value: m * base,
|
|
46
|
+
decimals: Math.max(0, -k + (m === 2.5 ? 1 : 0)),
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Value-axis ticks over `[min, max]`: round multiples of a nice step, about
|
|
52
|
+
* `count` of them, covering the range and no more. Done here rather than by
|
|
53
|
+
* recharts so the labels can share one precision (`$30.80` / `$31.00`, not
|
|
54
|
+
* `$30.8` / `$31`).
|
|
55
|
+
*/
|
|
56
|
+
export function valueTicks(
|
|
57
|
+
min: number,
|
|
58
|
+
max: number,
|
|
59
|
+
count = 5,
|
|
60
|
+
): { ticks: number[]; step: NiceStep } {
|
|
61
|
+
let lo = min
|
|
62
|
+
let hi = max
|
|
63
|
+
if (!(hi > lo)) {
|
|
64
|
+
// One value (or a flat line): open a band around it so it has an axis.
|
|
65
|
+
const pad = Math.abs(lo) > 0 ? Math.abs(lo) * 0.1 : 1
|
|
66
|
+
lo -= pad
|
|
67
|
+
hi += pad
|
|
68
|
+
}
|
|
69
|
+
const step = niceStep((hi - lo) / Math.max(count - 1, 1))
|
|
70
|
+
const first = Math.floor(lo / step.value + 1e-9)
|
|
71
|
+
const last = Math.ceil(hi / step.value - 1e-9)
|
|
72
|
+
const ticks: number[] = []
|
|
73
|
+
for (let i = first; i <= last; i += 1) {
|
|
74
|
+
// Multiply rather than accumulate, so 0.1-steps don't drift to 0.30000000000000004.
|
|
75
|
+
ticks.push(Number((i * step.value).toFixed(step.decimals)))
|
|
76
|
+
}
|
|
77
|
+
return { ticks, step }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const COMPACT = [
|
|
81
|
+
{ at: 1e9, suffix: 'B' },
|
|
82
|
+
{ at: 1e6, suffix: 'M' },
|
|
83
|
+
{ at: 1e3, suffix: 'K' },
|
|
84
|
+
] as const
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Formatter for the value-axis ticks from `valueTicks`: compacted to K/M/B by
|
|
88
|
+
* the largest tick, every label at the precision the step needs —
|
|
89
|
+
* `$300K`, `$30.80`, `-20%`, `12.5K`.
|
|
90
|
+
*/
|
|
91
|
+
export function axisValueFormatter(
|
|
92
|
+
unit: TimeSeriesUnit,
|
|
93
|
+
ticks: number[],
|
|
94
|
+
step: NiceStep,
|
|
95
|
+
): (v: number) => string {
|
|
96
|
+
const maxAbs = Math.max(0, ...ticks.map((t) => Math.abs(t)))
|
|
97
|
+
const scale = COMPACT.find((c) => maxAbs >= c.at)
|
|
98
|
+
if (!scale) {
|
|
99
|
+
// Uncompacted dollars below $10 keep their cents.
|
|
100
|
+
const digits = unit === '$' && maxAbs < 10 ? Math.max(step.decimals, 2) : step.decimals
|
|
101
|
+
return (v) => fixed(v, unit, digits)
|
|
102
|
+
}
|
|
103
|
+
const digits = Math.max(0, niceStep(step.value / scale.at).decimals)
|
|
104
|
+
return (v) => {
|
|
105
|
+
// Zero is zero in any scale: `$0`, not `$0K`.
|
|
106
|
+
if (v === 0) return fixed(0, unit, 0)
|
|
107
|
+
const text = fixed(v / scale.at, unit === '%' ? 'number' : unit, digits)
|
|
108
|
+
return `${text}${scale.suffix}${unit === '%' ? '%' : ''}`
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
// Time
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
const MINUTE = 60_000
|
|
117
|
+
const HOUR = 60 * MINUTE
|
|
118
|
+
const DAY = 24 * HOUR
|
|
119
|
+
|
|
120
|
+
/** A tick interval: a fixed number of minutes/days, or calendar months. */
|
|
121
|
+
export type TickStep =
|
|
122
|
+
| { unit: 'minute'; count: number }
|
|
123
|
+
| { unit: 'day'; count: number }
|
|
124
|
+
| { unit: 'month'; count: number }
|
|
125
|
+
|
|
126
|
+
const STEPS: TickStep[] = [
|
|
127
|
+
...[1, 5, 15, 30, 60, 180, 360, 720].map((count) => ({ unit: 'minute' as const, count })),
|
|
128
|
+
...[1, 2, 7, 14].map((count) => ({ unit: 'day' as const, count })),
|
|
129
|
+
...[1, 3, 6, 12, 24, 60, 120, 240, 600].map((count) => ({ unit: 'month' as const, count })),
|
|
130
|
+
]
|
|
131
|
+
|
|
132
|
+
function approxMs(step: TickStep): number {
|
|
133
|
+
if (step.unit === 'minute') return step.count * MINUTE
|
|
134
|
+
if (step.unit === 'day') return step.count * DAY
|
|
135
|
+
return step.count * 30.44 * DAY
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Whether every timestamp is midnight UTC — i.e. the data is date-only
|
|
140
|
+
* (`'2026-01-05'` parses to midnight UTC). Such data is labelled in UTC; in
|
|
141
|
+
* local time a US viewer would see every bar under the previous day.
|
|
142
|
+
*/
|
|
143
|
+
export function isDateOnly(times: number[]): boolean {
|
|
144
|
+
return times.length > 0 && times.every((t) => t % DAY === 0)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Calendar accessors in either UTC or the viewer's zone. */
|
|
148
|
+
function calendar(utc: boolean) {
|
|
149
|
+
return {
|
|
150
|
+
parts(t: number) {
|
|
151
|
+
const d = new Date(t)
|
|
152
|
+
return utc
|
|
153
|
+
? { y: d.getUTCFullYear(), mo: d.getUTCMonth(), d: d.getUTCDate(), h: d.getUTCHours(), mi: d.getUTCMinutes(), dow: d.getUTCDay() }
|
|
154
|
+
: { y: d.getFullYear(), mo: d.getMonth(), d: d.getDate(), h: d.getHours(), mi: d.getMinutes(), dow: d.getDay() }
|
|
155
|
+
},
|
|
156
|
+
make(y: number, mo: number, d: number, h = 0, mi = 0): number {
|
|
157
|
+
return utc ? Date.UTC(y, mo, d, h, mi) : new Date(y, mo, d, h, mi).getTime()
|
|
158
|
+
},
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Tick instants for one step, aligned to round calendar boundaries. */
|
|
163
|
+
function ticksForStep(min: number, max: number, step: TickStep, utc: boolean, limit: number): number[] {
|
|
164
|
+
const cal = calendar(utc)
|
|
165
|
+
const p = cal.parts(min)
|
|
166
|
+
const out: number[] = []
|
|
167
|
+
const push = (t: number) => {
|
|
168
|
+
if (t >= min && t <= max) out.push(t)
|
|
169
|
+
return out.length <= limit && t <= max
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (step.unit === 'minute') {
|
|
173
|
+
// Minutes since local midnight, rounded up to a multiple of the step.
|
|
174
|
+
const sinceMidnight = p.h * 60 + p.mi
|
|
175
|
+
const first = Math.ceil(sinceMidnight / step.count) * step.count
|
|
176
|
+
for (let m = first; ; m += step.count) {
|
|
177
|
+
if (!push(cal.make(p.y, p.mo, p.d, 0, m))) break
|
|
178
|
+
}
|
|
179
|
+
return out
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (step.unit === 'day') {
|
|
183
|
+
let day = p.d
|
|
184
|
+
if (cal.make(p.y, p.mo, day) < min) day += 1
|
|
185
|
+
// Weekly steps start on a Monday so the labels line up week to week.
|
|
186
|
+
if (step.count >= 7) {
|
|
187
|
+
const dow = cal.parts(cal.make(p.y, p.mo, day)).dow
|
|
188
|
+
day += (8 - dow) % 7
|
|
189
|
+
}
|
|
190
|
+
for (let d = day; ; d += step.count) {
|
|
191
|
+
if (!push(cal.make(p.y, p.mo, d))) break
|
|
192
|
+
}
|
|
193
|
+
return out
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Months: start on a month whose index is a multiple of the step, so a
|
|
197
|
+
// quarterly axis reads Jan/Apr/Jul/Oct and a 5-year axis 2020/2025/2030.
|
|
198
|
+
let month = p.y * 12 + p.mo
|
|
199
|
+
if (cal.make(p.y, p.mo, 1) < min) month += 1
|
|
200
|
+
month = Math.ceil(month / step.count) * step.count
|
|
201
|
+
for (let m = month; ; m += step.count) {
|
|
202
|
+
if (!push(cal.make(Math.floor(m / 12), m % 12, 1))) break
|
|
203
|
+
}
|
|
204
|
+
return out
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Evenly spaced, calendar-aligned ticks across `[min, max]`: the finest step
|
|
209
|
+
* that yields at most `maxTicks` of them (midnights, Mondays, month or year
|
|
210
|
+
* starts — never "every 4.3 days from the first sample").
|
|
211
|
+
*/
|
|
212
|
+
export function timeTicks(
|
|
213
|
+
min: number,
|
|
214
|
+
max: number,
|
|
215
|
+
utc: boolean,
|
|
216
|
+
maxTicks = 6,
|
|
217
|
+
): { ticks: number[]; step: TickStep } {
|
|
218
|
+
const cap = Math.max(maxTicks, 2)
|
|
219
|
+
if (!(max > min)) return { ticks: Number.isFinite(min) ? [min] : [], step: STEPS[0] }
|
|
220
|
+
const span = max - min
|
|
221
|
+
for (const step of STEPS) {
|
|
222
|
+
if (span / approxMs(step) > cap + 1) continue
|
|
223
|
+
const ticks = ticksForStep(min, max, step, utc, cap)
|
|
224
|
+
if (ticks.length <= cap && ticks.length > 0) return { ticks, step }
|
|
225
|
+
}
|
|
226
|
+
const step = STEPS[STEPS.length - 1]
|
|
227
|
+
return { ticks: ticksForStep(min, max, step, utc, cap), step }
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Label for an axis tick, as fine as the step it sits on. */
|
|
231
|
+
export function formatTick(t: number, step: TickStep, utc: boolean): string {
|
|
232
|
+
const timeZone = utc ? 'UTC' : undefined
|
|
233
|
+
if (step.unit === 'minute') {
|
|
234
|
+
const p = calendar(utc).parts(t)
|
|
235
|
+
// A tick on midnight names the day, so a multi-day intraday axis stays readable.
|
|
236
|
+
if (p.h === 0 && p.mi === 0) {
|
|
237
|
+
return new Intl.DateTimeFormat(LOCALE, { month: 'short', day: 'numeric', timeZone }).format(t)
|
|
238
|
+
}
|
|
239
|
+
return new Intl.DateTimeFormat(LOCALE, { hour: 'numeric', minute: '2-digit', timeZone }).format(t)
|
|
240
|
+
}
|
|
241
|
+
if (step.unit === 'day') {
|
|
242
|
+
return new Intl.DateTimeFormat(LOCALE, { month: 'short', day: 'numeric', timeZone }).format(t)
|
|
243
|
+
}
|
|
244
|
+
if (step.count < 12) {
|
|
245
|
+
return new Intl.DateTimeFormat(LOCALE, { month: 'short', year: 'numeric', timeZone }).format(t)
|
|
246
|
+
}
|
|
247
|
+
return new Intl.DateTimeFormat(LOCALE, { year: 'numeric', timeZone }).format(t)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Label for the tooltip: the full date, plus the time when the data has one. */
|
|
251
|
+
export function formatTooltipTime(t: number, utc: boolean, withTime: boolean): string {
|
|
252
|
+
return new Intl.DateTimeFormat(LOCALE, {
|
|
253
|
+
year: 'numeric',
|
|
254
|
+
month: 'short',
|
|
255
|
+
day: 'numeric',
|
|
256
|
+
...(withTime ? { hour: 'numeric', minute: '2-digit' } : {}),
|
|
257
|
+
timeZone: utc ? 'UTC' : undefined,
|
|
258
|
+
}).format(t)
|
|
259
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure data shaping for TimeSeriesChart — no React, no recharts, so every rule
|
|
3
|
+
* here is unit-tested on its own.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** A timestamp: a Date, epoch milliseconds, or any string `Date.parse` reads. */
|
|
7
|
+
export type TimeValue = string | number | Date
|
|
8
|
+
|
|
9
|
+
export interface TimeSeriesPoint {
|
|
10
|
+
t: TimeValue
|
|
11
|
+
/** `null` is a gap: the line breaks there instead of joining its neighbours. */
|
|
12
|
+
v: number | null
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface TimeSeries {
|
|
16
|
+
name: string
|
|
17
|
+
points: TimeSeriesPoint[]
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* How a value reads. `'%'` values are percentage POINTS — pass `12.5` for
|
|
22
|
+
* 12.5%, not `0.125`. `'$'` is US dollars.
|
|
23
|
+
*/
|
|
24
|
+
export type TimeSeriesUnit = '%' | '$' | 'number'
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* `'line'` plots the values as given. `'drawdown'` plots each series as the
|
|
28
|
+
* percentage it sits below its own running peak (0 at a new high, negative
|
|
29
|
+
* otherwise).
|
|
30
|
+
*/
|
|
31
|
+
export type TimeSeriesMode = 'line' | 'drawdown'
|
|
32
|
+
|
|
33
|
+
/** A point after parsing: epoch ms + a finite value or a gap. */
|
|
34
|
+
export interface NormalizedPoint {
|
|
35
|
+
t: number
|
|
36
|
+
v: number | null
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Epoch ms for a timestamp, or null when it cannot be read. */
|
|
40
|
+
export function toEpochMs(t: TimeValue): number | null {
|
|
41
|
+
const ms = t instanceof Date ? t.getTime() : typeof t === 'number' ? t : Date.parse(t)
|
|
42
|
+
return Number.isFinite(ms) ? ms : null
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Parse, sort and de-duplicate one series. Points whose timestamp cannot be
|
|
47
|
+
* read are dropped (there is nowhere to put them); a non-finite value becomes a
|
|
48
|
+
* gap. Two points at the same instant: the later one in the input wins.
|
|
49
|
+
*/
|
|
50
|
+
export function normalizePoints(points: TimeSeriesPoint[]): NormalizedPoint[] {
|
|
51
|
+
const byTime = new Map<number, number | null>()
|
|
52
|
+
for (const p of points) {
|
|
53
|
+
const t = toEpochMs(p.t)
|
|
54
|
+
if (t === null) continue
|
|
55
|
+
byTime.set(t, typeof p.v === 'number' && Number.isFinite(p.v) ? p.v : null)
|
|
56
|
+
}
|
|
57
|
+
return [...byTime.entries()].sort((a, b) => a[0] - b[0]).map(([t, v]) => ({ t, v }))
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Each point as the percentage below the running peak of everything before it.
|
|
62
|
+
*
|
|
63
|
+
* For `'$'` and `'number'` the peak is taken over the raw values. For `'%'`
|
|
64
|
+
* the values are read as cumulative returns and the peak is taken over the
|
|
65
|
+
* growth they imply (`1 + v/100`): falling from +50% to +20% is a 20%
|
|
66
|
+
* drawdown, not a 60% one.
|
|
67
|
+
*
|
|
68
|
+
* A gap stays a gap and does not reset the peak. While the peak is not
|
|
69
|
+
* positive there is no meaningful "% below it", so those points become gaps.
|
|
70
|
+
*/
|
|
71
|
+
export function toDrawdown(points: NormalizedPoint[], unit: TimeSeriesUnit): NormalizedPoint[] {
|
|
72
|
+
let peak: number | null = null
|
|
73
|
+
return points.map(({ t, v }) => {
|
|
74
|
+
if (v === null) return { t, v: null }
|
|
75
|
+
const level = unit === '%' ? 1 + v / 100 : v
|
|
76
|
+
if (peak === null || level > peak) peak = level
|
|
77
|
+
if (!(peak > 0)) return { t, v: null }
|
|
78
|
+
const dd = (level / peak - 1) * 100
|
|
79
|
+
// A new high is exactly 0, never -0 (which would format as "-0%").
|
|
80
|
+
return { t, v: dd === 0 ? 0 : dd }
|
|
81
|
+
})
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** One recharts row per distinct timestamp across all series. */
|
|
85
|
+
export interface ChartRow {
|
|
86
|
+
t: number
|
|
87
|
+
[key: string]: number | null | undefined
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* One drawn line: a contiguous run of a series between gaps. A series with
|
|
92
|
+
* gaps is several of these, so a gap is a real break in the stroke while a
|
|
93
|
+
* timestamp the series simply has no sample at (because another series does)
|
|
94
|
+
* is stepped over.
|
|
95
|
+
*/
|
|
96
|
+
export interface ChartSegment {
|
|
97
|
+
dataKey: string
|
|
98
|
+
seriesIndex: number
|
|
99
|
+
/** A run of one point — there is no stroke to draw, so it needs a dot. */
|
|
100
|
+
single: boolean
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface ChartModel {
|
|
104
|
+
rows: ChartRow[]
|
|
105
|
+
segments: ChartSegment[]
|
|
106
|
+
names: string[]
|
|
107
|
+
/** The unit values are DISPLAYED in — always `'%'` in drawdown mode. */
|
|
108
|
+
unit: TimeSeriesUnit
|
|
109
|
+
/** False when there is not one finite value to plot. */
|
|
110
|
+
hasData: boolean
|
|
111
|
+
/** Every timestamp in the chart, ascending. */
|
|
112
|
+
times: number[]
|
|
113
|
+
/** Smallest and largest plotted value (both 0 when there is no data). */
|
|
114
|
+
min: number
|
|
115
|
+
max: number
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Row key holding series `i`'s value: number, null (gap) or absent. */
|
|
119
|
+
export function valueKey(seriesIndex: number): string {
|
|
120
|
+
return `v${seriesIndex}`
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function buildChartModel(
|
|
124
|
+
series: TimeSeries[],
|
|
125
|
+
mode: TimeSeriesMode,
|
|
126
|
+
unit: TimeSeriesUnit,
|
|
127
|
+
): ChartModel {
|
|
128
|
+
const rowsByTime = new Map<number, ChartRow>()
|
|
129
|
+
const rowAt = (t: number): ChartRow => {
|
|
130
|
+
let row = rowsByTime.get(t)
|
|
131
|
+
if (!row) {
|
|
132
|
+
row = { t }
|
|
133
|
+
rowsByTime.set(t, row)
|
|
134
|
+
}
|
|
135
|
+
return row
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const segments: ChartSegment[] = []
|
|
139
|
+
let hasData = false
|
|
140
|
+
let min = Infinity
|
|
141
|
+
let max = -Infinity
|
|
142
|
+
|
|
143
|
+
series.forEach((s, i) => {
|
|
144
|
+
const normalized = normalizePoints(s.points)
|
|
145
|
+
const points = mode === 'drawdown' ? toDrawdown(normalized, unit) : normalized
|
|
146
|
+
|
|
147
|
+
let segIndex = 0
|
|
148
|
+
let segLength = 0
|
|
149
|
+
const closeSegment = () => {
|
|
150
|
+
if (segLength === 0) return
|
|
151
|
+
segments.push({ dataKey: `s${i}_${segIndex}`, seriesIndex: i, single: segLength === 1 })
|
|
152
|
+
segIndex += 1
|
|
153
|
+
segLength = 0
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
for (const p of points) {
|
|
157
|
+
const row = rowAt(p.t)
|
|
158
|
+
row[valueKey(i)] = p.v
|
|
159
|
+
if (p.v === null) {
|
|
160
|
+
closeSegment()
|
|
161
|
+
continue
|
|
162
|
+
}
|
|
163
|
+
row[`s${i}_${segIndex}`] = p.v
|
|
164
|
+
segLength += 1
|
|
165
|
+
hasData = true
|
|
166
|
+
if (p.v < min) min = p.v
|
|
167
|
+
if (p.v > max) max = p.v
|
|
168
|
+
}
|
|
169
|
+
closeSegment()
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
const rows = [...rowsByTime.values()].sort((a, b) => a.t - b.t)
|
|
173
|
+
return {
|
|
174
|
+
rows,
|
|
175
|
+
segments,
|
|
176
|
+
names: series.map((s) => s.name),
|
|
177
|
+
unit: mode === 'drawdown' ? '%' : unit,
|
|
178
|
+
hasData,
|
|
179
|
+
times: rows.map((r) => r.t),
|
|
180
|
+
min: hasData ? min : 0,
|
|
181
|
+
max: hasData ? max : 0,
|
|
182
|
+
}
|
|
183
|
+
}
|