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.
Files changed (39) hide show
  1. package/README.md +34 -13
  2. package/dist/index.cjs +2 -2
  3. package/dist/index.js +2635 -2547
  4. package/dist/style.css +1 -1
  5. package/dist/types/components/annotation/useWaveformChartAnnotations.d.ts +0 -1
  6. package/dist/types/components/core/layout.d.ts +4 -2
  7. package/dist/types/components/core/types.d.ts +2 -0
  8. package/dist/types/components/core/useWaveformChartController.d.ts +0 -1
  9. package/dist/types/components/core/useWaveformData.d.ts +1 -0
  10. package/dist/types/components/core/yAxisPadding.d.ts +1 -1
  11. package/dist/types/components/core/yAxisTitleLayout.d.ts +1 -1
  12. package/dist/types/components/interaction/useExtremaTickViewport.d.ts +5 -0
  13. package/dist/types/components/rendering/WaveformTrackAxes.vue.d.ts +1 -0
  14. package/dist/types/data/sx2Channel.d.ts +10 -0
  15. package/dist/types/demo/types.d.ts +2 -0
  16. package/dist/types/types/chart.d.ts +4 -0
  17. package/package.json +1 -1
  18. package/src/App.vue +6 -0
  19. package/src/components/WaveformChartView.vue +0 -3
  20. package/src/components/annotation/useWaveformChartAnnotations.ts +7 -23
  21. package/src/components/controls/WaveformToolbar.vue +19 -20
  22. package/src/components/core/layout.ts +52 -26
  23. package/src/components/core/trackLayoutBuilder.ts +16 -6
  24. package/src/components/core/types.ts +2 -0
  25. package/src/components/core/useWaveformData.ts +2 -0
  26. package/src/components/core/useWaveformLayout.ts +10 -0
  27. package/src/components/core/yAxisLayoutMetrics.ts +24 -15
  28. package/src/components/core/yAxisPadding.ts +28 -8
  29. package/src/components/core/yAxisTitleLayout.ts +9 -48
  30. package/src/components/interaction/useExtremaTickViewport.ts +43 -0
  31. package/src/components/rendering/WaveformTrack.vue +1 -0
  32. package/src/components/rendering/WaveformTrackAxes.vue +18 -0
  33. package/src/components/rendering/yAxisTickVisibility.ts +77 -16
  34. package/src/data/simulatedWaveforms.ts +10 -0
  35. package/src/data/sx2Channel.ts +315 -0
  36. package/src/demo/DemoYAxisPaddingControls.vue +15 -0
  37. package/src/demo/types.ts +2 -0
  38. package/src/index.ts +1 -0
  39. package/src/types/chart.ts +4 -0
@@ -83,7 +83,7 @@ export function formatYAxisTickLabel(
83
83
  return label
84
84
  }
85
85
 
86
- /** Scientific multipliers retain their rendered position but do not reserve horizontal space. */
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
- ): { tickTextWidth: number } {
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 tickValueSets = [withMaximumTick(resolvedTicks.values, maximumTick)]
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 maximumTickWidth = Math.max(
222
- Y_AXIS_CHARACTER_WIDTH,
223
- ...tickValueSets.flatMap((values) =>
224
- values.map((value) => {
225
- const text = formatYAxisTickLayoutLabel(value, resolvedTicks.domain, values, unit)
226
- return measureTextWidth ? measureTextWidth(text) : text.length * Y_AXIS_CHARACTER_WIDTH
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: maximumTickWidth,
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
- axisTextMetrics(group.domain, nice, undefined, group.seriesList[0]?.unit, tickCount)
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 width = axisTextMetrics(
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
- ).tickTextWidth
326
- widths.set(key, Math.max(widths.get(key) ?? Y_AXIS_CHARACTER_WIDTH, width))
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
- tickTextWidth + Y_AXIS_TICK_PADDING + Y_AXIS_LABEL_GAP + labelBandWidth / 2
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 tickValues = withMaximumTick(majorTicks, maximumTick)
167
- const { tickTextWidth } = axisTextMetrics(
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
- tickTextWidth +
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
- tickTextWidth +
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
- (group) =>
65
- axisTextMetrics(
66
- group.domain,
67
- nice,
68
- undefined,
69
- showUnits ? group.seriesList[0]?.unit : undefined,
70
- tickCount,
71
- compact,
72
- measureTextWidth,
73
- group.maximumTick,
74
- ).tickTextWidth,
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(Y_AXIS_CHARACTER_WIDTH, ...axisText)
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
- tickTextWidth +
87
+ titleTextWidth +
81
88
  Y_AXIS_LABEL_GAP +
82
89
  labelBandWidth / 2
83
90
  )
84
91
  const fullClearance =
85
- tickTextWidth + Y_AXIS_TICK_PADDING + Y_AXIS_LABEL_GAP + labelBandWidth + Y_AXIS_OUTER_PADDING
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
- domain: viewport ?? [Number.isFinite(min) ? min : a, Number.isFinite(max) ? max : b],
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, resolveYAxisLabelFontSize } from './yAxisLabelStyle'
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
- const title = series?.name.trim() || yLabel || ''
42
- const halfTitleHeight =
43
- estimateTitleWidth(title, resolveYAxisLabelFontSize(options.labelTextStyle)) / 2
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 [options.measureTextWidth?.(text) ?? text.length * Y_AXIS_CHARACTER_WIDTH]
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
- yLabel?: string,
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(track, axis, yLabel, options)
85
- // Compact slots already reserve every tick. Only move names inward, so
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
+ }
@@ -111,6 +111,7 @@ const frameInset = computed(() =>
111
111
  :show-units="showAxisUnits"
112
112
  :show-label-units="showLabelUnits"
113
113
  :hide-y-axis-titles="hideYAxisTitles"
114
+ :hide-overlapping-maximum-label="displayMode === 'compact'"
114
115
  />
115
116
 
116
117
  <component
@@ -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()