waveform-analysis 0.1.72 → 0.1.73

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 (37) hide show
  1. package/README.md +28 -10
  2. package/dist/index.cjs +2 -2
  3. package/dist/index.js +2933 -2849
  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 +2 -1
  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/interaction/useExtremaTickViewport.d.ts +5 -0
  12. package/dist/types/components/rendering/WaveformTrackAxes.vue.d.ts +1 -0
  13. package/dist/types/data/sx2Channel.d.ts +10 -0
  14. package/dist/types/demo/types.d.ts +1 -0
  15. package/dist/types/types/chart.d.ts +2 -0
  16. package/package.json +1 -1
  17. package/src/App.vue +3 -0
  18. package/src/components/WaveformChartView.vue +0 -3
  19. package/src/components/annotation/useWaveformChartAnnotations.ts +7 -23
  20. package/src/components/controls/WaveformToolbar.vue +19 -20
  21. package/src/components/core/layout.ts +16 -4
  22. package/src/components/core/trackLayoutBuilder.ts +8 -1
  23. package/src/components/core/types.ts +2 -0
  24. package/src/components/core/useWaveformData.ts +2 -0
  25. package/src/components/core/useWaveformLayout.ts +8 -0
  26. package/src/components/core/yAxisLayoutMetrics.ts +1 -0
  27. package/src/components/core/yAxisPadding.ts +23 -8
  28. package/src/components/interaction/useExtremaTickViewport.ts +43 -0
  29. package/src/components/rendering/WaveformTrack.vue +1 -0
  30. package/src/components/rendering/WaveformTrackAxes.vue +18 -0
  31. package/src/components/rendering/yAxisTickVisibility.ts +77 -16
  32. package/src/data/simulatedWaveforms.ts +10 -0
  33. package/src/data/sx2Channel.ts +315 -0
  34. package/src/demo/DemoYAxisPaddingControls.vue +7 -0
  35. package/src/demo/types.ts +1 -0
  36. package/src/index.ts +1 -0
  37. package/src/types/chart.ts +2 -0
@@ -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,13 @@ export function useWaveformLayout(context: LayoutContext) {
279
280
  showEmptyTracks: hasWaveformData.value,
280
281
  }),
281
282
  )
283
+ useExtremaTickViewport(
284
+ () => props.axes?.y?.extremaTicksVisible !== false,
285
+ innerWidth,
286
+ trackLayouts,
287
+ sharedTransform,
288
+ independentTransforms,
289
+ )
282
290
  const annotationLayoutsForTrack = (track: TrackLayout): AnnotationTrackLayout[] =>
283
291
  track.seriesList.map((series) => ({
284
292
  ...track,
@@ -71,6 +71,7 @@ export function resolveYAxisLayoutMetrics(
71
71
  compact,
72
72
  measureTextWidth,
73
73
  group.maximumTick,
74
+ group.minimumTick,
74
75
  ).tickTextWidth,
75
76
  )
76
77
  const tickTextWidth = Math.max(Y_AXIS_CHARACTER_WIDTH, ...axisText)
@@ -14,26 +14,41 @@ 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?.extremaTicksVisible)
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?.extremaTicksVisible,
31
+ )
32
+ }
33
+
34
+ function withYAxisExtremaTicks(group: YAxisSeriesGroup, visible = true): YAxisSeriesGroup {
35
+ const maxima = group.seriesList.flatMap((series) =>
36
+ Number.isFinite(series.rawYMaximum) ? [series.rawYMaximum!] : [],
37
+ )
38
+ const minima = group.seriesList.flatMap((series) =>
39
+ Number.isFinite(series.rawYMinimum) ? [series.rawYMinimum!] : [],
40
+ )
29
41
  return {
30
42
  ...group,
31
- maximumTick,
32
- domain: viewport ?? [Number.isFinite(min) ? min : a, Number.isFinite(max) ? max : b],
43
+ maximumTick: visible && maxima.length ? Math.max(...maxima) : undefined,
44
+ minimumTick: visible && minima.length ? Math.min(...minima) : undefined,
33
45
  }
34
46
  }
35
47
 
36
- export function withMaximumTick(values: number[], maximum?: number): number[] {
48
+ export function withMaximumTick(values: number[], maximum?: number, minimum?: number): number[] {
49
+ if (minimum !== undefined && Number.isFinite(minimum)) {
50
+ values = [...values.filter((value) => !sameTick(value, minimum)), minimum].sort((a, b) => a - b)
51
+ }
37
52
  if (maximum === undefined || !Number.isFinite(maximum)) return values
38
53
  return [...values.filter((value) => !sameTick(value, maximum)), maximum].sort((a, b) => a - b)
39
54
  }
@@ -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: () => boolean,
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()
@@ -1,4 +1,5 @@
1
1
  import { sameTick } from '../core/yAxisPadding'
2
+ import { formatYAxisTickLabel } from '../core/layout'
2
3
 
3
4
  interface Box {
4
5
  left: number
@@ -23,7 +24,13 @@ function textBox(text: SVGTextElement): Box {
23
24
  y += Number(match[2])
24
25
  }
25
26
  }
26
- return { left: x - width, right: x, top: y - height / 2, bottom: y + height / 2 }
27
+ const startAligned = text.getAttribute('text-anchor') === 'start'
28
+ return {
29
+ left: startAligned ? x : x - width,
30
+ right: startAligned ? x + width : x,
31
+ top: y - height / 2,
32
+ bottom: y + height / 2,
33
+ }
27
34
  }
28
35
 
29
36
  export function labelsOverlap(a: Box, b: Box): boolean {
@@ -43,12 +50,46 @@ function strokeWidth(element: Element): number {
43
50
  return Number.parseFloat(getComputedStyle(element).strokeWidth) || 1
44
51
  }
45
52
 
53
+ function formatCompactLabels(svg: SVGSVGElement) {
54
+ svg.querySelectorAll<SVGTextElement>('text[data-axis-maximum-label]').forEach((maximum) => {
55
+ const axis = maximum.parentElement!.parentElement!
56
+ const labels = [...axis.querySelectorAll<SVGTextElement>('.tick text')]
57
+ const value = (text: SVGTextElement) =>
58
+ Number(text.parentElement!.getAttribute('data-tick-value'))
59
+ const visibleValues = labels
60
+ .filter((text) => text.getAttribute('display') !== 'none')
61
+ .map(value)
62
+ const domain: [number, number] = [
63
+ Number(axis.getAttribute('data-label-domain-min')),
64
+ Number(axis.getAttribute('data-label-domain-max')),
65
+ ]
66
+ labels.forEach((text) => {
67
+ text.textContent = formatYAxisTickLabel(
68
+ value(text),
69
+ domain,
70
+ visibleValues,
71
+ axis.getAttribute('data-label-unit') ?? undefined,
72
+ axis.getAttribute('data-y-axis-side') === 'right' ? 'right' : 'left',
73
+ )
74
+ })
75
+ })
76
+ }
77
+
46
78
  /** Apply after axes and grid are in the DOM; never feed the filtered set back into layout. */
47
79
  export function updateMaximumTickVisibility(svg: SVGSVGElement) {
48
80
  svg.querySelectorAll('[data-maximum-hidden]').forEach((el) => {
49
81
  el.removeAttribute('display')
50
82
  el.removeAttribute('data-maximum-hidden')
51
83
  })
84
+ svg.querySelectorAll('.waveform-track__axis--y .tick text').forEach((text) => {
85
+ text.removeAttribute('display')
86
+ })
87
+ formatCompactLabels(svg)
88
+ const minimumLabels = [...svg.querySelectorAll<SVGTextElement>('text[data-axis-minimum-label]')]
89
+ svg.querySelectorAll<SVGTextElement>('text[data-axis-maximum-label]').forEach((maximum) => {
90
+ const box = textBox(maximum)
91
+ if (minimumLabels.some((minimum) => labelsOverlap(box, textBox(minimum)))) hide(maximum)
92
+ })
52
93
  svg.querySelectorAll<SVGGElement>('.waveform-track__axis--y .tick').forEach((tick) => {
53
94
  const position = Number(tick.getAttribute('data-tick-y'))
54
95
  const height = Number(tick.closest('.waveform-track')?.getAttribute('data-track-height'))
@@ -56,33 +97,49 @@ export function updateMaximumTickVisibility(svg: SVGSVGElement) {
56
97
  if (sameTick(position, 0) || sameTick(position, height)) line?.setAttribute('display', 'none')
57
98
  else line?.removeAttribute('display')
58
99
  })
100
+ const minimumTicks = [...svg.querySelectorAll<SVGGElement>('.tick[data-minimum-tick]')]
59
101
  const maximumTicks = [...svg.querySelectorAll<SVGGElement>('.tick[data-maximum-tick]')]
60
102
  const labels = [...svg.querySelectorAll<SVGTextElement>('.waveform-track__axis--y .tick text')]
61
- maximumTicks.forEach((tick) => {
103
+ minimumTicks.forEach((tick) => {
104
+ const text = tick.querySelector('text')
105
+ if (!text) return
106
+ maximumTicks.forEach((maximum) => {
107
+ const other = maximum.querySelector('text')
108
+ if (other && other !== text && labelsOverlap(textBox(text), textBox(other))) hide(other)
109
+ })
110
+ })
111
+ const extremeTicks = [...new Set([...minimumTicks, ...maximumTicks])]
112
+ extremeTicks.forEach((tick) => {
62
113
  const text = tick.querySelector('text')
63
114
  const line = tick.querySelector('line')
64
115
  if (!text || !line) return
65
116
  const box = textBox(text)
66
117
  labels.forEach((other) => {
67
- if (other.parentElement?.hasAttribute('data-maximum-tick')) return
118
+ if (text.getAttribute('display') === 'none') return
119
+ if (other.getAttribute('display') === 'none') return
120
+ if (other.parentElement?.matches('[data-minimum-tick], [data-maximum-tick]')) return
121
+ if (!tick.hasAttribute('data-minimum-tick') && other.hasAttribute('data-axis-minimum-label'))
122
+ return
68
123
  if (labelsOverlap(box, textBox(other))) hide(other)
69
124
  })
70
125
  const axis = tick.parentElement!
71
126
  const value = Number(tick.getAttribute('data-tick-value'))
72
127
  const position = Number(tick.getAttribute('data-tick-y'))
73
- axis.querySelectorAll<SVGGElement>('.tick:not([data-maximum-tick])').forEach((other) => {
74
- const otherLine = other.querySelector('line')
75
- if (
76
- otherLine &&
77
- linesOverlap(
78
- position,
79
- Number(other.getAttribute('data-tick-y')),
80
- strokeWidth(line),
81
- strokeWidth(otherLine),
128
+ axis
129
+ .querySelectorAll<SVGGElement>('.tick:not([data-maximum-tick]):not([data-minimum-tick])')
130
+ .forEach((other) => {
131
+ const otherLine = other.querySelector('line')
132
+ if (
133
+ otherLine &&
134
+ linesOverlap(
135
+ position,
136
+ Number(other.getAttribute('data-tick-y')),
137
+ strokeWidth(line),
138
+ strokeWidth(otherLine),
139
+ )
82
140
  )
83
- )
84
- hide(otherLine)
85
- })
141
+ hide(otherLine)
142
+ })
86
143
  // Only the primary Y axis owns the existing horizontal grid.
87
144
  if (axis.getAttribute('data-y-axis-index') !== '0') return
88
145
  const track = axis.closest('.waveform-track')!
@@ -90,6 +147,10 @@ export function updateMaximumTickVisibility(svg: SVGSVGElement) {
90
147
  const gridY = Number(gridLine.getAttribute('y1'))
91
148
  if (linesOverlap(position, gridY, strokeWidth(line), strokeWidth(gridLine))) hide(gridLine)
92
149
  })
93
- tick.setAttribute('data-maximum-value', String(value))
150
+ if (tick.hasAttribute('data-maximum-tick'))
151
+ tick.setAttribute('data-maximum-value', String(value))
152
+ if (tick.hasAttribute('data-minimum-tick'))
153
+ tick.setAttribute('data-minimum-value', String(value))
94
154
  })
155
+ formatCompactLabels(svg)
95
156
  }
@@ -1,5 +1,6 @@
1
1
  import type { WaveformData, WaveformPoint, WaveformSeries } from '../types'
2
2
  import { recordedDampedPoints } from './recordedDampedWaveform'
3
+ import { sx2Channel } from './sx2Channel'
3
4
 
4
5
  const POINT_COUNT = 1_000
5
6
  const START_TIME = -5
@@ -176,6 +177,15 @@ const seriesDefinitions: SimulatedSeriesDefinition[] = [
176
177
  pointType: 'none',
177
178
  signal: (time) => 6e-9 * Math.exp(-(time + 5) * 0.2) * Math.cos(TWO_PI * 0.8 * time),
178
179
  },
180
+ {
181
+ id: `recorded-${sx2Channel.chnl_id}`,
182
+ shotNo: String(sx2Channel.shot),
183
+ name: sx2Channel.chnl,
184
+ unit: sx2Channel.dat_unit,
185
+ lineType: 'linear',
186
+ pointType: 'none',
187
+ signal: sx2Channel.data.map((value, index) => [sx2Channel.time[index]!, value]),
188
+ },
179
189
  ]
180
190
 
181
191
  export function createSimulatedWaveformData(trackCount?: number): WaveformData {