waveform-analysis 0.1.72 → 0.1.74
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/README.md +34 -13
- package/dist/index.cjs +2 -2
- package/dist/index.js +2635 -2547
- package/dist/style.css +1 -1
- package/dist/types/components/annotation/useWaveformChartAnnotations.d.ts +0 -1
- package/dist/types/components/core/layout.d.ts +4 -2
- package/dist/types/components/core/types.d.ts +2 -0
- package/dist/types/components/core/useWaveformChartController.d.ts +0 -1
- package/dist/types/components/core/useWaveformData.d.ts +1 -0
- package/dist/types/components/core/yAxisPadding.d.ts +1 -1
- package/dist/types/components/core/yAxisTitleLayout.d.ts +1 -1
- package/dist/types/components/interaction/useExtremaTickViewport.d.ts +5 -0
- package/dist/types/components/rendering/WaveformTrackAxes.vue.d.ts +1 -0
- package/dist/types/data/sx2Channel.d.ts +10 -0
- package/dist/types/demo/types.d.ts +2 -0
- package/dist/types/types/chart.d.ts +4 -0
- package/package.json +1 -1
- package/src/App.vue +6 -0
- package/src/components/WaveformChartView.vue +0 -3
- package/src/components/annotation/useWaveformChartAnnotations.ts +7 -23
- package/src/components/controls/WaveformToolbar.vue +19 -20
- package/src/components/core/layout.ts +52 -26
- package/src/components/core/trackLayoutBuilder.ts +16 -6
- package/src/components/core/types.ts +2 -0
- package/src/components/core/useWaveformData.ts +2 -0
- package/src/components/core/useWaveformLayout.ts +10 -0
- package/src/components/core/yAxisLayoutMetrics.ts +24 -15
- package/src/components/core/yAxisPadding.ts +28 -8
- package/src/components/core/yAxisTitleLayout.ts +9 -48
- package/src/components/interaction/useExtremaTickViewport.ts +43 -0
- package/src/components/rendering/WaveformTrack.vue +1 -0
- package/src/components/rendering/WaveformTrackAxes.vue +18 -0
- package/src/components/rendering/yAxisTickVisibility.ts +77 -16
- package/src/data/simulatedWaveforms.ts +10 -0
- package/src/data/sx2Channel.ts +315 -0
- package/src/demo/DemoYAxisPaddingControls.vue +15 -0
- package/src/demo/types.ts +2 -0
- package/src/index.ts +1 -0
- package/src/types/chart.ts +4 -0
|
@@ -83,7 +83,7 @@ export function formatYAxisTickLabel(
|
|
|
83
83
|
return label
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
-
/**
|
|
86
|
+
/** Name spacing excludes scientific multipliers; outer clearance measures the full label. */
|
|
87
87
|
export function formatYAxisTickLayoutLabel(
|
|
88
88
|
value: number,
|
|
89
89
|
domain: [number, number],
|
|
@@ -102,6 +102,7 @@ export interface YAxisSeriesGroup {
|
|
|
102
102
|
domain: [number, number]
|
|
103
103
|
fixed: boolean
|
|
104
104
|
maximumTick?: number
|
|
105
|
+
minimumTick?: number
|
|
105
106
|
}
|
|
106
107
|
|
|
107
108
|
export interface YAxisSlot {
|
|
@@ -196,9 +197,12 @@ export function resolveRenderedYAxisSeriesGroups(
|
|
|
196
197
|
track.visibleSeries.length || !track.series.length
|
|
197
198
|
? track
|
|
198
199
|
: { ...track, visibleSeries: track.series }
|
|
199
|
-
return resolveYAxisSeriesGroups(axisTrack, overlayMode, yDomain, yDomains).map((group) =>
|
|
200
|
-
applyYAxisPadding(group, viewportYDomain, padding)
|
|
201
|
-
|
|
200
|
+
return resolveYAxisSeriesGroups(axisTrack, overlayMode, yDomain, yDomains).map((group) => {
|
|
201
|
+
const padded = applyYAxisPadding(group, viewportYDomain, padding)
|
|
202
|
+
return track.visibleSeries.length
|
|
203
|
+
? padded
|
|
204
|
+
: { ...padded, maximumTick: undefined, minimumTick: undefined }
|
|
205
|
+
})
|
|
202
206
|
}
|
|
203
207
|
|
|
204
208
|
export function axisTextMetrics(
|
|
@@ -210,25 +214,41 @@ export function axisTextMetrics(
|
|
|
210
214
|
includeWithoutLastTick = false,
|
|
211
215
|
measureTextWidth?: (text: string) => number,
|
|
212
216
|
maximumTick?: number,
|
|
213
|
-
|
|
217
|
+
minimumTick?: number,
|
|
218
|
+
): { tickTextWidth: number; titleTextWidth: number } {
|
|
214
219
|
const resolvedTicks = tickValues
|
|
215
220
|
? { domain, values: tickValues }
|
|
216
221
|
: resolveYAxisTicks(domain, tickCount, nice)
|
|
217
|
-
const
|
|
222
|
+
const inRange = (value?: number) =>
|
|
223
|
+
value !== undefined && value >= resolvedTicks.domain[0] && value <= resolvedTicks.domain[1]
|
|
224
|
+
? value
|
|
225
|
+
: undefined
|
|
226
|
+
const tickValueSets = [
|
|
227
|
+
withMaximumTick(resolvedTicks.values, inRange(maximumTick), inRange(minimumTick)),
|
|
228
|
+
]
|
|
218
229
|
if (includeWithoutLastTick && resolvedTicks.values.length > 1) {
|
|
219
230
|
tickValueSets.push(resolvedTicks.values.slice(0, -1))
|
|
220
231
|
}
|
|
221
|
-
const
|
|
222
|
-
Y_AXIS_CHARACTER_WIDTH
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
),
|
|
232
|
+
const width = (text: string) =>
|
|
233
|
+
measureTextWidth ? measureTextWidth(text) : text.length * Y_AXIS_CHARACTER_WIDTH
|
|
234
|
+
const labels = tickValueSets.flatMap((values) =>
|
|
235
|
+
values.map((value) => ({
|
|
236
|
+
full: width(formatYAxisTickLabel(value, resolvedTicks.domain, values, unit)),
|
|
237
|
+
title: width(formatYAxisTickLayoutLabel(value, resolvedTicks.domain, values, unit)),
|
|
238
|
+
})),
|
|
229
239
|
)
|
|
240
|
+
// Collision handling can move metadata to any surviving tick in compact layouts.
|
|
241
|
+
if (includeWithoutLastTick) {
|
|
242
|
+
tickValueSets[0]!.forEach((value) => {
|
|
243
|
+
labels.push({
|
|
244
|
+
full: width(formatYAxisTickLabel(value, resolvedTicks.domain, [value], unit)),
|
|
245
|
+
title: width(formatYAxisTickLayoutLabel(value, resolvedTicks.domain, [value], unit)),
|
|
246
|
+
})
|
|
247
|
+
})
|
|
248
|
+
}
|
|
230
249
|
return {
|
|
231
|
-
tickTextWidth:
|
|
250
|
+
tickTextWidth: Math.max(Y_AXIS_CHARACTER_WIDTH, ...labels.map((label) => label.full)),
|
|
251
|
+
titleTextWidth: Math.max(Y_AXIS_CHARACTER_WIDTH, ...labels.map((label) => label.title)),
|
|
232
252
|
}
|
|
233
253
|
}
|
|
234
254
|
|
|
@@ -237,12 +257,16 @@ export function measureYAxisGroupClearance(
|
|
|
237
257
|
tickCount?: number,
|
|
238
258
|
nice = true,
|
|
239
259
|
): number {
|
|
260
|
+
const { tickTextWidth, titleTextWidth } = axisTextMetrics(
|
|
261
|
+
group.domain,
|
|
262
|
+
nice,
|
|
263
|
+
undefined,
|
|
264
|
+
group.seriesList[0]?.unit,
|
|
265
|
+
tickCount,
|
|
266
|
+
)
|
|
240
267
|
return (
|
|
241
|
-
|
|
242
|
-
.tickTextWidth +
|
|
268
|
+
Math.max(tickTextWidth, titleTextWidth + Y_AXIS_LABEL_GAP + Y_AXIS_LABEL_BAND_WIDTH) +
|
|
243
269
|
Y_AXIS_TICK_PADDING +
|
|
244
|
-
Y_AXIS_LABEL_GAP +
|
|
245
|
-
Y_AXIS_LABEL_BAND_WIDTH +
|
|
246
270
|
Y_AXIS_OUTER_PADDING
|
|
247
271
|
)
|
|
248
272
|
}
|
|
@@ -301,6 +325,7 @@ export function buildYAxisSlots(
|
|
|
301
325
|
typeof padding === 'object' ? padding.labelTextStyle : undefined,
|
|
302
326
|
)
|
|
303
327
|
const widths = new Map<string, number>()
|
|
328
|
+
const titleWidths = new Map<string, number>()
|
|
304
329
|
tracks.forEach((track) => {
|
|
305
330
|
const sideIndexes = { left: 0, right: 0 }
|
|
306
331
|
resolveRenderedYAxisSeriesGroups(
|
|
@@ -313,7 +338,7 @@ export function buildYAxisSlots(
|
|
|
313
338
|
).forEach((group) => {
|
|
314
339
|
const sideIndex = sideIndexes[group.side]++
|
|
315
340
|
const key = `${group.side}:${sideIndex}`
|
|
316
|
-
const
|
|
341
|
+
const { tickTextWidth, titleTextWidth } = axisTextMetrics(
|
|
317
342
|
group.domain,
|
|
318
343
|
nice,
|
|
319
344
|
undefined,
|
|
@@ -322,8 +347,10 @@ export function buildYAxisSlots(
|
|
|
322
347
|
includeWithoutLastTick,
|
|
323
348
|
measureTextWidth,
|
|
324
349
|
group.maximumTick,
|
|
325
|
-
|
|
326
|
-
|
|
350
|
+
group.minimumTick,
|
|
351
|
+
)
|
|
352
|
+
widths.set(key, Math.max(widths.get(key) ?? Y_AXIS_CHARACTER_WIDTH, tickTextWidth))
|
|
353
|
+
titleWidths.set(key, Math.max(titleWidths.get(key) ?? Y_AXIS_CHARACTER_WIDTH, titleTextWidth))
|
|
327
354
|
})
|
|
328
355
|
})
|
|
329
356
|
|
|
@@ -336,15 +363,14 @@ export function buildYAxisSlots(
|
|
|
336
363
|
.sort((first, second) => first - second)
|
|
337
364
|
sideIndexes.forEach((sideIndex) => {
|
|
338
365
|
const tickTextWidth = widths.get(`${side}:${sideIndex}`) ?? Y_AXIS_CHARACTER_WIDTH
|
|
366
|
+
const titleTextWidth = titleWidths.get(`${side}:${sideIndex}`) ?? Y_AXIS_CHARACTER_WIDTH
|
|
339
367
|
const axisOffset = side === 'left' && clearance.left > 0 ? -clearance.left : clearance[side]
|
|
340
368
|
const labelDistance =
|
|
341
|
-
|
|
369
|
+
titleTextWidth + Y_AXIS_TICK_PADDING + Y_AXIS_LABEL_GAP + labelBandWidth / 2
|
|
342
370
|
const labelOffset = axisOffset + labelDistance * (side === 'left' ? -1 : 1)
|
|
343
371
|
const fullClearance =
|
|
344
|
-
tickTextWidth +
|
|
372
|
+
Math.max(tickTextWidth, titleTextWidth + Y_AXIS_LABEL_GAP + labelBandWidth) +
|
|
345
373
|
Y_AXIS_TICK_PADDING +
|
|
346
|
-
Y_AXIS_LABEL_GAP +
|
|
347
|
-
labelBandWidth +
|
|
348
374
|
Y_AXIS_OUTER_PADDING
|
|
349
375
|
slots.push({ side, sideIndex, axisOffset, labelOffset, clearance: fullClearance })
|
|
350
376
|
clearance[side] += fullClearance
|
|
@@ -163,8 +163,14 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou
|
|
|
163
163
|
group.maximumTick <= resolvedTicks.domain[1]
|
|
164
164
|
? group.maximumTick
|
|
165
165
|
: undefined
|
|
166
|
-
const
|
|
167
|
-
|
|
166
|
+
const minimumTick =
|
|
167
|
+
group.minimumTick !== undefined &&
|
|
168
|
+
group.minimumTick >= resolvedTicks.domain[0] &&
|
|
169
|
+
group.minimumTick <= resolvedTicks.domain[1]
|
|
170
|
+
? group.minimumTick
|
|
171
|
+
: undefined
|
|
172
|
+
const tickValues = withMaximumTick(majorTicks, maximumTick, minimumTick)
|
|
173
|
+
const { tickTextWidth, titleTextWidth } = axisTextMetrics(
|
|
168
174
|
scale.domain() as [number, number],
|
|
169
175
|
false,
|
|
170
176
|
tickValues,
|
|
@@ -172,10 +178,13 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou
|
|
|
172
178
|
tickCount,
|
|
173
179
|
)
|
|
174
180
|
const clearance =
|
|
175
|
-
|
|
181
|
+
Math.max(
|
|
182
|
+
tickTextWidth,
|
|
183
|
+
titleTextWidth +
|
|
184
|
+
Y_AXIS_LABEL_GAP +
|
|
185
|
+
resolveYAxisLabelBandWidth(options.yAxisPadding?.labelTextStyle),
|
|
186
|
+
) +
|
|
176
187
|
Y_AXIS_TICK_PADDING +
|
|
177
|
-
Y_AXIS_LABEL_GAP +
|
|
178
|
-
resolveYAxisLabelBandWidth(options.yAxisPadding?.labelTextStyle) +
|
|
179
188
|
Y_AXIS_OUTER_PADDING
|
|
180
189
|
const slot = options.yAxisSlots?.find(
|
|
181
190
|
(candidate) => candidate.side === group.side && candidate.sideIndex === sideIndex,
|
|
@@ -186,7 +195,7 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou
|
|
|
186
195
|
? -sideOffsets.left
|
|
187
196
|
: cell.width + sideOffsets.right
|
|
188
197
|
const labelDistance =
|
|
189
|
-
|
|
198
|
+
titleTextWidth +
|
|
190
199
|
Y_AXIS_TICK_PADDING +
|
|
191
200
|
Y_AXIS_LABEL_GAP +
|
|
192
201
|
resolveYAxisLabelBandWidth(options.yAxisPadding?.labelTextStyle) / 2
|
|
@@ -203,6 +212,7 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou
|
|
|
203
212
|
labelX,
|
|
204
213
|
scale,
|
|
205
214
|
maximumTick,
|
|
215
|
+
minimumTick,
|
|
206
216
|
majorTicks,
|
|
207
217
|
minorTicks: buildMinorTicks(majorTicks, 2),
|
|
208
218
|
tickValues,
|
|
@@ -28,6 +28,7 @@ export interface DisplaySeries {
|
|
|
28
28
|
source?: WaveformPointSource
|
|
29
29
|
xDomain: [number, number]
|
|
30
30
|
rawYMaximum?: number
|
|
31
|
+
rawYMinimum?: number
|
|
31
32
|
yDomain: [number, number]
|
|
32
33
|
hasErrorPoints: boolean
|
|
33
34
|
}
|
|
@@ -54,6 +55,7 @@ export interface TrackSeriesPath {
|
|
|
54
55
|
|
|
55
56
|
export interface WaveformYAxisLayout {
|
|
56
57
|
maximumTick?: number
|
|
58
|
+
minimumTick?: number
|
|
57
59
|
index: number
|
|
58
60
|
side: 'left' | 'right'
|
|
59
61
|
x: number
|
|
@@ -26,6 +26,7 @@ export interface PreparedWaveformSeries {
|
|
|
26
26
|
source: WaveformPointSource
|
|
27
27
|
xDomain: [number, number]
|
|
28
28
|
rawYMaximum?: number
|
|
29
|
+
rawYMinimum?: number
|
|
29
30
|
yDomain: [number, number]
|
|
30
31
|
hasErrorPoints: boolean
|
|
31
32
|
}
|
|
@@ -50,6 +51,7 @@ export function prepareWaveformSeries(data: WaveformData): PreparedWaveformSerie
|
|
|
50
51
|
: [],
|
|
51
52
|
),
|
|
52
53
|
rawYMaximum: Number.isFinite(sourceMetrics.yMaximum) ? sourceMetrics.yMaximum : undefined,
|
|
54
|
+
rawYMinimum: Number.isFinite(sourceMetrics.yMinimum) ? sourceMetrics.yMinimum : undefined,
|
|
53
55
|
hasErrorPoints: sourceMetrics.hasErrorPoints,
|
|
54
56
|
}
|
|
55
57
|
})
|
|
@@ -17,6 +17,7 @@ import type { ResolvedWaveformChartProps } from './waveformChartTypes'
|
|
|
17
17
|
import { applyXDomainStrategy } from './xDomain'
|
|
18
18
|
import { alignIntegerZoomDomain, normalizeIntegerZoomDomain } from '../interaction/integerZoom'
|
|
19
19
|
import { resolveViewportYDomains } from './yAxisLayoutMetrics'
|
|
20
|
+
import { useExtremaTickViewport } from '../interaction/useExtremaTickViewport'
|
|
20
21
|
import { useWaveformYAxisLayout } from './useWaveformYAxisLayout'
|
|
21
22
|
import type { useWaveformAnnotationInteraction } from '../annotation'
|
|
22
23
|
interface LayoutContext {
|
|
@@ -279,6 +280,15 @@ export function useWaveformLayout(context: LayoutContext) {
|
|
|
279
280
|
showEmptyTracks: hasWaveformData.value,
|
|
280
281
|
}),
|
|
281
282
|
)
|
|
283
|
+
useExtremaTickViewport(
|
|
284
|
+
() =>
|
|
285
|
+
Number(props.axes?.y?.maximumTickVisible !== false) * 2 +
|
|
286
|
+
Number(props.axes?.y?.minimumTickVisible !== false),
|
|
287
|
+
innerWidth,
|
|
288
|
+
trackLayouts,
|
|
289
|
+
sharedTransform,
|
|
290
|
+
independentTransforms,
|
|
291
|
+
)
|
|
282
292
|
const annotationLayoutsForTrack = (track: TrackLayout): AnnotationTrackLayout[] =>
|
|
283
293
|
track.seriesList.map((series) => ({
|
|
284
294
|
...track,
|
|
@@ -60,28 +60,37 @@ export function resolveYAxisLayoutMetrics(
|
|
|
60
60
|
padding,
|
|
61
61
|
),
|
|
62
62
|
)
|
|
63
|
-
.map(
|
|
64
|
-
(
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
63
|
+
.map((group) =>
|
|
64
|
+
axisTextMetrics(
|
|
65
|
+
group.domain,
|
|
66
|
+
nice,
|
|
67
|
+
undefined,
|
|
68
|
+
showUnits ? group.seriesList[0]?.unit : undefined,
|
|
69
|
+
tickCount,
|
|
70
|
+
compact,
|
|
71
|
+
measureTextWidth,
|
|
72
|
+
group.maximumTick,
|
|
73
|
+
group.minimumTick,
|
|
74
|
+
),
|
|
75
75
|
)
|
|
76
|
-
const tickTextWidth = Math.max(
|
|
76
|
+
const tickTextWidth = Math.max(
|
|
77
|
+
Y_AXIS_CHARACTER_WIDTH,
|
|
78
|
+
...axisText.map((text) => text.tickTextWidth),
|
|
79
|
+
)
|
|
80
|
+
const titleTextWidth = Math.max(
|
|
81
|
+
Y_AXIS_CHARACTER_WIDTH,
|
|
82
|
+
...axisText.map((text) => text.titleTextWidth),
|
|
83
|
+
)
|
|
77
84
|
const tickClearance = tickTextWidth + Y_AXIS_TICK_PADDING + Y_AXIS_OUTER_PADDING
|
|
78
85
|
const labelCenterX = -(
|
|
79
86
|
Y_AXIS_TICK_PADDING +
|
|
80
|
-
|
|
87
|
+
titleTextWidth +
|
|
81
88
|
Y_AXIS_LABEL_GAP +
|
|
82
89
|
labelBandWidth / 2
|
|
83
90
|
)
|
|
84
91
|
const fullClearance =
|
|
85
|
-
tickTextWidth
|
|
92
|
+
Math.max(tickTextWidth, titleTextWidth + Y_AXIS_LABEL_GAP + labelBandWidth) +
|
|
93
|
+
Y_AXIS_TICK_PADDING +
|
|
94
|
+
Y_AXIS_OUTER_PADDING
|
|
86
95
|
return { tickClearance, fullClearance, labelCenterX }
|
|
87
96
|
}
|
|
@@ -14,26 +14,46 @@ export function applyYAxisPadding(
|
|
|
14
14
|
viewport: [number, number] | undefined,
|
|
15
15
|
options: YAxisPaddingOptions | number,
|
|
16
16
|
): YAxisSeriesGroup {
|
|
17
|
-
if (group.fixed) return group
|
|
18
17
|
const config = typeof options === 'number' ? { upperPaddingRatio: options } : options
|
|
18
|
+
if (group.fixed) return withYAxisExtremaTicks(group, config)
|
|
19
19
|
const upper = paddingRatio(config?.upperPaddingEnabled, config?.upperPaddingRatio)
|
|
20
20
|
const lower = paddingRatio(config?.lowerPaddingEnabled, config?.lowerPaddingRatio)
|
|
21
|
-
const maxima = group.seriesList.flatMap((series) =>
|
|
22
|
-
Number.isFinite(series.rawYMaximum) ? [series.rawYMaximum!] : [],
|
|
23
|
-
)
|
|
24
|
-
const maximumTick = upper > 0 && maxima.length ? Math.max(...maxima) : undefined
|
|
25
21
|
const [a, b] = group.domain
|
|
26
22
|
const span = b - a
|
|
27
23
|
const min = lower > 0 ? a - span * lower : a
|
|
28
24
|
const max = upper > 0 ? b + span * upper : b
|
|
25
|
+
return withYAxisExtremaTicks(
|
|
26
|
+
{
|
|
27
|
+
...group,
|
|
28
|
+
domain: viewport ?? [Number.isFinite(min) ? min : a, Number.isFinite(max) ? max : b],
|
|
29
|
+
},
|
|
30
|
+
config,
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function withYAxisExtremaTicks(
|
|
35
|
+
group: YAxisSeriesGroup,
|
|
36
|
+
options: YAxisPaddingOptions,
|
|
37
|
+
): YAxisSeriesGroup {
|
|
38
|
+
const maxima = group.seriesList.flatMap((series) =>
|
|
39
|
+
Number.isFinite(series.rawYMaximum) ? [series.rawYMaximum!] : [],
|
|
40
|
+
)
|
|
41
|
+
const minima = group.seriesList.flatMap((series) =>
|
|
42
|
+
Number.isFinite(series.rawYMinimum) ? [series.rawYMinimum!] : [],
|
|
43
|
+
)
|
|
29
44
|
return {
|
|
30
45
|
...group,
|
|
31
|
-
maximumTick
|
|
32
|
-
|
|
46
|
+
maximumTick:
|
|
47
|
+
options?.maximumTickVisible !== false && maxima.length ? Math.max(...maxima) : undefined,
|
|
48
|
+
minimumTick:
|
|
49
|
+
options?.minimumTickVisible !== false && minima.length ? Math.min(...minima) : undefined,
|
|
33
50
|
}
|
|
34
51
|
}
|
|
35
52
|
|
|
36
|
-
export function withMaximumTick(values: number[], maximum?: number): number[] {
|
|
53
|
+
export function withMaximumTick(values: number[], maximum?: number, minimum?: number): number[] {
|
|
54
|
+
if (minimum !== undefined && Number.isFinite(minimum)) {
|
|
55
|
+
values = [...values.filter((value) => !sameTick(value, minimum)), minimum].sort((a, b) => a - b)
|
|
56
|
+
}
|
|
37
57
|
if (maximum === undefined || !Number.isFinite(maximum)) return values
|
|
38
58
|
return [...values.filter((value) => !sameTick(value, maximum)), maximum].sort((a, b) => a - b)
|
|
39
59
|
}
|
|
@@ -1,13 +1,9 @@
|
|
|
1
1
|
import { formatYAxisTickLayoutLabel } from './layout'
|
|
2
2
|
import type { TrackLayout, WaveformYAxisLayout } from './types'
|
|
3
3
|
import type { WaveformYAxisLabelTextStyle } from '../../types'
|
|
4
|
-
import { resolveYAxisLabelBandWidth
|
|
4
|
+
import { resolveYAxisLabelBandWidth } from './yAxisLabelStyle'
|
|
5
5
|
import { Y_AXIS_CHARACTER_WIDTH, Y_AXIS_TICK_PADDING } from './yAxisConstants'
|
|
6
6
|
|
|
7
|
-
// Match the 11px ticks in WaveformTrack.css.
|
|
8
|
-
const TICK_FONT_SIZE = 11
|
|
9
|
-
const TEXT_GAP = 2
|
|
10
|
-
|
|
11
7
|
interface TitleLayoutOptions {
|
|
12
8
|
labelTextStyle?: WaveformYAxisLabelTextStyle
|
|
13
9
|
showUnits?: boolean
|
|
@@ -15,52 +11,20 @@ interface TitleLayoutOptions {
|
|
|
15
11
|
measureTextWidth?: (text: string) => number
|
|
16
12
|
}
|
|
17
13
|
|
|
18
|
-
function estimateTitleWidth(title: string, fontSize: number): number {
|
|
19
|
-
return Array.from(title).reduce((width, character) => {
|
|
20
|
-
// Latin labels average well below one em; full-width characters occupy one em.
|
|
21
|
-
const em = /[\u2e80-\u9fff\uf900-\ufaff\uff01-\uff60]/u.test(character)
|
|
22
|
-
? 1
|
|
23
|
-
: /[MW@%]/u.test(character)
|
|
24
|
-
? 0.95
|
|
25
|
-
: /[ilI1., :;|'!]/u.test(character)
|
|
26
|
-
? 0.4
|
|
27
|
-
: 0.65
|
|
28
|
-
return width + em * fontSize
|
|
29
|
-
}, 0)
|
|
30
|
-
}
|
|
31
|
-
|
|
32
14
|
type TitleTrack = Pick<TrackLayout, 'height' | 'column' | 'yAxes'>
|
|
33
15
|
|
|
34
|
-
function titleDistance(
|
|
35
|
-
track: TitleTrack,
|
|
36
|
-
axis: WaveformYAxisLayout,
|
|
37
|
-
yLabel: string | undefined,
|
|
38
|
-
options: TitleLayoutOptions,
|
|
39
|
-
): number {
|
|
16
|
+
function titleDistance(axis: WaveformYAxisLayout, options: TitleLayoutOptions): number {
|
|
40
17
|
const series = axis.seriesList[0]
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
const titleTop = track.height / 2 - halfTitleHeight - TEXT_GAP
|
|
45
|
-
const titleBottom = track.height / 2 + halfTitleHeight + TEXT_GAP
|
|
46
|
-
const lowestTick = Math.min(...axis.tickValues)
|
|
47
|
-
const highestTick = Math.max(...axis.tickValues)
|
|
48
|
-
const tickWidths = axis.tickValues.flatMap((value) => {
|
|
49
|
-
// D3 places ticks at scale(value) + 0.5. The lowest label is bottom-aligned;
|
|
50
|
-
// all other labels are vertically centered using dy="0.32em".
|
|
51
|
-
const y = axis.scale(value) + 0.5
|
|
52
|
-
const isTopAligned = options.compact && value === highestTick
|
|
53
|
-
const top = y - (value === lowestTick ? TICK_FONT_SIZE : isTopAligned ? 0 : TICK_FONT_SIZE / 2)
|
|
54
|
-
const bottom =
|
|
55
|
-
value === lowestTick ? y : y + (isTopAligned ? TICK_FONT_SIZE : TICK_FONT_SIZE / 2)
|
|
56
|
-
if (bottom < titleTop || top > titleBottom) return []
|
|
18
|
+
// Reserve the widest tick, including extrema, regardless of the frame height.
|
|
19
|
+
// The layout formatter excludes scientific multipliers but retains the numeric label.
|
|
20
|
+
const tickWidths = axis.tickValues.map((value) => {
|
|
57
21
|
const text = formatYAxisTickLayoutLabel(
|
|
58
22
|
value,
|
|
59
23
|
axis.scale.domain() as [number, number],
|
|
60
24
|
axis.tickValues,
|
|
61
25
|
options.showUnits === false ? undefined : series?.unit,
|
|
62
26
|
)
|
|
63
|
-
return
|
|
27
|
+
return options.measureTextWidth?.(text) ?? text.length * Y_AXIS_CHARACTER_WIDTH
|
|
64
28
|
})
|
|
65
29
|
// The title band leaves 4px beside each glyph.
|
|
66
30
|
return (
|
|
@@ -72,7 +36,7 @@ function titleDistance(
|
|
|
72
36
|
|
|
73
37
|
export function alignLeftYAxisTitles(
|
|
74
38
|
layouts: TitleTrack[],
|
|
75
|
-
|
|
39
|
+
_yLabel?: string,
|
|
76
40
|
options: TitleLayoutOptions = {},
|
|
77
41
|
): void {
|
|
78
42
|
const distances = new Map<string, number>()
|
|
@@ -81,11 +45,8 @@ export function alignLeftYAxisTitles(
|
|
|
81
45
|
for (const axis of track.yAxes) {
|
|
82
46
|
if (axis.side !== 'left') continue
|
|
83
47
|
const key = `${track.column}:${leftIndex++}`
|
|
84
|
-
const distance = titleDistance(
|
|
85
|
-
|
|
86
|
-
// alignment can never push them beyond the space reserved at the chart edge.
|
|
87
|
-
const boundedDistance = options.compact ? Math.min(axis.x - axis.labelX, distance) : distance
|
|
88
|
-
distances.set(key, Math.max(distances.get(key) ?? 0, boundedDistance))
|
|
48
|
+
const distance = titleDistance(axis, options)
|
|
49
|
+
distances.set(key, Math.max(distances.get(key) ?? 0, distance))
|
|
89
50
|
}
|
|
90
51
|
}
|
|
91
52
|
for (const track of layouts) {
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { watch, type ComputedRef, type ShallowRef } from 'vue'
|
|
2
|
+
import type { ZoomTransform } from 'd3'
|
|
3
|
+
|
|
4
|
+
import type { TrackLayout } from '../core/types'
|
|
5
|
+
|
|
6
|
+
/** Keep X coordinates stable when toggling extrema changes the reserved Y-label width. */
|
|
7
|
+
export function useExtremaTickViewport(
|
|
8
|
+
visible: () => number,
|
|
9
|
+
innerWidth: ComputedRef<number>,
|
|
10
|
+
tracks: ComputedRef<TrackLayout[]>,
|
|
11
|
+
sharedTransform: ShallowRef<ZoomTransform>,
|
|
12
|
+
independentTransforms: ShallowRef<ZoomTransform[]>,
|
|
13
|
+
) {
|
|
14
|
+
const resizeTransform = (transform: ZoomTransform, previous: number, next: number) =>
|
|
15
|
+
previous > 0 && next > 0
|
|
16
|
+
? transform.translate(((next / previous - 1) * transform.x) / transform.k, 0)
|
|
17
|
+
: transform
|
|
18
|
+
|
|
19
|
+
watch(
|
|
20
|
+
() => ({
|
|
21
|
+
visible: visible(),
|
|
22
|
+
width: innerWidth.value,
|
|
23
|
+
tracks: tracks.value.map((track) => ({
|
|
24
|
+
id: track.id,
|
|
25
|
+
index: track.index,
|
|
26
|
+
width: track.width,
|
|
27
|
+
})),
|
|
28
|
+
}),
|
|
29
|
+
(next, previous) => {
|
|
30
|
+
if (next.visible === previous.visible) return
|
|
31
|
+
sharedTransform.value = resizeTransform(sharedTransform.value, previous.width, next.width)
|
|
32
|
+
const transforms = [...independentTransforms.value]
|
|
33
|
+
next.tracks.forEach((track) => {
|
|
34
|
+
const prior = previous.tracks.find((item) => item.id === track.id)
|
|
35
|
+
const transform = transforms[track.index]
|
|
36
|
+
if (prior && transform) {
|
|
37
|
+
transforms[track.index] = resizeTransform(transform, prior.width, track.width)
|
|
38
|
+
}
|
|
39
|
+
})
|
|
40
|
+
independentTransforms.value = transforms
|
|
41
|
+
},
|
|
42
|
+
)
|
|
43
|
+
}
|
|
@@ -21,6 +21,7 @@ interface Props {
|
|
|
21
21
|
showUnits?: boolean
|
|
22
22
|
showLabelUnits?: boolean
|
|
23
23
|
hideYAxisTitles?: boolean
|
|
24
|
+
hideOverlappingMaximumLabel?: boolean
|
|
24
25
|
}
|
|
25
26
|
|
|
26
27
|
const props = withDefaults(defineProps<Props>(), { showUnits: true })
|
|
@@ -56,6 +57,7 @@ function renderAxes() {
|
|
|
56
57
|
const element = yAxisElements.value[index]
|
|
57
58
|
if (!element) return
|
|
58
59
|
const unit = props.showUnits ? axis.seriesList[0]?.unit : undefined
|
|
60
|
+
const highestTick = Math.max(...axis.tickValues)
|
|
59
61
|
const yAxis = (axis.side === 'left' ? axisLeft(axis.scale) : axisRight(axis.scale))
|
|
60
62
|
.tickFormat((value) =>
|
|
61
63
|
formatYAxisTickLabel(
|
|
@@ -78,11 +80,26 @@ function renderAxes() {
|
|
|
78
80
|
.attr('y', null)
|
|
79
81
|
.attr('dy', '0')
|
|
80
82
|
.attr('dominant-baseline', 'central')
|
|
83
|
+
.attr('data-axis-maximum-label', (value) =>
|
|
84
|
+
props.hideOverlappingMaximumLabel && sameTick(value, highestTick) ? 'true' : null,
|
|
85
|
+
)
|
|
86
|
+
.attr('data-axis-minimum-label', (value) =>
|
|
87
|
+
props.hideOverlappingMaximumLabel && sameTick(value, Math.min(...axis.tickValues))
|
|
88
|
+
? 'true'
|
|
89
|
+
: null,
|
|
90
|
+
)
|
|
91
|
+
selection
|
|
92
|
+
.attr('data-label-domain-min', axis.scale.domain()[0])
|
|
93
|
+
.attr('data-label-domain-max', axis.scale.domain()[1])
|
|
94
|
+
.attr('data-label-unit', unit ?? null)
|
|
81
95
|
selection
|
|
82
96
|
.selectAll<SVGGElement, number>('.tick')
|
|
83
97
|
.attr('data-maximum-tick', (value) =>
|
|
84
98
|
axis.maximumTick !== undefined && sameTick(value, axis.maximumTick) ? 'true' : null,
|
|
85
99
|
)
|
|
100
|
+
.attr('data-minimum-tick', (value) =>
|
|
101
|
+
axis.minimumTick !== undefined && sameTick(value, axis.minimumTick) ? 'true' : null,
|
|
102
|
+
)
|
|
86
103
|
.attr('data-tick-value', (value) => value)
|
|
87
104
|
.attr('data-tick-y', (value) => axis.scale(value))
|
|
88
105
|
selection
|
|
@@ -147,6 +164,7 @@ watch(
|
|
|
147
164
|
() => props.axes?.x?.labelFormatter,
|
|
148
165
|
() => props.axes?.x?.lineVisible,
|
|
149
166
|
() => props.axes?.y?.lineVisible,
|
|
167
|
+
() => props.hideOverlappingMaximumLabel,
|
|
150
168
|
],
|
|
151
169
|
async () => {
|
|
152
170
|
await nextTick()
|