5 Commits

Author SHA1 Message Date
李启源
7646bf4907 merge: integrate hybrid domain architecture 2026-08-07 15:59:38 +08:00
李启源
e2725e5569 refactor(chart): introduce hybrid domain architecture 2026-08-07 15:59:26 +08:00
李启源
9eb1f0f137 feat(chart): support configurable plot margins
All checks were successful
Package component / package (push) Successful in 7m2s
2026-08-05 15:16:52 +08:00
李启源
fbf10fdb88 feat(chart): support configurable x-axis labels
All checks were successful
Package component / package (push) Successful in 4m35s
2026-08-05 10:58:40 +08:00
李启源
c5aa3c662a fix(chart): refine axis number formatting
All checks were successful
Package component / package (push) Successful in 4m2s
2026-08-04 11:28:42 +08:00
51 changed files with 1371 additions and 426 deletions

View File

@@ -97,7 +97,7 @@ const data = ref<WaveformData>({
### Props ### Props
| Prop | 类型 | 默认值 | 说明 | | Prop | 类型 | 默认值 | 说明 |
| --------------------------------- | ------------------------------------------- | ------------------------------------------------------- | --------------------------------- | | --------------------------------- | ------------------------------------------- | ------------------------------------------------------- | ---------------------------------- |
| `data` | `WaveformData` | 必填 | 波形数据 | | `data` | `WaveformData` | 必填 | 波形数据 |
| `displayMode` | `'independent' \| 'separated' \| 'compact'` | `'independent'` | 图框布局 | | `displayMode` | `'independent' \| 'separated' \| 'compact'` | `'independent'` | 图框布局 |
| `overlayMode` | `'single-axis' \| 'multi-axis'` | `'single-axis'` | 叠加曲线的 Y 轴模式 | | `overlayMode` | `'single-axis' \| 'multi-axis'` | `'single-axis'` | 叠加曲线的 Y 轴模式 |
@@ -114,7 +114,7 @@ const data = ref<WaveformData>({
| `yDomain` | `[number, number]` | 未设置 | 所有波形的固定 Y 轴范围 | | `yDomain` | `[number, number]` | 未设置 | 所有波形的固定 Y 轴范围 |
| `yDomains` | `Record<string, [number, number]>` | 未设置 | 按 track/series ID 配置固定范围 | | `yDomains` | `Record<string, [number, number]>` | 未设置 | 按 track/series ID 配置固定范围 |
| `grid` | `WaveformGridOptions` | `{ rowCount: 2, columnCount: 1, showPagination: true }` | 网格和分页 | | `grid` | `WaveformGridOptions` | `{ rowCount: 2, columnCount: 1, showPagination: true }` | 网格和分页 |
| `axes` | `WaveformAxesOptions` | 轴线均显示 | X/Y 轴基线显隐 | | `axes` | `WaveformAxesOptions` | 轴线均显示 | X/Y 轴基线显隐与 X 轴 label 格式化 |
| `rendering` | `WaveformRenderingOptions` | `{}` | 降采样与点/误差棒间距 | | `rendering` | `WaveformRenderingOptions` | `{}` | 降采样与点/误差棒间距 |
| `title` / `legend` / `frameStyle` | 对应公开类型 | 未设置 | 标题、图例和图框样式 | | `title` / `legend` / `frameStyle` | 对应公开类型 | 未设置 | 标题、图例和图框样式 |
| `frameNumber` | `string \| number` | 未设置 | 图框水印内容 | | `frameNumber` | `string \| number` | 未设置 | 图框水印内容 |
@@ -129,8 +129,8 @@ const data = ref<WaveformData>({
所有公开类型均可从包入口导入,例如 `WaveformData``WaveformSeries` 所有公开类型均可从包入口导入,例如 `WaveformData``WaveformSeries`
`WaveformAnnotation``WaveformLineStyle``WaveformRenderingOptions` `WaveformAnnotation``WaveformLineStyle``WaveformRenderingOptions`
`WaveformAxesOptions``WaveformZeroLineOptions``WaveformGridOptions` `WaveformAxesOptions``WaveformXAxisLabelFormatter``WaveformZeroLineOptions`
`WaveformGridTrackLines` `WaveformGridOptions``WaveformGridTrackLines`
### 数据结构 ### 数据结构
@@ -508,6 +508,30 @@ scale 定位零线:
/> />
``` ```
X 轴刻度和左右端点默认先按 `timeUnit` 转换为秒或毫秒,再显示为无千分位、无科学计数法的
完整普通十进制值,不会四舍五入为整数。可通过 `axes.x.labelFormatter` 对显示值做运算和格式化:
```vue
<WaveformChart
:data="chartData"
:axes="{
x: {
labelFormatter: (value, context) =>
`${context.kind === 'tick' ? '' : '[' + context.kind + '] '}${(value / 1000).toFixed(3)}`,
},
}"
/>
```
formatter 首参是已按 `timeUnit` 换算的数值;上下文包含 `kind``tick``start``end`)、
原始 `rawValue``timeUnit`、原始可视域 `domain` 和换算后的 `displayDomain`。formatter 只决定
label 文本,不改变刻度位置、源数据、缩放域或事件中的原始 X 坐标。
Demo 左侧“网格与轴线”支持直接切换数值或固定时间格式。数值模式可设置运算倍率和小数位数;
固定时间模式输出 `YYYY-MM-DD HH:mm:ss[.SSS]`,可选择中国标准时间、本地时区或 UTC并控制
是否显示毫秒。时间戳数据仍按组件的秒坐标契约传入,使用默认毫秒显示单位时 formatter 会收到
可直接传给 `Date` 的毫秒时间戳。
`interactionMode` 可选 `zoom``annotation`,默认使用缩放模式。右键绘图区可直接打开 `interactionMode` 可选 `zoom``annotation`,默认使用缩放模式。右键绘图区可直接打开
标注编辑器,无需切换交互模式。`zoomable``pannable``showTooltip` 可分别控制缩放、 标注编辑器,无需切换交互模式。`zoomable``pannable``showTooltip` 可分别控制缩放、
空格拖拽平移和 tooltip平移默认关闭。 空格拖拽平移和 tooltip平移默认关闭。
@@ -603,7 +627,7 @@ async function importAnnotationFile(file: File) {
字段无效时会抛出 `TypeError`,不会返回部分结果。导入包含未知 `seriesId` 的标注是允许的, 字段无效时会抛出 `TypeError`,不会返回部分结果。导入包含未知 `seriesId` 的标注是允许的,
对应曲线加载后会恢复显示。文件选择、错误提示和下载由业务层实现。 对应曲线加载后会恢复显示。文件选择、错误提示和下载由业务层实现。
XY 轴会根据各自完整显示域选择格式:最大绝对值在 `[0.01, 100)` 时显示两位普通小数;大于等于 `100`,或大于 `0` 且小于 `0.01` 时,刻度显示两位缩放值,并在轴末端单独显示共享倍率 `E±NN`。X 轴先按 `timeUnit` 转换为秒或毫秒再判断范围,多 Y 轴分别计算倍率。tooltip 使用最多 4 位小数的本地化普通数字;标注编辑器的 X 坐标跟随 `timeUnit` 并固定 3 位小数Y 坐标显示完整普通十进制。所有格式化都只发生在展示层,内部坐标值保持原始精度。 X 轴刻度和左右端点先按 `timeUnit` 转换为秒或毫秒,再显示为不带分组符和科学计数法的完整普通十进制值,并可通过 `axes.x.labelFormatter` 自定义。Y 轴会根据完整显示域选择格式:最大绝对值在 `[0.01, 100)` 时显示两位普通小数;大于等于 `100`,或大于 `0` 且小于 `0.01` 时,刻度显示两位缩放值,并在轴末端单独显示共享倍率 `E±NN`,多 Y 轴分别计算倍率。tooltip 使用最多 4 位小数的本地化普通数字并省略无意义尾零;标注编辑器的 X 坐标跟随 `timeUnit` 并固定 3 位小数Y 坐标显示完整普通十进制。所有格式化都只发生在展示层,内部坐标值保持原始精度。
标注框默认布局在采样点正上方,只做绘图区边界裁剪;文本框通过连接箭头指向标注位置,多个标注重叠时可通过拖动手动避让。 标注框默认布局在采样点正上方,只做绘图区边界裁剪;文本框通过连接箭头指向标注位置,多个标注重叠时可通过拖动手动避让。
## 事件 ## 事件

36
docs/architecture.md Normal file
View File

@@ -0,0 +1,36 @@
# Architecture
Waveform Analysis uses a hybrid architecture. Vue Composition API remains the orchestration
boundary, while classes are reserved for domain objects with lifecycle or algorithm-selection
invariants.
## Boundaries
- `useWaveformChartController` is the facade for the chart. It composes composables and exposes the
existing reactive controller surface; it owns no independent copy of component props.
- `useWaveformViewport` keeps refs, computed values, pointer events, DOM capture, D3 coordinates,
and emitted events. `ViewportInteractionStateMachine` is its domain collaborator: it has no Vue
or DOM dependency and accepts only legal `begin`, `move`, `finish`, `cancel`, and `reset`
transitions. Its state is a `box`/`pan` discriminated union, with `null` representing idle.
- `RenderablePointSelectionStrategy` defines the replaceable rendering algorithm boundary.
`CompletePointSelectionStrategy` preserves the complete visible source range and
`PeakPreservingPointSelectionStrategy` preserves first/minimum/maximum/last points per bucket.
`resolveRenderablePointSelectionStrategy` resolves and reuses the strategy from rendering options. The
existing `selectRenderablePoints` function remains the compatibility facade used by rendering.
- `normalizeWaveformData` and `normalizeWaveformSeries` are functional adapters from public data
shapes to the internal series model. `buildTrackLayouts` remains a functional builder because
layout construction is a stateless calculation, not a long-lived object.
## Vue Integration
The state-machine instance is stored in `shallowRef(markRaw(...))`. Vue receives defensive state
snapshots through a shallow ref, while SVG overlay elements remain in the composable as DOM
resources. Presentation mode, annotation editing, scales, domains, ticks, formatting, and other
stateless calculations stay in their existing computed/composable or function boundaries.
## Constraints
Do not create classes solely to wrap Composition API refs, props, lifecycle hooks, D3 selections, or
pure mathematical helpers. Do not add inheritance trees, global event buses, service locators, or
duplicate prop state. A new class must own a real invariant or replaceable algorithm and must be
used by a production path with isolated tests.

View File

@@ -1,6 +1,6 @@
{ {
"name": "waveform-analysis", "name": "waveform-analysis",
"version": "0.1.27", "version": "0.1.30",
"main": "./dist/index.cjs", "main": "./dist/index.cjs",
"module": "./dist/index.js", "module": "./dist/index.js",
"types": "./dist/types/index.d.ts", "types": "./dist/types/index.d.ts",

View File

@@ -14,6 +14,7 @@ import {
type WaveformLegendOrientation, type WaveformLegendOrientation,
type WaveformLegendPosition, type WaveformLegendPosition,
type WaveformOverlayMode, type WaveformOverlayMode,
type WaveformPlotMargin,
type WaveformTitleOptions, type WaveformTitleOptions,
type WaveformZoomEndPayload, type WaveformZoomEndPayload,
type WaveformZeroLineOptions, type WaveformZeroLineOptions,
@@ -23,6 +24,7 @@ import { createSimulatedWaveformData } from './data/simulatedWaveforms'
import DemoChartHost from './demo/DemoChartHost.vue' import DemoChartHost from './demo/DemoChartHost.vue'
import DemoControlPanel from './demo/DemoControlPanel.vue' import DemoControlPanel from './demo/DemoControlPanel.vue'
import type { DemoChartModel, DemoControlPanelModel } from './demo/types' import type { DemoChartModel, DemoControlPanelModel } from './demo/types'
import { useDemoXAxisLabelControls } from './demo/useDemoXAxisLabelControls'
const fullChartData = createSimulatedWaveformData() const fullChartData = createSimulatedWaveformData()
const displayMode = ref<WaveformDisplayMode>('independent') const displayMode = ref<WaveformDisplayMode>('independent')
@@ -40,11 +42,14 @@ const verticalGridVisible = ref(true)
const verticalGridColor = ref('#dfe5ef') const verticalGridColor = ref('#dfe5ef')
const xAxisLineVisible = ref(false) const xAxisLineVisible = ref(false)
const yAxisLineVisible = ref(false) const yAxisLineVisible = ref(false)
const { controlModel: xAxisLabelControlModel, xAxisLabelFormatter } = useDemoXAxisLabelControls()
const annotations = ref<WaveformAnnotation[]>([]) const annotations = ref<WaveformAnnotation[]>([])
const annotationsVisible = ref(true) const annotationsVisible = ref(true)
const cleanView = ref(false) const cleanView = ref(false)
const presentationMode = ref(false) const presentationMode = ref(false)
const showTooltip = ref(true) const showTooltip = ref(true)
const plotMarginTop = ref(18)
const plotMarginBottom = ref(52)
const zeroLineVisible = ref(false) const zeroLineVisible = ref(false)
const zeroLineColor = ref('#98a2b3') const zeroLineColor = ref('#98a2b3')
const zeroLineWidth = ref(1) const zeroLineWidth = ref(1)
@@ -112,7 +117,10 @@ const frameStyle = computed<WaveformFrameStyle>(() => ({
backgroundColor: frameBackgroundColor.value, backgroundColor: frameBackgroundColor.value,
})) }))
const axes = computed<WaveformAxesOptions>(() => ({ const axes = computed<WaveformAxesOptions>(() => ({
x: { lineVisible: xAxisLineVisible.value }, x: {
lineVisible: xAxisLineVisible.value,
...(xAxisLabelFormatter.value ? { labelFormatter: xAxisLabelFormatter.value } : {}),
},
y: { lineVisible: yAxisLineVisible.value }, y: { lineVisible: yAxisLineVisible.value },
})) }))
const zeroLine = computed<WaveformZeroLineOptions>(() => ({ const zeroLine = computed<WaveformZeroLineOptions>(() => ({
@@ -121,6 +129,10 @@ const zeroLine = computed<WaveformZeroLineOptions>(() => ({
width: zeroLineWidth.value, width: zeroLineWidth.value,
dash: zeroLineDash.value, dash: zeroLineDash.value,
})) }))
const plotMargin = computed<WaveformPlotMargin>(() => ({
top: plotMarginTop.value,
bottom: plotMarginBottom.value,
}))
const initialXValues = normalizeWaveformSeries(fullChartData).flatMap((series) => const initialXValues = normalizeWaveformSeries(fullChartData).flatMap((series) =>
series.points.map((point) => point.x), series.points.map((point) => point.x),
@@ -289,6 +301,8 @@ const controlPanelModel = reactive({
displayMode, displayMode,
overlayMode, overlayMode,
showTooltip, showTooltip,
plotMarginTop,
plotMarginBottom,
cleanView, cleanView,
presentationMode, presentationMode,
selectedSeriesId, selectedSeriesId,
@@ -308,6 +322,7 @@ const controlPanelModel = reactive({
verticalGridColor, verticalGridColor,
xAxisLineVisible, xAxisLineVisible,
yAxisLineVisible, yAxisLineVisible,
...xAxisLabelControlModel,
frameBorderColor, frameBorderColor,
frameBackgroundColor, frameBackgroundColor,
frameBorderWidth, frameBorderWidth,
@@ -354,6 +369,7 @@ const chartModel = reactive({
cleanView, cleanView,
presentationMode, presentationMode,
showTooltip, showTooltip,
plotMargin,
zeroLine, zeroLine,
frameWatermarkVisible, frameWatermarkVisible,
annotations, annotations,

View File

@@ -23,6 +23,7 @@ const props = withDefaults(defineProps<WaveformChartProps>(), {
interactionMode: undefined, interactionMode: undefined,
grid: () => ({ rowCount: 2, columnCount: 1, showPagination: true }), grid: () => ({ rowCount: 2, columnCount: 1, showPagination: true }),
rendering: () => ({}), rendering: () => ({}),
plotMargin: () => ({}),
legend: () => ({ position: 'top-right', orientation: 'auto' }), legend: () => ({ position: 'top-right', orientation: 'auto' }),
defaultHiddenSeriesIds: () => [], defaultHiddenSeriesIds: () => [],
cleanView: false, cleanView: false,

View File

@@ -19,6 +19,7 @@ const {
overlayMode, overlayMode,
resolvedChartLeftMargin, resolvedChartLeftMargin,
titleAreaHeight, titleAreaHeight,
resolvedPlotMargin,
pointerInsideChart, pointerInsideChart,
handleNativeContextMenu, handleNativeContextMenu,
titleAreaReserved, titleAreaReserved,
@@ -109,7 +110,7 @@ const {
{ {
'waveform-chart--clean': isCleanView, 'waveform-chart--clean': isCleanView,
'waveform-chart--presentation': isPresentationMode, 'waveform-chart--presentation': isPresentationMode,
'waveform-chart--panning': selection?.mode === 'pan', 'waveform-chart--panning': selection?.kind === 'pan',
}, },
]" ]"
:style="containerStyle" :style="containerStyle"
@@ -118,6 +119,8 @@ const {
:data-presentation-mode="isPresentationMode" :data-presentation-mode="isPresentationMode"
:data-overlay-mode="overlayMode" :data-overlay-mode="overlayMode"
:data-chart-left-margin="resolvedChartLeftMargin" :data-chart-left-margin="resolvedChartLeftMargin"
:data-plot-margin-top="resolvedPlotMargin.top"
:data-plot-margin-bottom="resolvedPlotMargin.bottom"
:data-title-area-height="titleAreaHeight" :data-title-area-height="titleAreaHeight"
@pointerenter="pointerInsideChart = true" @pointerenter="pointerInsideChart = true"
@pointerleave="pointerInsideChart = false" @pointerleave="pointerInsideChart = false"
@@ -244,7 +247,7 @@ const {
/> />
<rect <rect
v-if="selectionBox && selection?.mode === 'box'" v-if="selectionBox && selection?.kind === 'box'"
class="waveform-chart__zoom-selection" class="waveform-chart__zoom-selection"
:x="selectionBox.x" :x="selectionBox.x"
:y="selectionBox.y" :y="selectionBox.y"
@@ -287,17 +290,17 @@ const {
/> />
</g> </g>
</g> </g>
</g>
<text <text
v-if="resolvedXLabel && !isCleanView" v-if="resolvedXLabel && !isCleanView"
class="waveform-chart__label waveform-chart__x-label" class="waveform-chart__label waveform-chart__x-label"
:x="innerWidth / 2" :x="resolvedChartLeftMargin + innerWidth / 2"
:y="xAxisTitleY" :y="xAxisTitleY"
text-anchor="middle" text-anchor="middle"
> >
{{ resolvedXLabel }} {{ resolvedXLabel }}
</text> </text>
</g>
<text <text
v-if="hasChartArea && !hasWaveformData" v-if="hasChartArea && !hasWaveformData"

View File

@@ -7,6 +7,9 @@
/** 图表边距 */ /** 图表边距 */
export const margin = { top: 18, right: 24, bottom: 52, left: 64 } export const margin = { top: 18, right: 24, bottom: 52, left: 64 }
/** Distance from the drawing SVG bottom edge to the X-axis title baseline. */
export const X_AXIS_TITLE_BOTTOM_OFFSET = 12
/** /**
* 图表最小高度(像素) * 图表最小高度(像素)
*/ */

View File

@@ -192,7 +192,7 @@ describe('multi-value Y-axis grouping', () => {
]) ])
}) })
it('places left and right scientific exponents eight pixels outside their tick labels', () => { it('reserves label clearance for exponent-prefixed ticks on both axis sides', () => {
const layout = buildTrackLayouts({ const layout = buildTrackLayouts({
cells: [ cells: [
{ {
@@ -206,7 +206,7 @@ describe('multi-value Y-axis grouping', () => {
plotHeight: 300, plotHeight: 300,
cellHeight: 330, cellHeight: 330,
xAxisBand: 30, xAxisBand: 30,
series: track([series('left', 0, 254), series('right', 0, 254)]), series: track([series('left', 1000, 3000), series('right', 1000, 3000)]),
}, },
], ],
grid: { rowCount: 1, columnCount: 1, showPagination: false, trackLines: {} }, grid: { rowCount: 1, columnCount: 1, showPagination: false, trackLines: {} },
@@ -222,14 +222,13 @@ describe('multi-value Y-axis grouping', () => {
})[0] })[0]
expect( expect(
layout?.yAxes.map(({ side, x, exponentX, exponentLabel }) => ({ layout?.yAxes.map(({ side, x, labelX }) => ({
side, side,
offset: Math.abs(exponentX - x), labelOffset: Math.abs(labelX - x),
exponentLabel,
})), })),
).toEqual([ ).toEqual([
{ side: 'left', offset: 43, exponentLabel: 'E+02' }, { side: 'left', labelOffset: 67 },
{ side: 'right', offset: 43, exponentLabel: 'E+02' }, { side: 'right', labelOffset: 67 },
]) ])
}) })
@@ -237,7 +236,7 @@ describe('multi-value Y-axis grouping', () => {
const [group] = buildYAxisSeriesGroups(track([series('long', -1e120, 1e120)]), 'multi-axis') const [group] = buildYAxisSeriesGroups(track([series('long', -1e120, 1e120)]), 'multi-axis')
expect(group).toBeDefined() expect(group).toBeDefined()
expect(measureYAxisGroupClearance(group!)).toBe(119) expect(measureYAxisGroupClearance(group!)).toBe(90)
}) })
}) })

View File

@@ -1,9 +1,9 @@
import { scaleLinear } from 'd3' import { scaleLinear } from 'd3'
import type { WaveformOverlayMode } from '../../types' import type { WaveformOverlayMode } from '../../types'
import { formatScientificAxisExponent, formatScientificAxisLabel, paddedDomain } from '../../utils' import { formatScientificAxisLabel, paddedDomain } from '../../utils'
import type { DisplaySeries, DisplayTrack, TrackLayout } from './types' import type { DisplaySeries, DisplayTrack, TrackLayout } from './types'
import { MAX_MULTI_Y_AXIS_COUNT, Y_AXIS_EXPONENT_GAP } from './constants' import { MAX_MULTI_Y_AXIS_COUNT } from './constants'
import { import {
mergeYDomains, mergeYDomains,
resolveSeriesFixedYDomain, resolveSeriesFixedYDomain,
@@ -12,7 +12,7 @@ import {
} from './yDomain' } from './yDomain'
// 导出常量供外部使用 // 导出常量供外部使用
export { MAX_MULTI_Y_AXIS_COUNT, Y_AXIS_EXPONENT_GAP } from './constants' export { MAX_MULTI_Y_AXIS_COUNT } from './constants'
const Y_AXIS_CHARACTER_WIDTH = 7 const Y_AXIS_CHARACTER_WIDTH = 7
const Y_AXIS_TICK_PADDING = 7 const Y_AXIS_TICK_PADDING = 7
@@ -102,36 +102,27 @@ export function resolveYAxisSeriesGroups(
export function axisTextMetrics( export function axisTextMetrics(
domain: [number, number], domain: [number, number],
nice = true, nice = true,
): { tickValues?: number[],
exponentLabel: string | null ): { tickTextWidth: number } {
exponentWidth: number
tickTextWidth: number
} {
const scale = scaleLinear(domain, [1, 0]) const scale = scaleLinear(domain, [1, 0])
if (nice) scale.nice() if (nice) scale.nice()
const [axisMin, axisMax] = scale.domain() const [axisMin, axisMax] = scale.domain()
const values = scale.ticks(10) const values = tickValues ?? Array.from(new Set([axisMin, ...scale.ticks(10), axisMax]))
const topTickValue = Math.max(...values)
const maximumTickCharacters = Math.max( const maximumTickCharacters = Math.max(
1, 1,
...values.map((value) => formatScientificAxisLabel(value, { axisMin, axisMax }).length), ...values.map(
(value) => formatScientificAxisLabel(value, { axisMin, axisMax, topTickValue }).length,
),
) )
const exponentLabel = formatScientificAxisExponent(axisMin, axisMax)
return { return {
exponentLabel,
exponentWidth: exponentLabel ? exponentLabel.length * Y_AXIS_CHARACTER_WIDTH : 0,
tickTextWidth: maximumTickCharacters * Y_AXIS_CHARACTER_WIDTH, tickTextWidth: maximumTickCharacters * Y_AXIS_CHARACTER_WIDTH,
} }
} }
function axisExponentClearance(domain: [number, number], nice: boolean): number {
const { exponentLabel, exponentWidth } = axisTextMetrics(domain, nice)
return exponentLabel ? exponentWidth + Y_AXIS_EXPONENT_GAP : 0
}
export function measureYAxisGroupClearance(group: YAxisSeriesGroup): number { export function measureYAxisGroupClearance(group: YAxisSeriesGroup): number {
return ( return (
axisTextMetrics(group.domain, !group.fixed).tickTextWidth + axisTextMetrics(group.domain, !group.fixed).tickTextWidth +
axisExponentClearance(group.domain, !group.fixed) +
Y_AXIS_TICK_PADDING + Y_AXIS_TICK_PADDING +
Y_AXIS_LABEL_GAP + Y_AXIS_LABEL_GAP +
Y_AXIS_LABEL_BAND_WIDTH + Y_AXIS_LABEL_BAND_WIDTH +
@@ -142,7 +133,6 @@ export function measureYAxisGroupClearance(group: YAxisSeriesGroup): number {
function measureYAxisGroupTickClearance(group: YAxisSeriesGroup): number { function measureYAxisGroupTickClearance(group: YAxisSeriesGroup): number {
return ( return (
axisTextMetrics(group.domain, !group.fixed).tickTextWidth + axisTextMetrics(group.domain, !group.fixed).tickTextWidth +
axisExponentClearance(group.domain, !group.fixed) +
Y_AXIS_TICK_PADDING + Y_AXIS_TICK_PADDING +
Y_AXIS_OUTER_PADDING Y_AXIS_OUTER_PADDING
) )

View File

@@ -12,8 +12,13 @@ import {
selectSeriesRenderPoints, selectSeriesRenderPoints,
type ResolvedWaveformRenderingOptions, type ResolvedWaveformRenderingOptions,
} from '../../core/rendering' } from '../../core/rendering'
import type { WaveformDisplayMode, WaveformOverlayMode, WaveformPoint } from '../../types' import type {
import { buildMinorTicks, formatAxisTimeExponent, formatEndpointTime } from '../../utils' WaveformDisplayMode,
WaveformOverlayMode,
WaveformPoint,
WaveformXAxisLabelFormatter,
} from '../../types'
import { buildMinorTicks, formatXAxisLabel } from '../../utils'
import { import {
getBottomRowCellIndexes, getBottomRowCellIndexes,
type GridCellGeometry, type GridCellGeometry,
@@ -21,7 +26,6 @@ import {
} from './grid' } from './grid'
import { axisTextMetrics, resolveYAxisSeriesGroups } from './layout' import { axisTextMetrics, resolveYAxisSeriesGroups } from './layout'
import type { DisplaySeries, DisplayTrack, TrackLayout, WaveformYAxisLayout } from './types' import type { DisplaySeries, DisplayTrack, TrackLayout, WaveformYAxisLayout } from './types'
import { Y_AXIS_EXPONENT_GAP } from './constants'
import { import {
Y_AXIS_LABEL_BAND_WIDTH, Y_AXIS_LABEL_BAND_WIDTH,
Y_AXIS_LABEL_GAP, Y_AXIS_LABEL_GAP,
@@ -46,6 +50,7 @@ export interface BuildTrackLayoutsOptions {
fixedYDomains?: Record<string, [number, number]> fixedYDomains?: Record<string, [number, number]>
yDomains?: Record<string, [number, number]> yDomains?: Record<string, [number, number]>
timeUnit: 's' | 'ms' timeUnit: 's' | 'ms'
xAxisLabelFormatter?: WaveformXAxisLabelFormatter
rendering: ResolvedWaveformRenderingOptions rendering: ResolvedWaveformRenderingOptions
hideSecondaryLabels: boolean hideSecondaryLabels: boolean
yAxisLabelX: number yAxisLabelX: number
@@ -118,31 +123,16 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou
const tickValues = Array.from( const tickValues = Array.from(
new Set([axisStart, ...visibleMajorTicks, ...(showAxisEnd ? [axisEnd] : [])]), new Set([axisStart, ...visibleMajorTicks, ...(showAxisEnd ? [axisEnd] : [])]),
) )
const { exponentLabel, exponentWidth, tickTextWidth } = axisTextMetrics( const { tickTextWidth } = axisTextMetrics(group.domain, !group.fixed, tickValues)
group.domain,
!group.fixed,
)
const exponentClearance = exponentLabel ? exponentWidth + Y_AXIS_EXPONENT_GAP : 0
const clearance = const clearance =
tickTextWidth + tickTextWidth +
Y_AXIS_TICK_PADDING + Y_AXIS_TICK_PADDING +
exponentClearance +
Y_AXIS_LABEL_GAP + Y_AXIS_LABEL_GAP +
Y_AXIS_LABEL_BAND_WIDTH + Y_AXIS_LABEL_BAND_WIDTH +
Y_AXIS_OUTER_PADDING Y_AXIS_OUTER_PADDING
const x = group.side === 'left' ? -sideOffsets.left : cell.width + sideOffsets.right const x = group.side === 'left' ? -sideOffsets.left : cell.width + sideOffsets.right
const exponentX =
x +
(group.side === 'left'
? -(Y_AXIS_TICK_PADDING + tickTextWidth + Y_AXIS_EXPONENT_GAP)
: Y_AXIS_TICK_PADDING + tickTextWidth + Y_AXIS_EXPONENT_GAP)
const labelDistance = const labelDistance =
tickTextWidth + tickTextWidth + Y_AXIS_TICK_PADDING + Y_AXIS_LABEL_GAP + Y_AXIS_LABEL_BAND_WIDTH / 2
Y_AXIS_TICK_PADDING +
exponentClearance +
exponentWidth +
Y_AXIS_LABEL_GAP +
Y_AXIS_LABEL_BAND_WIDTH / 2
const labelX = x + (group.side === 'left' ? -labelDistance : labelDistance) const labelX = x + (group.side === 'left' ? -labelDistance : labelDistance)
sideOffsets[group.side] += clearance sideOffsets[group.side] += clearance
return { return {
@@ -150,8 +140,6 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou
side: group.side, side: group.side,
x, x,
labelX, labelX,
exponentX,
exponentLabel,
scale, scale,
majorTicks, majorTicks,
minorTicks: buildMinorTicks(majorTicks), minorTicks: buildMinorTicks(majorTicks),
@@ -165,10 +153,21 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou
const yAxisTickValues = yAxes[0]?.tickValues ?? [] const yAxisTickValues = yAxes[0]?.tickValues ?? []
const domain = xScale.domain() as [number, number] const domain = xScale.domain() as [number, number]
const endpointLabels = { const endpointLabels = {
start: formatEndpointTime(domain[0], domain, options.timeUnit), start: formatXAxisLabel(
end: formatEndpointTime(domain[1], domain, options.timeUnit), domain[0],
domain,
options.timeUnit,
'start',
options.xAxisLabelFormatter,
),
end: formatXAxisLabel(
domain[1],
domain,
options.timeUnit,
'end',
options.xAxisLabelFormatter,
),
} }
const xAxisExponent = formatAxisTimeExponent(domain, options.timeUnit)
const leftClearance = endpointLabels.start.length * 7 + 10 const leftClearance = endpointLabels.start.length * 7 + 10
const rightClearance = endpointLabels.end.length * 7 + 10 const rightClearance = endpointLabels.end.length * 7 + 10
const xAxisTickValues = xMajorTicks.filter((tick) => { const xAxisTickValues = xMajorTicks.filter((tick) => {
@@ -235,7 +234,6 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou
yAxisTickValues, yAxisTickValues,
xAxisTickValues, xAxisTickValues,
endpointLabels, endpointLabels,
xAxisExponent,
path: seriesPaths[0]?.path ?? null, path: seriesPaths[0]?.path ?? null,
seriesPaths, seriesPaths,
gridLines: options.grid.trackLines[displayTrack.id] ?? { gridLines: options.grid.trackLines[displayTrack.id] ?? {

View File

@@ -51,8 +51,6 @@ export interface WaveformYAxisLayout {
side: 'left' | 'right' side: 'left' | 'right'
x: number x: number
labelX: number labelX: number
exponentX: number
exponentLabel: string | null
scale: ScaleLinear<number, number> scale: ScaleLinear<number, number>
majorTicks: number[] majorTicks: number[]
minorTicks: number[] minorTicks: number[]
@@ -109,7 +107,6 @@ export interface TrackLayout {
yAxisTickValues: number[] yAxisTickValues: number[]
xAxisTickValues: number[] xAxisTickValues: number[]
endpointLabels: { start: string; end: string } endpointLabels: { start: string; end: string }
xAxisExponent: string | null
path: string | null path: string | null
seriesPaths: TrackSeriesPath[] seriesPaths: TrackSeriesPath[]
showXAxis: boolean showXAxis: boolean

View File

@@ -18,6 +18,7 @@ import { useWaveformChartAnnotations } from '../annotation/useWaveformChartAnnot
import { useWaveformHover } from '../interaction/useWaveformHover' import { useWaveformHover } from '../interaction/useWaveformHover'
import { useWaveformViewport } from '../interaction/useWaveformViewport' import { useWaveformViewport } from '../interaction/useWaveformViewport'
import { useWaveformZoom } from '../interaction/useWaveformZoom' import { useWaveformZoom } from '../interaction/useWaveformZoom'
import { ViewportInteractionStateMachine } from '../interaction/viewportInteractionState'
import { useAnimationFrameThrottle } from '../utils/useAnimationFrameThrottle' import { useAnimationFrameThrottle } from '../utils/useAnimationFrameThrottle'
import { margin } from './constants' import { margin } from './constants'
import { getPageSize } from './grid' import { getPageSize } from './grid'
@@ -78,7 +79,8 @@ export function useWaveformChartController(
}, },
{ deep: true }, { deep: true },
) )
const selection = ref<ViewportSelectionState | null>(null) const selection = shallowRef<ViewportSelectionState | null>(null)
const viewportInteraction = shallowRef(markRaw(new ViewportInteractionStateMachine()))
const spacePressed = ref(false) const spacePressed = ref(false)
const pointerInsideChart = ref(false) const pointerInsideChart = ref(false)
let handleDataReferenceChange: () => void = () => undefined let handleDataReferenceChange: () => void = () => undefined
@@ -193,6 +195,7 @@ export function useWaveformChartController(
props, props,
emit, emit,
selection, selection,
viewportInteraction,
spacePressed, spacePressed,
trackLayouts, trackLayouts,
chartTracks, chartTracks,

View File

@@ -2,7 +2,7 @@ import { scaleLinear, type ZoomTransform } from 'd3'
import { computed, type ComputedRef, type Ref, type ShallowRef } from 'vue' import { computed, type ComputedRef, type Ref, type ShallowRef } from 'vue'
import { resolveWaveformRenderingOptions } from '../../core' import { resolveWaveformRenderingOptions } from '../../core'
import { formatScientificAxisExponent, formatScientificAxisLabel, paddedDomain } from '../../utils' import { paddedDomain } from '../../utils'
import { import {
layoutAnnotations, layoutAnnotations,
type AnnotationSeriesInfo, type AnnotationSeriesInfo,
@@ -24,13 +24,12 @@ import {
normalizeGridOptions, normalizeGridOptions,
paginateSeries, paginateSeries,
resolveGridCellGeometry, resolveGridCellGeometry,
X_AXIS_BAND,
} from './grid' } from './grid'
import { import {
buildTrackLayouts, buildTrackLayouts,
axisTextMetrics,
measureTrackYAxisClearance, measureTrackYAxisClearance,
resolveYAxisSeriesGroups, resolveYAxisSeriesGroups,
Y_AXIS_EXPONENT_GAP,
} from './layout' } from './layout'
import type { DisplaySeries, DisplayTrack, TrackLayout } from './types' import type { DisplaySeries, DisplayTrack, TrackLayout } from './types'
import type { PreparedWaveformSeries } from './useWaveformData' import type { PreparedWaveformSeries } from './useWaveformData'
@@ -112,40 +111,18 @@ export function useWaveformLayout(context: LayoutContext) {
.flatMap((track) => .flatMap((track) =>
resolveYAxisSeriesGroups(track, props.overlayMode, props.yDomain, props.yDomains), resolveYAxisSeriesGroups(track, props.overlayMode, props.yDomain, props.yDomains),
) )
.map((group) => { .map((group) => axisTextMetrics(group.domain, !group.fixed).tickTextWidth)
const scale = scaleLinear(group.domain, [1, 0]) const tickTextWidth = Math.max(Y_AXIS_CHARACTER_WIDTH, ...axisText)
if (!group.fixed) scale.nice() const tickClearance = tickTextWidth + Y_AXIS_TICK_PADDING + Y_AXIS_OUTER_PADDING
const [axisMin, axisMax] = scale.domain()
return {
exponentLabel: formatScientificAxisExponent(axisMin, axisMax),
tickLabels: scale
.ticks(10)
.map((value) => formatScientificAxisLabel(value, { axisMin, axisMax })),
}
})
const maximumCharacters = Math.max(
1,
...axisText.flatMap(({ tickLabels }) => tickLabels).map((label) => label.length),
)
const tickTextWidth = maximumCharacters * Y_AXIS_CHARACTER_WIDTH
const maximumExponentWidth = Math.max(
0,
...axisText.map(({ exponentLabel }) => (exponentLabel?.length ?? 0) * Y_AXIS_CHARACTER_WIDTH),
)
const exponentClearance = maximumExponentWidth ? maximumExponentWidth + Y_AXIS_EXPONENT_GAP : 0
const tickClearance =
tickTextWidth + Y_AXIS_TICK_PADDING + exponentClearance + Y_AXIS_OUTER_PADDING
const labelCenterX = -( const labelCenterX = -(
Y_AXIS_TICK_PADDING + Y_AXIS_TICK_PADDING +
tickTextWidth + tickTextWidth +
exponentClearance +
Y_AXIS_LABEL_GAP + Y_AXIS_LABEL_GAP +
Y_AXIS_LABEL_BAND_WIDTH / 2 Y_AXIS_LABEL_BAND_WIDTH / 2
) )
const fullClearance = const fullClearance =
tickTextWidth + tickTextWidth +
Y_AXIS_TICK_PADDING + Y_AXIS_TICK_PADDING +
exponentClearance +
Y_AXIS_LABEL_GAP + Y_AXIS_LABEL_GAP +
Y_AXIS_LABEL_BAND_WIDTH + Y_AXIS_LABEL_BAND_WIDTH +
Y_AXIS_OUTER_PADDING Y_AXIS_OUTER_PADDING
@@ -307,6 +284,7 @@ export function useWaveformLayout(context: LayoutContext) {
) )
: sharedYDomains.value, : sharedYDomains.value,
timeUnit: props.timeUnit, timeUnit: props.timeUnit,
xAxisLabelFormatter: props.axes?.x?.labelFormatter,
rendering: renderingOptions.value, rendering: renderingOptions.value,
hideSecondaryLabels: isCleanView.value || yAxisLayout.value.hideSecondaryLabels, hideSecondaryLabels: isCleanView.value || yAxisLayout.value.hideSecondaryLabels,
yAxisLabelX: yAxisMetrics.value.labelCenterX, yAxisLabelX: yAxisMetrics.value.labelCenterX,
@@ -337,7 +315,6 @@ export function useWaveformLayout(context: LayoutContext) {
) )
: [], : [],
) )
const xAxisTitleY = computed(() => innerHeight.value + X_AXIS_BAND + 10)
const editorSeries = computed<AnnotationSeriesInfo | undefined>(() => { const editorSeries = computed<AnnotationSeriesInfo | undefined>(() => {
const seriesId = annotationInteraction.editorDraft.value?.annotation.seriesId const seriesId = annotationInteraction.editorDraft.value?.annotation.seriesId
const series = chartSeries.value.find((item) => item.id === seriesId) const series = chartSeries.value.find((item) => item.id === seriesId)
@@ -381,7 +358,6 @@ export function useWaveformLayout(context: LayoutContext) {
resolveSeriesYScale, resolveSeriesYScale,
annotationTrackLayouts, annotationTrackLayouts,
renderedAnnotations, renderedAnnotations,
xAxisTitleY,
editorSeries, editorSeries,
resolveFrameNumber, resolveFrameNumber,
} }

View File

@@ -7,6 +7,7 @@ import {
TITLE_CHAR_WIDTH_RATIO, TITLE_CHAR_WIDTH_RATIO,
TITLE_DEFAULT_FONT_SIZE, TITLE_DEFAULT_FONT_SIZE,
TITLE_LINE_HEIGHT, TITLE_LINE_HEIGHT,
X_AXIS_TITLE_BOTTOM_OFFSET,
ZERO_LINE_DEFAULTS, ZERO_LINE_DEFAULTS,
} from './constants' } from './constants'
import { calculateRotatedTitleLayout, TITLE_AREA_HORIZONTAL_PADDING } from './title' import { calculateRotatedTitleLayout, TITLE_AREA_HORIZONTAL_PADDING } from './title'
@@ -144,11 +145,23 @@ export function useWaveformPresentation(context: PresentationContext) {
const titleAreaHeight = computed(() => const titleAreaHeight = computed(() =>
titleAreaReserved.value ? titleLayout.value.areaHeight : 0, titleAreaReserved.value ? titleLayout.value.areaHeight : 0,
) )
const chartTopMargin = computed(() => margin.top) const resolvePlotMargin = (value: number | undefined, fallback: number) =>
typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : fallback
const resolvedPlotMargin = computed(() => ({
top: resolvePlotMargin(props.plotMargin.top, margin.top),
bottom: resolvePlotMargin(props.plotMargin.bottom, margin.bottom),
}))
const chartTopMargin = computed(() => resolvedPlotMargin.value.top)
const drawingHeight = computed(() => const drawingHeight = computed(() =>
Math.max(0, chartHeight.value - titleAreaHeight.value - paginationBandHeight.value), Math.max(0, chartHeight.value - titleAreaHeight.value - paginationBandHeight.value),
) )
const innerHeight = computed(() => Math.max(0, drawingHeight.value - margin.top - margin.bottom)) const xAxisTitleY = computed(() => Math.max(0, drawingHeight.value - X_AXIS_TITLE_BOTTOM_OFFSET))
const innerHeight = computed(() =>
Math.max(
0,
drawingHeight.value - resolvedPlotMargin.value.top - resolvedPlotMargin.value.bottom,
),
)
const titleAreaStyle = computed<CSSProperties>(() => ({ const titleAreaStyle = computed<CSSProperties>(() => ({
height: `${titleAreaHeight.value}px`, height: `${titleAreaHeight.value}px`,
justifyContent: justifyContent:
@@ -193,8 +206,10 @@ export function useWaveformPresentation(context: PresentationContext) {
titleMeasureStyle, titleMeasureStyle,
titleLayout, titleLayout,
titleAreaHeight, titleAreaHeight,
resolvedPlotMargin,
chartTopMargin, chartTopMargin,
drawingHeight, drawingHeight,
xAxisTitleY,
innerHeight, innerHeight,
titleAreaStyle, titleAreaStyle,
titleVisualStyle, titleVisualStyle,

View File

@@ -7,6 +7,7 @@ import type {
WaveformInteractionMode, WaveformInteractionMode,
WaveformLegendOptions, WaveformLegendOptions,
WaveformOverlayMode, WaveformOverlayMode,
WaveformPlotMargin,
WaveformPoint, WaveformPoint,
WaveformRenderingOptions, WaveformRenderingOptions,
WaveformTitleOptions, WaveformTitleOptions,
@@ -42,6 +43,7 @@ export interface WaveformChartProps {
interactionMode?: WaveformInteractionMode interactionMode?: WaveformInteractionMode
grid?: WaveformGridOptions grid?: WaveformGridOptions
rendering?: WaveformRenderingOptions rendering?: WaveformRenderingOptions
plotMargin?: WaveformPlotMargin
title?: WaveformTitleOptions title?: WaveformTitleOptions
legend?: WaveformLegendOptions legend?: WaveformLegendOptions
hiddenSeriesIds?: string[] hiddenSeriesIds?: string[]
@@ -65,6 +67,7 @@ type DefaultedProp =
| 'annotationsVisible' | 'annotationsVisible'
| 'grid' | 'grid'
| 'rendering' | 'rendering'
| 'plotMargin'
| 'legend' | 'legend'
| 'defaultHiddenSeriesIds' | 'defaultHiddenSeriesIds'
| 'cleanView' | 'cleanView'
@@ -92,16 +95,17 @@ export interface WaveformChartEmit {
(event: 'page-change', page: number, pageCount: number): void (event: 'page-change', page: number, pageCount: number): void
} }
export interface ViewportSelectionState { interface ViewportSelectionBase {
trackIndex: number trackIndex: number
independent: boolean independent: boolean
overlay: SVGRectElement
startX: number startX: number
startY: number startY: number
currentX: number currentX: number
currentY: number currentY: number
pointerId: number pointerId: number
mode: 'box' | 'pan'
xDomain: [number, number] xDomain: [number, number]
yDomains: Record<string, [number, number]> yDomains: Record<string, [number, number]>
} }
export type ViewportSelectionState =
(ViewportSelectionBase & { kind: 'box' }) | (ViewportSelectionBase & { kind: 'pan' })

View File

@@ -13,6 +13,7 @@ export type {
WaveformAnnotationStyle, WaveformAnnotationStyle,
WaveformAnnotation, WaveformAnnotation,
WaveformRenderingOptions, WaveformRenderingOptions,
WaveformPlotMargin,
WaveformTitleTextStyle, WaveformTitleTextStyle,
WaveformTitleOptions, WaveformTitleOptions,
WaveformLegendPosition, WaveformLegendPosition,

View File

@@ -11,6 +11,7 @@ export type {
WaveformAnnotationStyle, WaveformAnnotationStyle,
WaveformAnnotation, WaveformAnnotation,
WaveformRenderingOptions, WaveformRenderingOptions,
WaveformPlotMargin,
WaveformTitleTextStyle, WaveformTitleTextStyle,
WaveformTitleOptions, WaveformTitleOptions,
WaveformLegendPosition, WaveformLegendPosition,

View File

@@ -91,6 +91,22 @@ describe('WaveformTooltip', () => {
expect(wrapper.get('.waveform-tooltip__value').text()).toBe('-1,405.4932 A') expect(wrapper.get('.waveform-tooltip__value').text()).toBe('-1,405.4932 A')
}) })
it('formats tooltip time with at most four decimal places', () => {
const wrapper = mount(WaveformTooltip, {
props: {
visible: true,
position: { x: 10, y: 10 },
timeUnit: 's',
hoveredPoint: { x: 1.234567, y: 12 },
seriesPoints: [],
containerWidth: 400,
containerHeight: 300,
},
})
expect(wrapper.get('.waveform-tooltip__time').text()).toBe('s: 1.2346')
})
it('omits the error label when both resolved errors are zero', () => { it('omits the error label when both resolved errors are zero', () => {
const point = { x: 1, y: 12 } const point = { x: 1, y: 12 }
const wrapper = mount(WaveformTooltip, { const wrapper = mount(WaveformTooltip, {

View File

@@ -136,7 +136,7 @@ export function useWaveformHover(context: HoverContext) {
} }
const handleSharedPointerMove = (event: PointerEvent) => { const handleSharedPointerMove = (event: PointerEvent) => {
if (isPresentationMode.value) return if (isPresentationMode.value) return
if (selection.value?.overlay === event.currentTarget) { if (selection.value && !selection.value.independent) {
updateViewportDrag(event) updateViewportDrag(event)
return return
} }

View File

@@ -1,5 +1,5 @@
import { pointer, scaleLinear, zoomIdentity, type ZoomTransform } from 'd3' import { pointer, scaleLinear, zoomIdentity, type ZoomTransform } from 'd3'
import { computed, nextTick, type ComputedRef, type Ref, type ShallowRef } from 'vue' import { computed, nextTick, shallowRef, type ComputedRef, type Ref, type ShallowRef } from 'vue'
import { MINIMUM_SELECTION_SIZE } from '../core/constants' import { MINIMUM_SELECTION_SIZE } from '../core/constants'
import type { DisplayTrack, TrackLayout } from '../core/types' import type { DisplayTrack, TrackLayout } from '../core/types'
@@ -10,11 +10,13 @@ import type {
WaveformChartEmit, WaveformChartEmit,
} from '../core/waveformChartTypes' } from '../core/waveformChartTypes'
import type { AnnotationSeriesCandidate } from '../annotation' import type { AnnotationSeriesCandidate } from '../annotation'
import type { ViewportInteractionStateMachine } from './viewportInteractionState'
interface ViewportContext { interface ViewportContext {
props: ResolvedWaveformChartProps props: ResolvedWaveformChartProps
emit: WaveformChartEmit emit: WaveformChartEmit
selection: Ref<ViewportSelectionState | null> selection: Ref<ViewportSelectionState | null>
viewportInteraction: ShallowRef<ViewportInteractionStateMachine>
spacePressed: Ref<boolean> spacePressed: Ref<boolean>
trackLayouts: ComputedRef<TrackLayout[]> trackLayouts: ComputedRef<TrackLayout[]>
chartTracks: ComputedRef<DisplayTrack[]> chartTracks: ComputedRef<DisplayTrack[]>
@@ -43,6 +45,7 @@ export function useWaveformViewport(context: ViewportContext) {
props, props,
emit, emit,
selection, selection,
viewportInteraction,
spacePressed, spacePressed,
trackLayouts, trackLayouts,
chartTracks, chartTracks,
@@ -65,6 +68,10 @@ export function useWaveformViewport(context: ViewportContext) {
clearHover, clearHover,
resolveTrackAtPointer, resolveTrackAtPointer,
} = context } = context
const activeOverlay = shallowRef<SVGRectElement>()
const syncSelection = () => {
selection.value = viewportInteraction.value.state
}
const selectionBox = computed(() => { const selectionBox = computed(() => {
const active = selection.value const active = selection.value
if (!active) return null if (!active) return null
@@ -143,19 +150,19 @@ export function useWaveformViewport(context: ViewportContext) {
const [rawX, rawY] = pointer(event, overlay) const [rawX, rawY] = pointer(event, overlay)
const x = Math.max(0, Math.min(independent ? track.width : innerWidth.value, rawX)) const x = Math.max(0, Math.min(independent ? track.width : innerWidth.value, rawX))
const y = Math.max(0, Math.min(independent ? track.height : innerHeight.value, rawY)) const y = Math.max(0, Math.min(independent ? track.height : innerHeight.value, rawY))
selection.value = { const started = viewportInteraction.value.begin({
trackIndex, trackIndex,
independent, independent,
overlay,
startX: x, startX: x,
startY: y, startY: y,
currentX: x,
currentY: y,
pointerId: event.pointerId, pointerId: event.pointerId,
mode: panRequested ? 'pan' : 'box', kind: panRequested ? 'pan' : 'box',
xDomain: track.xScale.domain() as [number, number], xDomain: track.xScale.domain() as [number, number],
yDomains: currentYDomains(), yDomains: currentYDomains(),
} })
if (!started) return
activeOverlay.value = overlay
syncSelection()
overlay.setPointerCapture?.(event.pointerId) overlay.setPointerCapture?.(event.pointerId)
clearHover() clearHover()
event.preventDefault() event.preventDefault()
@@ -213,27 +220,33 @@ export function useWaveformViewport(context: ViewportContext) {
const updateViewportDrag = (event: PointerEvent) => { const updateViewportDrag = (event: PointerEvent) => {
if (isPresentationMode.value) return if (isPresentationMode.value) return
const active = selection.value const active = selection.value
if (!active || event.pointerId !== active.pointerId) return const overlay = activeOverlay.value
if (!active || !overlay || event.pointerId !== active.pointerId) return
const track = trackLayouts.value.find((item) => item.index === active.trackIndex) const track = trackLayouts.value.find((item) => item.index === active.trackIndex)
if (!track) return if (!track) return
const [rawX, rawY] = pointer(event, active.overlay) const [rawX, rawY] = pointer(event, overlay)
active.currentX = Math.max( const currentX = Math.max(
0, 0,
Math.min(active.independent ? track.width : innerWidth.value, rawX), Math.min(active.independent ? track.width : innerWidth.value, rawX),
) )
active.currentY = Math.max( const currentY = Math.max(
0, 0,
Math.min(active.independent ? track.height : innerHeight.value, rawY), Math.min(active.independent ? track.height : innerHeight.value, rawY),
) )
selection.value = { ...active } const next = viewportInteraction.value.move(event.pointerId, { currentX, currentY })
if (active.mode === 'pan') applyPan(active, track) if (!next) return
selection.value = next
if (next.kind === 'pan') applyPan(next, track)
event.preventDefault() event.preventDefault()
} }
const cancelViewportDrag = (event?: PointerEvent) => { const cancelViewportDrag = (event?: PointerEvent) => {
const active = selection.value const active = selection.value
if (!active || (event && event.pointerId !== active.pointerId)) return if (!active || (event && event.pointerId !== active.pointerId)) return
active.overlay.releasePointerCapture?.(active.pointerId) activeOverlay.value?.releasePointerCapture?.(active.pointerId)
selection.value = null if (viewportInteraction.value.cancel(event?.pointerId)) {
activeOverlay.value = undefined
syncSelection()
}
} }
const applyBoxZoom = (active: ViewportSelectionState) => { const applyBoxZoom = (active: ViewportSelectionState) => {
const track = trackLayouts.value.find((item) => item.index === active.trackIndex) const track = trackLayouts.value.find((item) => item.index === active.trackIndex)
@@ -300,15 +313,32 @@ export function useWaveformViewport(context: ViewportContext) {
} }
const active = selection.value const active = selection.value
if (!active || event.pointerId !== active.pointerId) return if (!active || event.pointerId !== active.pointerId) return
updateViewportDrag(event) const track = trackLayouts.value.find((item) => item.index === active.trackIndex)
active.overlay.releasePointerCapture?.(active.pointerId) if (!track) return
selection.value = null const overlay = activeOverlay.value
if (active.mode === 'pan') { if (!overlay) return
const [rawX, rawY] = pointer(event, overlay)
const currentX = Math.max(
0,
Math.min(active.independent ? track.width : innerWidth.value, rawX),
)
const currentY = Math.max(
0,
Math.min(active.independent ? track.height : innerHeight.value, rawY),
)
const completed = viewportInteraction.value.finish(event.pointerId, { currentX, currentY })
if (!completed) return
overlay.releasePointerCapture?.(completed.pointerId)
activeOverlay.value = undefined
syncSelection()
event.preventDefault()
if (completed.kind === 'pan') {
applyPan(completed, track)
void nextTick(configureZoom) void nextTick(configureZoom)
return return
} }
if (Math.abs(active.currentX - active.startX) >= MINIMUM_SELECTION_SIZE) { if (Math.abs(completed.currentX - completed.startX) >= MINIMUM_SELECTION_SIZE) {
applyBoxZoom(active) applyBoxZoom(completed)
} }
} }
const resetViewport = (trackIndex?: number) => { const resetViewport = (trackIndex?: number) => {

View File

@@ -0,0 +1,76 @@
import { describe, expect, it } from 'vitest'
import { ViewportInteractionStateMachine } from './viewportInteractionState'
const gesture = {
trackIndex: 2,
independent: true,
startX: 10,
startY: 20,
pointerId: 7,
kind: 'box' as const,
xDomain: [0, 100] as [number, number],
yDomains: { track: [-1, 1] as [number, number] },
}
describe('ViewportInteractionStateMachine', () => {
it('accepts one gesture and rejects a conflicting start', () => {
const machine = new ViewportInteractionStateMachine()
expect(machine.begin(gesture)).toBe(true)
expect(machine.begin({ ...gesture, pointerId: 8 })).toBe(false)
expect(machine.state).toMatchObject({ kind: 'box', pointerId: 7 })
})
it('rejects moves and completion from a different pointer', () => {
const machine = new ViewportInteractionStateMachine()
machine.begin(gesture)
expect(machine.move(8, { currentX: 30, currentY: 40 })).toBeNull()
expect(machine.finish(8, { currentX: 30, currentY: 40 })).toBeNull()
expect(machine.state?.currentX).toBe(10)
})
it('updates a valid pointer, completes it, and returns to idle', () => {
const machine = new ViewportInteractionStateMachine()
machine.begin({ ...gesture, kind: 'pan' })
expect(machine.move(7, { currentX: 30, currentY: 40 })).toMatchObject({
kind: 'pan',
currentX: 30,
currentY: 40,
})
expect(machine.finish(7, { currentX: 50, currentY: 60 })).toMatchObject({
kind: 'pan',
currentX: 50,
currentY: 60,
})
expect(machine.state).toBeNull()
})
it('only cancels the owning pointer and supports explicit reset', () => {
const machine = new ViewportInteractionStateMachine()
machine.begin(gesture)
expect(machine.cancel(8)).toBe(false)
expect(machine.state).not.toBeNull()
expect(machine.cancel(7)).toBe(true)
expect(machine.state).toBeNull()
machine.begin(gesture)
machine.reset()
expect(machine.state).toBeNull()
})
it('returns defensive copies from its state getter', () => {
const machine = new ViewportInteractionStateMachine()
machine.begin(gesture)
const snapshot = machine.state!
snapshot.currentX = 99
snapshot.xDomain[0] = 50
snapshot.yDomains.track![0] = 50
expect(machine.state).toMatchObject({ currentX: 10, xDomain: [0, 100] })
expect(machine.state?.yDomains.track).toEqual([-1, 1])
})
})

View File

@@ -0,0 +1,80 @@
import type { ViewportSelectionState } from '../core/waveformChartTypes'
export interface ViewportGestureStart {
trackIndex: number
independent: boolean
startX: number
startY: number
pointerId: number
kind: 'box' | 'pan'
xDomain: [number, number]
yDomains: Record<string, [number, number]>
}
export interface ViewportGesturePosition {
currentX: number
currentY: number
}
function cloneState(state: ViewportSelectionState | null): ViewportSelectionState | null {
if (!state) return null
return {
...state,
xDomain: [...state.xDomain],
yDomains: Object.fromEntries(
Object.entries(state.yDomains).map(([key, domain]) => [key, [...domain] as [number, number]]),
),
}
}
/** Owns the legal lifecycle of one active viewport pointer gesture. */
export class ViewportInteractionStateMachine {
private current: ViewportSelectionState | null = null
get state(): ViewportSelectionState | null {
return cloneState(this.current)
}
begin(input: ViewportGestureStart): boolean {
if (this.current) return false
this.current = {
...input,
currentX: input.startX,
currentY: input.startY,
xDomain: [...input.xDomain],
yDomains: Object.fromEntries(
Object.entries(input.yDomains).map(([key, domain]) => [
key,
[...domain] as [number, number],
]),
),
}
return true
}
move(pointerId: number, position: ViewportGesturePosition): ViewportSelectionState | null {
if (!this.current || this.current.pointerId !== pointerId) return null
this.current = { ...this.current, ...position }
return this.state
}
finish(pointerId: number, position: ViewportGesturePosition): ViewportSelectionState | null {
if (!this.current || this.current.pointerId !== pointerId) return null
this.current = { ...this.current, ...position }
const completed = this.state
this.current = null
return completed
}
cancel(pointerId?: number): boolean {
if (!this.current || (pointerId !== undefined && this.current.pointerId !== pointerId)) {
return false
}
this.current = null
return true
}
reset(): void {
this.current = null
}
}

View File

@@ -3,7 +3,7 @@ import { axisBottom, axisLeft, axisRight, select } from 'd3'
import { nextTick, onMounted, ref, watch } from 'vue' import { nextTick, onMounted, ref, watch } from 'vue'
import type { WaveformAxesOptions } from '../../types' import type { WaveformAxesOptions } from '../../types'
import { formatAxisTime, formatScientificAxisLabel } from '../../utils' import { formatScientificAxisLabel, formatXAxisLabel } from '../../utils'
import type { DisplaySeries, TrackLayout, WaveformYAxisLayout } from '../core/types' import type { DisplaySeries, TrackLayout, WaveformYAxisLayout } from '../core/types'
interface Props { interface Props {
@@ -43,8 +43,11 @@ function renderAxes() {
const element = yAxisElements.value[index] const element = yAxisElements.value[index]
if (!element) return if (!element) return
const [axisMin, axisMax] = axis.scale.domain() const [axisMin, axisMax] = axis.scale.domain()
const topTickValue = Math.max(...axis.tickValues)
const yAxis = (axis.side === 'left' ? axisLeft(axis.scale) : axisRight(axis.scale)) const yAxis = (axis.side === 'left' ? axisLeft(axis.scale) : axisRight(axis.scale))
.tickFormat((value) => formatScientificAxisLabel(Number(value), { axisMin, axisMax })) .tickFormat((value) =>
formatScientificAxisLabel(Number(value), { axisMin, axisMax, topTickValue }),
)
.tickSize(-4) .tickSize(-4)
.tickPadding(7) .tickPadding(7)
.tickSizeOuter(0) .tickSizeOuter(0)
@@ -63,10 +66,12 @@ function renderAxes() {
axisBottom(props.track.xScale) axisBottom(props.track.xScale)
.tickValues(props.track.xAxisTickValues) .tickValues(props.track.xAxisTickValues)
.tickFormat((value) => .tickFormat((value) =>
formatAxisTime( formatXAxisLabel(
Number(value), Number(value),
props.timeUnit,
props.track.xScale.domain() as [number, number], props.track.xScale.domain() as [number, number],
props.timeUnit,
'tick',
props.axes?.x?.labelFormatter,
), ),
) )
.tickSize(-4) .tickSize(-4)
@@ -90,6 +95,7 @@ watch(
() => props.track.xAxisTickValues, () => props.track.xAxisTickValues,
() => props.track.yAxisTickValues, () => props.track.yAxisTickValues,
() => props.timeUnit, () => props.timeUnit,
() => props.axes?.x?.labelFormatter,
() => props.axes?.x?.lineVisible, () => props.axes?.x?.lineVisible,
() => props.axes?.y?.lineVisible, () => props.axes?.y?.lineVisible,
], ],
@@ -136,17 +142,6 @@ watch(
{{ track.endpointLabels.end }} {{ track.endpointLabels.end }}
</text> </text>
</g> </g>
<text
v-if="track.showXAxis && track.xAxisExponent"
class="waveform-track__axis-exponent waveform-track__axis-exponent--x waveform-chart__axis-exponent waveform-chart__axis-exponent--x"
:x="track.width ?? innerWidth"
:y="track.height + 27"
text-anchor="end"
aria-hidden="true"
>
{{ track.xAxisExponent }}
</text>
<g <g
v-for="axis in track.isEmpty ? [] : track.yAxes" v-for="axis in track.isEmpty ? [] : track.yAxes"
:key="`y-axis-${track.index}-${axis.index}`" :key="`y-axis-${track.index}-${axis.index}`"
@@ -160,20 +155,6 @@ watch(
:data-y-axis-side="axis.side" :data-y-axis-side="axis.side"
:transform="`translate(${axis.x}, 0)`" :transform="`translate(${axis.x}, 0)`"
/> />
<text
v-for="axis in track.isEmpty ? [] : track.yAxes.filter((item) => item.exponentLabel)"
:key="`y-axis-exponent-${track.index}-${axis.index}`"
class="waveform-track__axis-exponent waveform-track__axis-exponent--y waveform-chart__axis-exponent waveform-chart__axis-exponent--y"
:data-y-axis-index="axis.index"
:x="axis.exponentX"
y="0"
dy="0.32em"
:text-anchor="axis.side === 'left' ? 'end' : 'start'"
aria-hidden="true"
>
{{ axis.exponentLabel }}
</text>
<g <g
v-if=" v-if="
!cleanView && !cleanView &&

View File

@@ -12,6 +12,7 @@ export type {
WaveformAnnotationStyle, WaveformAnnotationStyle,
WaveformAnnotation, WaveformAnnotation,
WaveformRenderingOptions, WaveformRenderingOptions,
WaveformPlotMargin,
WaveformTitleTextStyle, WaveformTitleTextStyle,
WaveformTitleOptions, WaveformTitleOptions,
WaveformFrameStyle, WaveformFrameStyle,

View File

@@ -72,7 +72,7 @@ describe('WaveformChart', () => {
expect(endTicks[3]).toHaveLength(0) expect(endTicks[3]).toHaveLength(0)
}) })
it('keeps one separate shared exponent for every compact Y axis', async () => { it('prefixes one shared exponent to the largest visible tick on every compact Y axis', async () => {
const wrapper = await mountSizedChart( const wrapper = await mountSizedChart(
{ {
kind: 'series', kind: 'series',
@@ -83,7 +83,7 @@ describe('WaveformChart', () => {
kind: 'points', kind: 'points',
points: [ points: [
{ x: 0, y: 0 }, { x: 0, y: 0 },
{ x: 1, y: 254 }, { x: 1, y: 2540 },
], ],
}, },
}, },
@@ -103,14 +103,14 @@ describe('WaveformChart', () => {
) )
const axes = wrapper.findAll('.waveform-chart__axis--y') const axes = wrapper.findAll('.waveform-chart__axis--y')
const exponents = wrapper.findAll('.waveform-chart__axis-exponent--y')
expect(axes).toHaveLength(2) expect(axes).toHaveLength(2)
expect(exponents.map((label) => label.text())).toEqual(['E+02', 'E-04']) axes.forEach((axis, index) => {
axes.forEach((axis) => {
const labels = axis.findAll('.tick text').map((tick) => tick.text()) const labels = axis.findAll('.tick text').map((tick) => tick.text())
expect(labels.every((label) => !label.startsWith('E'))).toBe(true) const exponentLabels = labels.filter((label) => label.startsWith('E'))
expect(labels.every((label) => /^-?\d+\.\d{2}$/.test(label))).toBe(true) expect(exponentLabels).toHaveLength(1)
expect(exponentLabels[0]).toMatch(index === 0 ? /^E\+03 / : /^E-04 /)
}) })
expect(wrapper.find('.waveform-chart__axis-exponent--y').exists()).toBe(false)
}) })
it('prefers a trimmed series name and falls back to yLabel for unnamed data', async () => { it('prefers a trimmed series name and falls back to yLabel for unnamed data', async () => {

View File

@@ -178,7 +178,7 @@ describe('WaveformChart', () => {
await flushPromises() await flushPromises()
expect(wrapper.emitted('point-hover')?.at(-1)).toEqual([{ x: 1, y: 5 }]) expect(wrapper.emitted('point-hover')?.at(-1)).toEqual([{ x: 1, y: 5 }])
expect(wrapper.find('.waveform-chart__tooltip').exists()).toBe(true) expect(wrapper.find('.waveform-chart__tooltip').exists()).toBe(true)
expect(wrapper.get('.waveform-chart__tooltip').text()).toContain('ms: 1,000.0000') expect(wrapper.get('.waveform-chart__tooltip-time').text()).toBe('ms: 1,000')
const crosshairLines = wrapper.findAll('.waveform-chart__crosshair line') const crosshairLines = wrapper.findAll('.waveform-chart__crosshair line')
expect(crosshairLines).toHaveLength(1) expect(crosshairLines).toHaveLength(1)
expect(crosshairLines[0].attributes('x1')).toBe(crosshairLines[0].attributes('x2')) expect(crosshairLines[0].attributes('x1')).toBe(crosshairLines[0].attributes('x2'))
@@ -303,7 +303,7 @@ describe('WaveformChart', () => {
flushAnimationFrames() flushAnimationFrames()
await flushPromises() await flushPromises()
expect(wrapper.get('.waveform-chart__tooltip').text()).toContain('ms: 1,000.0000') expect(wrapper.get('.waveform-chart__tooltip-time').text()).toBe('ms: 1,000')
expect(wrapper.findAll('.waveform-chart__crosshair line')).toHaveLength(1) expect(wrapper.findAll('.waveform-chart__crosshair line')).toHaveLength(1)
expect(wrapper.get('.waveform-chart__line').element).toBe(pathBeforeHover) expect(wrapper.get('.waveform-chart__line').element).toBe(pathBeforeHover)
expect(chartUpdate).not.toHaveBeenCalled() expect(chartUpdate).not.toHaveBeenCalled()

View File

@@ -62,15 +62,15 @@ describe('WaveformChart', () => {
], ],
} }
const wrapper = await mountSizedChart(firstData) const wrapper = await mountSizedChart(firstData)
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('1.00') expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('1000')
firstData.points.push({ x: 2, y: 2 }) firstData.points.push({ x: 2, y: 2 })
await flushPromises() await flushPromises()
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('1.00') expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('1000')
await wrapper.setProps({ data: { ...firstData, points: [...firstData.points] } }) await wrapper.setProps({ data: { ...firstData, points: [...firstData.points] } })
await flushPromises() await flushPromises()
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('2.00') expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('2000')
}) })
it('keeps controlled annotations when replacing the loaded data window', async () => { it('keeps controlled annotations when replacing the loaded data window', async () => {
@@ -170,7 +170,7 @@ describe('WaveformChart', () => {
expect(wrapper.findAll('.waveform-chart__track-label')).toHaveLength(0) expect(wrapper.findAll('.waveform-chart__track-label')).toHaveLength(0)
expect( expect(
wrapper.findAll('.waveform-chart__axis-endpoint--end').map((item) => item.text()), wrapper.findAll('.waveform-chart__axis-endpoint--end').map((item) => item.text()),
).toEqual(['1.00', '2.00']) ).toEqual(['1000', '2000'])
}) })
it('keeps the zero Y-axis label on upper compact tracks', async () => { it('keeps the zero Y-axis label on upper compact tracks', async () => {

View File

@@ -1,7 +1,8 @@
import { flushPromises } from '@vue/test-utils' import { flushPromises } from '@vue/test-utils'
import { describe, expect, it } from 'vitest' import { describe, expect, it, vi } from 'vitest'
import { flushAnimationFrames } from '../../test/setup' import { flushAnimationFrames } from '../../test/setup'
import type { WaveformXAxisLabelFormatter } from '../../types'
import { type WaveformData } from '../waveform' import { type WaveformData } from '../waveform'
import { gridSeries, mountSizedChart } from '../../test/waveformChart' import { gridSeries, mountSizedChart } from '../../test/waveformChart'
@@ -162,35 +163,42 @@ describe('WaveformChart', () => {
expect(wrapper.get('.waveform-chart__axis-endpoint--end').attributes('x')).toBe( expect(wrapper.get('.waveform-chart__axis-endpoint--end').attributes('x')).toBe(
String(trackWidth), String(trackWidth),
) )
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('4.99') expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('4990.3')
expect(wrapper.get('.waveform-chart__axis-exponent--x').text()).toBe('E+03') expect(wrapper.find('.waveform-chart__axis-exponent--x').exists()).toBe(false)
}) })
it('uses one shared scientific exponent only for large and tiny Y-axis domains', async () => { it('uses one shared scientific exponent only for large and tiny Y-axis domains', async () => {
const cases = [ const cases = [
{ values: [0, 50], exponent: null }, { values: [0, 50], yDomain: [0, 50] as [number, number], exponent: null },
{ values: [0, 254], exponent: 'E+02' }, { values: [1000, 3000], yDomain: [1000, 3000] as [number, number], exponent: 'E+03' },
{ values: [0, 0.0002], exponent: 'E-04' }, {
values: [0.0001, 0.0003],
yDomain: [0.0001, 0.0003] as [number, number],
exponent: 'E-04',
},
] ]
for (const { values, exponent } of cases) { for (const { values, yDomain, exponent } of cases) {
const wrapper = await mountSizedChart({ const data = {
kind: 'points', kind: 'points' as const,
points: values.map((y, x) => ({ x, y })), points: values.map((y, x) => ({ x, y })),
}) }
const originalValues = data.points.map((point) => point.y)
const wrapper = await mountSizedChart(data, { yDomain })
const labels = wrapper const labels = wrapper
.get('.waveform-chart__axis--y') .get('.waveform-chart__axis--y')
.findAll('.tick text') .findAll('.tick text')
.map((tick) => tick.text()) .map((tick) => tick.text())
const exponentLabel = wrapper.find('.waveform-chart__axis-exponent--y') const exponentLabels = labels.filter((label) => label.startsWith('E'))
if (exponent === null) { if (exponent === null) {
expect(exponentLabel.exists()).toBe(false) expect(exponentLabels).toHaveLength(0)
} else { } else {
expect(exponentLabel.text()).toBe(exponent) expect(exponentLabels).toHaveLength(1)
expect(labels.every((label) => !label.startsWith('E'))).toBe(true) expect(exponentLabels[0]).toMatch(new RegExp(`^${exponent.replace('+', '\\+')} `))
expect(labels.every((label) => /^-?\d+\.\d{2}$/.test(label))).toBe(true)
} }
expect(wrapper.find('.waveform-chart__axis-exponent--y').exists()).toBe(false)
expect(data.points.map((point) => point.y)).toEqual(originalValues)
wrapper.unmount() wrapper.unmount()
} }
@@ -212,13 +220,13 @@ describe('WaveformChart', () => {
expect(millisecondsChart.text()).toContain('时间ms') expect(millisecondsChart.text()).toContain('时间ms')
expect(millisecondTicks.length).toBeGreaterThan(0) expect(millisecondTicks.length).toBeGreaterThan(0)
expect(millisecondsChart.get('.waveform-chart__axis-endpoint--end').text()).toBe('1.00') expect(millisecondsChart.get('.waveform-chart__axis-endpoint--end').text()).toBe('1000')
expect(millisecondsChart.get('.waveform-chart__axis-exponent--x').text()).toBe('E+03') expect(millisecondsChart.find('.waveform-chart__axis-exponent--x').exists()).toBe(false)
expect(millisecondsChart.find('.waveform-chart__watermark').exists()).toBe(false) expect(millisecondsChart.find('.waveform-chart__watermark').exists()).toBe(false)
const secondsChart = await mountSizedChart(data, { timeUnit: 's', xLabel: 'Elapsed time' }) const secondsChart = await mountSizedChart(data, { timeUnit: 's', xLabel: 'Elapsed time' })
expect(secondsChart.text()).toContain('Elapsed time') expect(secondsChart.text()).toContain('Elapsed time')
expect(secondsChart.get('.waveform-chart__axis-endpoint--end').text()).toBe('1.00') expect(secondsChart.get('.waveform-chart__axis-endpoint--end').text()).toBe('1')
expect(secondsChart.find('.waveform-chart__axis-exponent--x').exists()).toBe(false) expect(secondsChart.find('.waveform-chart__axis-exponent--x').exists()).toBe(false)
}) })
@@ -237,15 +245,15 @@ describe('WaveformChart', () => {
expect(start.attributes('x')).toBe('0') expect(start.attributes('x')).toBe('0')
expect(start.attributes('text-anchor')).toBe('start') expect(start.attributes('text-anchor')).toBe('start')
expect(start.text()).toBe('0.00') expect(start.text()).toBe('0')
expect(end.attributes('x')).toBe( expect(end.attributes('x')).toBe(
wrapper.get('.waveform-chart__track').attributes('data-track-width'), wrapper.get('.waveform-chart__track').attributes('data-track-width'),
) )
expect(end.attributes('text-anchor')).toBe('end') expect(end.attributes('text-anchor')).toBe('end')
expect(end.text()).toBe('2.00') expect(end.text()).toBe('1999')
expect(middleTickLabels.length).toBeGreaterThan(0) expect(middleTickLabels.length).toBeGreaterThan(0)
expect(middleTickLabels).not.toContain('0.00') expect(middleTickLabels).not.toContain('0')
expect(wrapper.get('.waveform-chart__axis-exponent--x').text()).toBe('E+03') expect(wrapper.find('.waveform-chart__axis-exponent--x').exists()).toBe(false)
expect(wrapper.findAll('.waveform-chart__grid--major line').length).toBeGreaterThan( expect(wrapper.findAll('.waveform-chart__grid--major line').length).toBeGreaterThan(
middleTickLabels.length, middleTickLabels.length,
) )
@@ -260,6 +268,70 @@ describe('WaveformChart', () => {
expect(endpointGroup.attributes('font-size')).toBe(xAxis.attributes('font-size')) expect(endpointGroup.attributes('font-size')).toBe(xAxis.attributes('font-size'))
}) })
it('formats X-axis ticks and endpoints with display-unit values and source context', async () => {
const data: WaveformData = {
kind: 'points',
points: [
{ x: 0.125, y: 0 },
{ x: 1.875, y: 1 },
],
}
const originalPoints = data.points.map((point) => ({ ...point }))
const labelFormatter: WaveformXAxisLabelFormatter = (value, context) =>
`${context.kind}:${value / 10}`
const formatter = vi.fn(labelFormatter)
const wrapper = await mountSizedChart(data, {
timeUnit: 'ms',
axes: { x: { labelFormatter: formatter } },
})
expect(wrapper.get('.waveform-chart__axis-endpoint--start').text()).toBe('start:12.5')
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('end:187.5')
expect(
wrapper
.get('.waveform-chart__axis--x')
.findAll('.tick text')
.every((tick) => tick.text().startsWith('tick:')),
).toBe(true)
const startCall = formatter.mock.calls.find(([, context]) => context.kind === 'start')
const tickCall = formatter.mock.calls.find(([, context]) => context.kind === 'tick')
const endCall = formatter.mock.calls.find(([, context]) => context.kind === 'end')
expect(startCall).toEqual([
125,
{
kind: 'start',
rawValue: 0.125,
timeUnit: 'ms',
domain: [0.125, 1.875],
displayDomain: [125, 1875],
},
])
expect(tickCall?.[0]).toBeTypeOf('number')
expect(tickCall?.[1]).toMatchObject({
kind: 'tick',
timeUnit: 'ms',
domain: [0.125, 1.875],
displayDomain: [125, 1875],
})
expect(endCall?.[1]).toMatchObject({ kind: 'end', rawValue: 1.875 })
expect(data.points).toEqual(originalPoints)
await wrapper.setProps({
axes: { x: { labelFormatter: (value: number) => `updated:${value}` } },
})
await flushPromises()
expect(wrapper.get('.waveform-chart__axis-endpoint--start').text()).toBe('updated:125')
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('updated:1875')
expect(
wrapper
.get('.waveform-chart__axis--x')
.findAll('.tick text')
.every((tick) => tick.text().startsWith('updated:')),
).toBe(true)
expect(data.points).toEqual(originalPoints)
})
it('keeps zoom-change domains in source seconds', async () => { it('keeps zoom-change domains in source seconds', async () => {
const wrapper = await mountSizedChart({ const wrapper = await mountSizedChart({
kind: 'points', kind: 'points',
@@ -300,6 +372,8 @@ describe('WaveformChart', () => {
) )
expect(wrapper.get('.waveform-chart__axis-endpoint--start').text()).not.toBe(initialStart) expect(wrapper.get('.waveform-chart__axis-endpoint--start').text()).not.toBe(initialStart)
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).not.toBe(initialEnd) expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).not.toBe(initialEnd)
expect(wrapper.get('.waveform-chart__axis-endpoint--start').text()).toContain('.') expect(
Number.isFinite(Number(wrapper.get('.waveform-chart__axis-endpoint--start').text())),
).toBe(true)
}) })
}) })

View File

@@ -59,7 +59,7 @@ describe('WaveformChart', () => {
await flushPromises() await flushPromises()
expect(wrapper.findAll('.waveform-chart__axis--x')).toHaveLength(1) expect(wrapper.findAll('.waveform-chart__axis--x')).toHaveLength(1)
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('2.00') expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('2000')
}) })
it('updates rendering props and disables zoom interaction', async () => { it('updates rendering props and disables zoom interaction', async () => {

View File

@@ -33,10 +33,10 @@ describe('WaveformChart', () => {
tracks[0].get('.waveform-chart__y-axis-label-bg').attributes('x'), tracks[0].get('.waveform-chart__y-axis-label-bg').attributes('x'),
) )
expect(labelX).toBe(-103) expect(labelX).toBe(-74)
expect(labelBackgroundX).toBe(labelX - 12) expect(labelBackgroundX).toBe(labelX - 12)
expect(Number(wrapper.attributes('data-chart-left-margin'))).toBe(119) expect(Number(wrapper.attributes('data-chart-left-margin'))).toBe(90)
expect(secondLeft - firstWidth).toBeGreaterThanOrEqual(119) expect(secondLeft - firstWidth).toBeGreaterThanOrEqual(90)
}) })
it('keeps a tick-only gutter when channel labels are empty', async () => { it('keeps a tick-only gutter when channel labels are empty', async () => {
@@ -62,8 +62,8 @@ describe('WaveformChart', () => {
const secondLeft = Number(tracks[1].attributes('data-track-left')) const secondLeft = Number(tracks[1].attributes('data-track-left'))
expect(wrapper.findAll('.waveform-chart__y-axis-label')).toHaveLength(0) expect(wrapper.findAll('.waveform-chart__y-axis-label')).toHaveLength(0)
expect(Number(wrapper.attributes('data-chart-left-margin'))).toBe(89) expect(Number(wrapper.attributes('data-chart-left-margin'))).toBe(64)
expect(secondLeft - firstWidth).toBeGreaterThanOrEqual(89) expect(secondLeft - firstWidth).toBeGreaterThanOrEqual(60)
}) })
it('keeps the Y-axis label gutter stable while paging between value ranges', async () => { it('keeps the Y-axis label gutter stable while paging between value ranges', async () => {
@@ -122,7 +122,7 @@ describe('WaveformChart', () => {
const tracks = wrapper.findAll('.waveform-chart__track') const tracks = wrapper.findAll('.waveform-chart__track')
const firstWidth = Number(tracks[0].attributes('data-track-width')) const firstWidth = Number(tracks[0].attributes('data-track-width'))
const secondLeft = Number(tracks[1].attributes('data-track-left')) const secondLeft = Number(tracks[1].attributes('data-track-left'))
expect(secondLeft - firstWidth).toBeGreaterThanOrEqual(39) expect(secondLeft - firstWidth).toBeGreaterThanOrEqual(32)
}) })
it('uses one shared overlay and bottom-row x axes for separated and compact grids', async () => { it('uses one shared overlay and bottom-row x axes for separated and compact grids', async () => {
@@ -262,10 +262,10 @@ describe('WaveformChart', () => {
) )
const tracks = wrapper.findAll('.waveform-chart__track') const tracks = wrapper.findAll('.waveform-chart__track')
expect(tracks[0]?.get('.waveform-chart__axis-endpoint--start').text()).toBe('-8.00') expect(tracks[0]?.get('.waveform-chart__axis-endpoint--start').text()).toBe('-8000')
expect(tracks[0]?.get('.waveform-chart__axis-endpoint--end').text()).toBe('5.00') expect(tracks[0]?.get('.waveform-chart__axis-endpoint--end').text()).toBe('5000')
expect(tracks[1]?.get('.waveform-chart__axis-endpoint--start').text()).toBe('-8.00') expect(tracks[1]?.get('.waveform-chart__axis-endpoint--start').text()).toBe('-8000')
expect(tracks[1]?.get('.waveform-chart__axis-endpoint--end').text()).toBe('5.00') expect(tracks[1]?.get('.waveform-chart__axis-endpoint--end').text()).toBe('5000')
}) })
it('uses an explicit initial x domain override for an independent track', async () => { it('uses an explicit initial x domain override for an independent track', async () => {
@@ -293,7 +293,7 @@ describe('WaveformChart', () => {
}, },
) )
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('1.00') expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('1000')
}) })
it('reacts to exact fixed Y-domain props and returns to automatic bounds', async () => { it('reacts to exact fixed Y-domain props and returns to automatic bounds', async () => {
@@ -312,13 +312,13 @@ describe('WaveformChart', () => {
await wrapper.setProps({ yDomain: [3, 97] }) await wrapper.setProps({ yDomain: [3, 97] })
await flushPromises() await flushPromises()
expect(yTickLabels()).toContain('3.00') expect(yTickLabels()).toContain('3')
expect(yTickLabels()).toContain('97.00') expect(yTickLabels()).toContain('97')
await wrapper.setProps({ yDomain: undefined }) await wrapper.setProps({ yDomain: undefined })
await flushPromises() await flushPromises()
expect(yTickLabels()).not.toContain('3.00') expect(yTickLabels()).not.toContain('3')
expect(yTickLabels()).not.toContain('97.00') expect(yTickLabels()).not.toContain('97')
}) })
it('keeps annotations bound to their channel while paging', async () => { it('keeps annotations bound to their channel while paging', async () => {

View File

@@ -30,7 +30,7 @@ describe('WaveformChart', () => {
kind: 'points', kind: 'points',
points: [ points: [
{ x: 0, y: 0 }, { x: 0, y: 0 },
{ x: 1, y: 254 }, { x: 1, y: 2540 },
], ],
}, },
}, },
@@ -51,13 +51,13 @@ describe('WaveformChart', () => {
) )
expect( expect(
wrapper wrapper.findAll('.waveform-chart__axis--y').map((axis) =>
.findAll('.waveform-chart__axis-exponent--y') axis
.map((label) => [label.attributes('data-y-axis-index'), label.text()]), .findAll('.tick text')
).toEqual([ .map((label) => label.text())
['1', 'E+02'], .filter((label) => label.startsWith('E')),
['2', 'E-04'], ),
]) ).toEqual([[], [expect.stringMatching(/^E\+03 /)], [expect.stringMatching(/^E-04 /)]])
}) })
it('reprojects annotations with the Y axis assigned to their series', async () => { it('reprojects annotations with the Y axis assigned to their series', async () => {

View File

@@ -54,8 +54,9 @@ describe('WaveformChart', () => {
], ],
}) })
expect(first.find('.waveform-chart__axis-exponent--y').exists()).toBe(false) expect(first.get('.waveform-chart__axis--y').text()).not.toContain('E')
expect(second.get('.waveform-chart__axis-exponent--y').text()).toBe('E+04') expect(second.get('.waveform-chart__axis--y').text()).toContain('E+04 ')
expect(second.find('.waveform-chart__axis-exponent--y').exists()).toBe(false)
}) })
it('renders a configurable zero line only when the Y domain contains zero', async () => { it('renders a configurable zero line only when the Y domain contains zero', async () => {
@@ -299,8 +300,9 @@ describe('WaveformChart', () => {
}) })
expect(xAxis.get('path.domain').attributes('display')).toBe('none') expect(xAxis.get('path.domain').attributes('display')).toBe('none')
expect(wrapper.findAll('.waveform-chart__axis-endpoint')).toHaveLength(2) expect(wrapper.findAll('.waveform-chart__axis-endpoint')).toHaveLength(2)
expect(wrapper.get('.waveform-chart__axis-exponent--x').text()).not.toBe('') expect(wrapper.find('.waveform-chart__axis-exponent--x').exists()).toBe(false)
expect(wrapper.get('.waveform-chart__axis-exponent--y').text()).not.toBe('') expect(yAxes.some((axis) => axis.text().includes('E+04 '))).toBe(true)
expect(wrapper.find('.waveform-chart__axis-exponent--y').exists()).toBe(false)
expect(wrapper.find('.waveform-chart__grid').exists()).toBe(false) expect(wrapper.find('.waveform-chart__grid').exists()).toBe(false)
expect(wrapper.get('.waveform-chart__plot-frame').attributes()).toMatchObject({ expect(wrapper.get('.waveform-chart__plot-frame').attributes()).toMatchObject({
stroke: '#dc2626', stroke: '#dc2626',

View File

@@ -105,6 +105,65 @@ describe('WaveformChart', () => {
expect(wrapper.get('.waveform-chart__svg').attributes('height')).toBe('300') expect(wrapper.get('.waveform-chart__svg').attributes('height')).toBe('300')
}) })
it('configures plot top and bottom margins and reacts to updates', async () => {
const wrapper = await mountSizedChart(
{ kind: 'samples', values: [0, 1], sampleRate: 1 },
{
displayMode: 'compact',
grid: { rowCount: 1, columnCount: 1 },
plotMargin: { top: 30, bottom: 70 },
},
)
expect(wrapper.attributes('data-plot-margin-top')).toBe('30')
expect(wrapper.attributes('data-plot-margin-bottom')).toBe('70')
expect(wrapper.get('.waveform-chart__overlay').attributes('height')).toBe('260')
expect(wrapper.get('.waveform-chart__svg > g').attributes('transform')).toContain(', 30)')
await wrapper.setProps({ plotMargin: { top: 12 } })
expect(wrapper.attributes('data-plot-margin-top')).toBe('12')
expect(wrapper.attributes('data-plot-margin-bottom')).toBe('52')
expect(wrapper.get('.waveform-chart__overlay').attributes('height')).toBe('296')
})
it('falls back to default plot margins for invalid values', async () => {
const wrapper = await mountSizedChart(
{ kind: 'samples', values: [0, 1], sampleRate: 1 },
{
displayMode: 'compact',
grid: { rowCount: 1, columnCount: 1 },
plotMargin: { top: -1, bottom: Number.NaN },
},
)
expect(wrapper.attributes('data-plot-margin-top')).toBe('18')
expect(wrapper.attributes('data-plot-margin-bottom')).toBe('52')
expect(wrapper.get('.waveform-chart__overlay').attributes('height')).toBe('290')
})
it('keeps the chart title and X-axis title fixed when plot margins change', async () => {
const wrapper = await mountSizedChart(
{ kind: 'samples', values: [0, 1], sampleRate: 1 },
{
displayMode: 'compact',
grid: { rowCount: 1, columnCount: 1 },
title: { text: '固定标题' },
},
)
const titleAreaStyle = wrapper.get('.waveform-chart__title-area').attributes('style')
const xAxisTitle = wrapper.get('.waveform-chart__x-label')
const initialX = xAxisTitle.attributes('x')
const initialY = xAxisTitle.attributes('y')
await wrapper.setProps({ plotMargin: { top: 60, bottom: 90 } })
expect(wrapper.get('.waveform-chart__title-area').attributes('style')).toBe(titleAreaStyle)
expect(wrapper.get('.waveform-chart__x-label').attributes('x')).toBe(initialX)
expect(wrapper.get('.waveform-chart__x-label').attributes('y')).toBe(initialY)
expect(wrapper.get('.waveform-chart__svg > g').attributes('transform')).toContain(', 60)')
})
it('does not render or reserve space for missing, hidden, or blank titles', async () => { it('does not render or reserve space for missing, hidden, or blank titles', async () => {
for (const title of [undefined, { visible: false, text: '隐藏标题' }, { text: ' ' }]) { for (const title of [undefined, { visible: false, text: '隐藏标题' }, { text: ' ' }]) {
const wrapper = await mountSizedChart( const wrapper = await mountSizedChart(

View File

@@ -79,8 +79,8 @@ describe('WaveformChart', () => {
expect(wrapper.emitted('zoom-change')).toBeUndefined() expect(wrapper.emitted('zoom-change')).toBeUndefined()
expect(wrapper.emitted('zoom-end')).toBeUndefined() expect(wrapper.emitted('zoom-end')).toBeUndefined()
expect(wrapper.get('.waveform-chart__axis-endpoint--start').text()).toBe('0.00') expect(wrapper.get('.waveform-chart__axis-endpoint--start').text()).toBe('0')
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('2.00') expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('2000')
}) })
it('ignores independent viewport dragging', async () => { it('ignores independent viewport dragging', async () => {
@@ -333,14 +333,14 @@ describe('WaveformChart', () => {
) )
flushAnimationFrames() flushAnimationFrames()
await flushPromises() await flushPromises()
expect(wrapper.get('.waveform-chart__axis-endpoint--start').text()).not.toBe('0.00') expect(wrapper.get('.waveform-chart__axis-endpoint--start').text()).not.toBe('0')
overlay.element.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, cancelable: true })) overlay.element.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, cancelable: true }))
await flushPromises() await flushPromises()
expect(wrapper.emitted('zoom-reset')).toHaveLength(1) expect(wrapper.emitted('zoom-reset')).toHaveLength(1)
expect(wrapper.get('.waveform-chart__axis-endpoint--start').text()).toBe('0.00') expect(wrapper.get('.waveform-chart__axis-endpoint--start').text()).toBe('0')
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('2.00') expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('2000')
}) })
it('exposes resetViewport for independent tracks', async () => { it('exposes resetViewport for independent tracks', async () => {
@@ -364,13 +364,13 @@ describe('WaveformChart', () => {
) )
flushAnimationFrames() flushAnimationFrames()
await flushPromises() await flushPromises()
expect(wrapper.findAll('.waveform-chart__axis-endpoint--start')[0].text()).not.toBe('0.00') expect(wrapper.findAll('.waveform-chart__axis-endpoint--start')[0].text()).not.toBe('0')
const chart = wrapper.vm as unknown as { resetViewport: () => void } const chart = wrapper.vm as unknown as { resetViewport: () => void }
chart.resetViewport() chart.resetViewport()
await flushPromises() await flushPromises()
expect(wrapper.findAll('.waveform-chart__axis-endpoint--start')[0].text()).toBe('0.00') expect(wrapper.findAll('.waveform-chart__axis-endpoint--start')[0].text()).toBe('0')
expect(wrapper.findAll('.waveform-chart__axis-endpoint--end')[0].text()).toBe('1.00') expect(wrapper.findAll('.waveform-chart__axis-endpoint--end')[0].text()).toBe('1000')
}) })
}) })

View File

@@ -49,15 +49,15 @@ describe('WaveformChart wheel zoom out', () => {
end: wrapper.get('.waveform-chart__axis-endpoint--end').text(), end: wrapper.get('.waveform-chart__axis-endpoint--end').text(),
}) })
expect(endpoints()).toEqual({ start: '-5.00', end: '5.00' }) expect(endpoints()).toEqual({ start: '-5000', end: '5000' })
await dispatchWheel(-4000) await dispatchWheel(-4000)
expect(endpoints()).not.toEqual({ start: '-5.00', end: '5.00' }) expect(endpoints()).not.toEqual({ start: '-5000', end: '5000' })
await wrapper.setProps({ data: createData(-0.125, 0.125) }) await wrapper.setProps({ data: createData(-0.125, 0.125) })
await flushPromises() await flushPromises()
await dispatchWheel(4000) await dispatchWheel(4000)
expect(endpoints()).toEqual({ start: '-5.00', end: '5.00' }) expect(endpoints()).toEqual({ start: '-5000', end: '5000' })
}) })
it('emits one zoom-end payload after zooming a shared viewport out', async () => { it('emits one zoom-end payload after zooming a shared viewport out', async () => {

View File

@@ -3,6 +3,10 @@ import { bisector } from 'd3'
import type { WaveformPoint } from '@/types' import type { WaveformPoint } from '@/types'
import { resolveWaveformPointErrors } from './data' import { resolveWaveformPointErrors } from './data'
import type { ResolvedWaveformRenderingOptions } from './renderingOptions' import type { ResolvedWaveformRenderingOptions } from './renderingOptions'
import {
resolveRenderablePointSelectionStrategy,
type VisiblePointRange,
} from './renderingStrategies'
export { export {
DEFAULT_WAVEFORM_RENDERING_OPTIONS, DEFAULT_WAVEFORM_RENDERING_OPTIONS,
@@ -13,11 +17,6 @@ export {
const pointBisector = bisector((point: WaveformPoint) => point.x) const pointBisector = bisector((point: WaveformPoint) => point.x)
const acceptAllPoints = () => true const acceptAllPoints = () => true
interface VisiblePointRange {
start: number
end: number
}
interface PointSeriesSource { interface PointSeriesSource {
points: WaveformPoint[] points: WaveformPoint[]
} }
@@ -65,10 +64,6 @@ export function hasMinimumVisibleXValues(
return false return false
} }
function pushUniquePoint(target: WaveformPoint[], point: WaveformPoint | undefined) {
if (point && target[target.length - 1] !== point) target.push(point)
}
function selectRenderablePointsInRange( function selectRenderablePointsInRange(
points: WaveformPoint[], points: WaveformPoint[],
range: VisiblePointRange, range: VisiblePointRange,
@@ -76,82 +71,17 @@ function selectRenderablePointsInRange(
width: number, width: number,
options: ResolvedWaveformRenderingOptions, options: ResolvedWaveformRenderingOptions,
): WaveformPoint[] { ): WaveformPoint[] {
const domainStart = Math.min(domain[0], domain[1])
const domainEnd = Math.max(domain[0], domain[1])
const start = Math.max(0, range.start - 1) const start = Math.max(0, range.start - 1)
const end = Math.min(points.length, range.end + 1) const end = Math.min(points.length, range.end + 1)
const visibleCount = end - start const visibleCount = end - start
if (visibleCount <= 0) return [] if (visibleCount <= 0) return []
if (!options.downsample || visibleCount <= options.downsampleThreshold) { return resolveRenderablePointSelectionStrategy({ visibleCount, width, options }).select({
return points.slice(start, end) points,
} range,
domain,
const maximumPointCount = Math.max(4, Math.floor(width * options.maxPointsPerPixel)) width,
const bucketCount = Math.max(1, Math.floor(maximumPointCount / 4)) options,
if (visibleCount <= maximumPointCount) return points.slice(start, end) })
const result: WaveformPoint[] = []
const span = domainEnd - domainStart || 1
const bucketIndexes = Array.from({ length: 4 }, () => -1)
let activeBucket = -1
let firstIndex = -1
let lastIndex = -1
let minimumIndex = -1
let maximumIndex = -1
const addBucketIndex = (index: number, count: number) => {
if (index < 0) return count
for (let position = 0; position < count; position += 1) {
if (bucketIndexes[position] === index) return count
}
bucketIndexes[count] = index
return count + 1
}
const flushBucket = () => {
if (firstIndex < 0) return
let count = 0
count = addBucketIndex(firstIndex, count)
count = addBucketIndex(minimumIndex, count)
count = addBucketIndex(maximumIndex, count)
count = addBucketIndex(lastIndex, count)
for (let index = 1; index < count; index += 1) {
const value = bucketIndexes[index]
let position = index - 1
while (position >= 0 && bucketIndexes[position] > value) {
bucketIndexes[position + 1] = bucketIndexes[position]
position -= 1
}
bucketIndexes[position + 1] = value
}
for (let index = 0; index < count; index += 1) {
pushUniquePoint(result, points[bucketIndexes[index]])
}
}
pushUniquePoint(result, points[start])
for (let index = range.start; index < range.end; index += 1) {
const point = points[index]
const bucket = Math.min(
bucketCount - 1,
Math.max(0, Math.floor(((point.x - domainStart) / span) * bucketCount)),
)
if (bucket !== activeBucket) {
flushBucket()
activeBucket = bucket
firstIndex = index
lastIndex = index
minimumIndex = index
maximumIndex = index
continue
}
lastIndex = index
if (point.y < points[minimumIndex].y) minimumIndex = index
if (point.y > points[maximumIndex].y) maximumIndex = index
}
flushBucket()
pushUniquePoint(result, points[end - 1])
return result
} }
/** /**

View File

@@ -0,0 +1,78 @@
import { describe, expect, it } from 'vitest'
import type { WaveformPoint } from '../types'
import { DEFAULT_WAVEFORM_RENDERING_OPTIONS } from './renderingOptions'
import {
CompletePointSelectionStrategy,
PeakPreservingPointSelectionStrategy,
resolveRenderablePointSelectionStrategy,
} from './renderingStrategies'
const denseOptions = {
...DEFAULT_WAVEFORM_RENDERING_OPTIONS,
downsampleThreshold: 0,
maxPointsPerPixel: 1,
}
describe('renderable point selection strategies', () => {
it('resolves complete-point selection at the configured boundaries', () => {
const complete = resolveRenderablePointSelectionStrategy({
visibleCount: 100,
width: 100,
options: DEFAULT_WAVEFORM_RENDERING_OPTIONS,
})
const disabled = resolveRenderablePointSelectionStrategy({
visibleCount: 10_000,
width: 100,
options: { ...denseOptions, downsample: false },
})
expect(complete).toBeInstanceOf(CompletePointSelectionStrategy)
expect(disabled).toBeInstanceOf(CompletePointSelectionStrategy)
})
it('resolves peak-preserving selection for dense visible data', () => {
const strategy = resolveRenderablePointSelectionStrategy({
visibleCount: 1_000,
width: 100,
options: denseOptions,
})
expect(strategy).toBeInstanceOf(PeakPreservingPointSelectionStrategy)
expect(strategy.name).toBe('peak-preserving')
})
it('reuses the resolved strategy instance across selections', () => {
const request = { visibleCount: 100, width: 100, options: DEFAULT_WAVEFORM_RENDERING_OPTIONS }
const peakRequest = { visibleCount: 1_000, width: 100, options: denseOptions }
expect(resolveRenderablePointSelectionStrategy(request)).toBe(
resolveRenderablePointSelectionStrategy(request),
)
expect(resolveRenderablePointSelectionStrategy(peakRequest)).toBe(
resolveRenderablePointSelectionStrategy(peakRequest),
)
})
it('retains first, last, minimum, and maximum points in a peak bucket', () => {
const points: WaveformPoint[] = [
{ x: 0, y: 5 },
{ x: 1, y: 1 },
{ x: 2, y: 10 },
{ x: 3, y: 3 },
{ x: 4, y: 7 },
]
const strategy = new PeakPreservingPointSelectionStrategy()
const selected = strategy.select({
points,
range: { start: 0, end: points.length },
domain: [0, 4],
width: 4,
options: denseOptions,
})
expect(selected[0]).toBe(points[0])
expect(selected.at(-1)).toBe(points.at(-1))
expect(selected.map((point) => point.y)).toEqual(expect.arrayContaining([1, 10]))
})
})

View File

@@ -0,0 +1,149 @@
import type { WaveformPoint } from '../types'
import type { ResolvedWaveformRenderingOptions } from './renderingOptions'
export interface VisiblePointRange {
start: number
end: number
}
export interface RenderablePointSelectionContext {
points: WaveformPoint[]
range: VisiblePointRange
domain: [number, number]
width: number
options: ResolvedWaveformRenderingOptions
}
export interface RenderablePointSelectionStrategy {
readonly name: 'complete' | 'peak-preserving'
select(context: RenderablePointSelectionContext): WaveformPoint[]
}
function selectionBounds(range: VisiblePointRange, pointCount: number) {
return {
start: Math.max(0, range.start - 1),
end: Math.min(pointCount, range.end + 1),
}
}
function pushUniquePoint(target: WaveformPoint[], point: WaveformPoint | undefined) {
if (point && target[target.length - 1] !== point) target.push(point)
}
export class CompletePointSelectionStrategy implements RenderablePointSelectionStrategy {
readonly name = 'complete' as const
select(context: RenderablePointSelectionContext): WaveformPoint[] {
const { start, end } = selectionBounds(context.range, context.points.length)
return context.points.slice(start, end)
}
}
export class PeakPreservingPointSelectionStrategy implements RenderablePointSelectionStrategy {
readonly name = 'peak-preserving' as const
select(context: RenderablePointSelectionContext): WaveformPoint[] {
const { points, range, domain, width, options } = context
const { start, end } = selectionBounds(range, points.length)
const visibleCount = end - start
if (visibleCount <= 0) return []
const domainStart = Math.min(domain[0], domain[1])
const domainEnd = Math.max(domain[0], domain[1])
const maximumPointCount = Math.max(4, Math.floor(width * options.maxPointsPerPixel))
const bucketCount = Math.max(1, Math.floor(maximumPointCount / 4))
const result: WaveformPoint[] = []
const span = domainEnd - domainStart || 1
const bucketIndexes = Array.from({ length: 4 }, () => -1)
let activeBucket = -1
let firstIndex = -1
let lastIndex = -1
let minimumIndex = -1
let maximumIndex = -1
const addBucketIndex = (index: number, count: number) => {
if (index < 0) return count
for (let position = 0; position < count; position += 1) {
if (bucketIndexes[position] === index) return count
}
bucketIndexes[count] = index
return count + 1
}
const flushBucket = () => {
if (firstIndex < 0) return
let count = 0
count = addBucketIndex(firstIndex, count)
count = addBucketIndex(minimumIndex, count)
count = addBucketIndex(maximumIndex, count)
count = addBucketIndex(lastIndex, count)
for (let index = 1; index < count; index += 1) {
const value = bucketIndexes[index]
let position = index - 1
while (position >= 0 && bucketIndexes[position] > value) {
bucketIndexes[position + 1] = bucketIndexes[position]
position -= 1
}
bucketIndexes[position + 1] = value
}
for (let index = 0; index < count; index += 1) {
pushUniquePoint(result, points[bucketIndexes[index]])
}
}
pushUniquePoint(result, points[start])
for (let index = range.start; index < range.end; index += 1) {
const point = points[index]
const bucket = Math.min(
bucketCount - 1,
Math.max(0, Math.floor(((point.x - domainStart) / span) * bucketCount)),
)
if (bucket !== activeBucket) {
flushBucket()
activeBucket = bucket
firstIndex = index
lastIndex = index
minimumIndex = index
maximumIndex = index
continue
}
lastIndex = index
if (point.y < points[minimumIndex].y) minimumIndex = index
if (point.y > points[maximumIndex].y) maximumIndex = index
}
flushBucket()
pushUniquePoint(result, points[end - 1])
return result
}
}
export interface RenderablePointSelectionStrategyRequest {
visibleCount: number
width: number
options: ResolvedWaveformRenderingOptions
}
export class RenderablePointSelectionStrategyFactory {
private readonly complete = new CompletePointSelectionStrategy()
private readonly peakPreserving = new PeakPreservingPointSelectionStrategy()
resolve(request: RenderablePointSelectionStrategyRequest): RenderablePointSelectionStrategy {
const maximumPointCount = Math.max(
4,
Math.floor(request.width * request.options.maxPointsPerPixel),
)
const shouldUseCompletePoints =
!request.options.downsample ||
request.visibleCount <= request.options.downsampleThreshold ||
request.visibleCount <= maximumPointCount
return shouldUseCompletePoints ? this.complete : this.peakPreserving
}
}
const defaultRenderablePointSelectionStrategyFactory = new RenderablePointSelectionStrategyFactory()
export function resolveRenderablePointSelectionStrategy(
request: RenderablePointSelectionStrategyRequest,
): RenderablePointSelectionStrategy {
return defaultRenderablePointSelectionStrategyFactory.resolve(request)
}

View File

@@ -45,6 +45,7 @@ defineExpose({ resetViewport })
:clean-view="model.cleanView" :clean-view="model.cleanView"
:presentation-mode="model.presentationMode" :presentation-mode="model.presentationMode"
:show-tooltip="model.showTooltip" :show-tooltip="model.showTooltip"
:plot-margin="model.plotMargin"
:zero-line="model.zeroLine" :zero-line="model.zeroLine"
:frame-number="model.frameWatermarkVisible ? 1 : undefined" :frame-number="model.frameWatermarkVisible ? 1 : undefined"
:annotations-visible="model.annotationsVisible" :annotations-visible="model.annotationsVisible"

View File

@@ -8,6 +8,35 @@ const model = defineModel<DemoControlPanelModel>('model', { required: true })
</script> </script>
<template> <template>
<section class="control-section">
<h2>绘图区边距</h2>
<div class="plot-margin-controls">
<label class="frame-style-control">
<span>上边距</span>
<InputNumber
v-model:value="model.plotMarginTop"
:min="0"
:max="300"
:step="1"
addon-after="px"
size="small"
aria-label="绘图区上边距"
/>
</label>
<label class="frame-style-control">
<span>下边距</span>
<InputNumber
v-model:value="model.plotMarginBottom"
:min="0"
:max="300"
:step="1"
addon-after="px"
size="small"
aria-label="绘图区下边距"
/>
</label>
</div>
</section>
<section class="control-section"> <section class="control-section">
<div class="control-section__header"> <div class="control-section__header">
<h2>零值参考线</h2> <h2>零值参考线</h2>
@@ -96,6 +125,76 @@ const model = defineModel<DemoControlPanelModel>('model', { required: true })
<Switch v-model:checked="model.yAxisLineVisible" size="small" aria-label="显示纵轴线" /> <Switch v-model:checked="model.yAxisLineVisible" size="small" aria-label="显示纵轴线" />
</div> </div>
</div> </div>
<div class="x-axis-label-controls">
<label class="frame-style-control frame-style-control--switch">
<span>自定义 Label</span>
<Switch
v-model:checked="model.xAxisLabelFormatterEnabled"
size="small"
aria-label="自定义 X Label"
/>
</label>
<label v-if="model.xAxisLabelFormatterEnabled" class="frame-style-control">
<span>格式类型</span>
<Select
v-model:value="model.xAxisLabelFormat"
:options="model.xAxisLabelFormatOptions"
size="small"
aria-label="X Label 格式类型"
/>
</label>
<label
v-if="model.xAxisLabelFormatterEnabled && model.xAxisLabelFormat === 'number'"
class="frame-style-control"
>
<span>运算倍率</span>
<InputNumber
v-model:value="model.xAxisLabelMultiplier"
:min="-1000000"
:max="1000000"
:step="0.1"
size="small"
aria-label="X Label 运算倍率"
/>
</label>
<label
v-if="model.xAxisLabelFormatterEnabled && model.xAxisLabelFormat === 'number'"
class="frame-style-control"
>
<span>小数位数</span>
<InputNumber
v-model:value="model.xAxisLabelFractionDigits"
:min="0"
:max="12"
:step="1"
size="small"
aria-label="X Label 小数位数"
/>
</label>
<label
v-if="model.xAxisLabelFormatterEnabled && model.xAxisLabelFormat === 'datetime'"
class="frame-style-control"
>
<span>时区</span>
<Select
v-model:value="model.xAxisLabelTimeZone"
:options="model.xAxisLabelTimeZoneOptions"
size="small"
aria-label="X Label 时区"
/>
</label>
<label
v-if="model.xAxisLabelFormatterEnabled && model.xAxisLabelFormat === 'datetime'"
class="frame-style-control frame-style-control--switch"
>
<span>显示毫秒</span>
<Switch
v-model:checked="model.xAxisLabelShowMilliseconds"
size="small"
aria-label="X Label 显示毫秒"
/>
</label>
</div>
</section> </section>
<section class="control-section"> <section class="control-section">
<h2>图框样式</h2> <h2>图框样式</h2>

View File

@@ -0,0 +1,29 @@
import { flushPromises, mount } from '@vue/test-utils'
import { InputNumber } from 'ant-design-vue'
import { describe, expect, it } from 'vitest'
import App from '../App.vue'
import { WaveformChart } from '../components'
describe('plot margin demo controls', () => {
it('updates the chart top and bottom margins from the sidebar', async () => {
const wrapper = mount(App)
await flushPromises()
const chart = wrapper.getComponent(WaveformChart)
const controls = wrapper.get('.plot-margin-controls').findAllComponents(InputNumber)
expect(controls).toHaveLength(2)
expect(controls[0]?.props('value')).toBe(18)
expect(controls[1]?.props('value')).toBe(52)
expect(chart.props('plotMargin')).toEqual({ top: 18, bottom: 52 })
controls[0]?.vm.$emit('update:value', 30)
controls[1]?.vm.$emit('update:value', 70)
await flushPromises()
expect(chart.props('plotMargin')).toEqual({ top: 30, bottom: 70 })
expect(wrapper.get('.waveform-chart').attributes('data-plot-margin-top')).toBe('30')
expect(wrapper.get('.waveform-chart').attributes('data-plot-margin-bottom')).toBe('70')
wrapper.unmount()
})
})

View File

@@ -10,10 +10,12 @@ import type {
WaveformLegendPosition, WaveformLegendPosition,
WaveformLineStyle, WaveformLineStyle,
WaveformOverlayMode, WaveformOverlayMode,
WaveformPlotMargin,
WaveformTitleOptions, WaveformTitleOptions,
WaveformZeroLineOptions, WaveformZeroLineOptions,
WaveformZoomEndPayload, WaveformZoomEndPayload,
} from '../components' } from '../components'
import type { DemoXAxisLabelFormat, DemoXAxisLabelTimeZone } from './useDemoXAxisLabelControls'
interface SelectOption<T> { interface SelectOption<T> {
label: string label: string
@@ -25,6 +27,8 @@ export interface DemoControlPanelModel {
displayMode: WaveformDisplayMode displayMode: WaveformDisplayMode
overlayMode: WaveformOverlayMode overlayMode: WaveformOverlayMode
showTooltip: boolean showTooltip: boolean
plotMarginTop: number
plotMarginBottom: number
cleanView: boolean cleanView: boolean
presentationMode: boolean presentationMode: boolean
selectedSeriesId: string selectedSeriesId: string
@@ -44,6 +48,14 @@ export interface DemoControlPanelModel {
verticalGridColor: string verticalGridColor: string
xAxisLineVisible: boolean xAxisLineVisible: boolean
yAxisLineVisible: boolean yAxisLineVisible: boolean
xAxisLabelFormatterEnabled: boolean
xAxisLabelFormat: DemoXAxisLabelFormat
xAxisLabelFormatOptions: Array<SelectOption<DemoXAxisLabelFormat>>
xAxisLabelMultiplier: number
xAxisLabelFractionDigits: number
xAxisLabelTimeZone: DemoXAxisLabelTimeZone
xAxisLabelTimeZoneOptions: Array<SelectOption<DemoXAxisLabelTimeZone>>
xAxisLabelShowMilliseconds: boolean
frameBorderColor: string frameBorderColor: string
frameBackgroundColor: string frameBackgroundColor: string
frameBorderWidth: number frameBorderWidth: number
@@ -90,6 +102,7 @@ export interface DemoChartModel {
cleanView: boolean cleanView: boolean
presentationMode: boolean presentationMode: boolean
showTooltip: boolean showTooltip: boolean
plotMargin: WaveformPlotMargin
zeroLine: WaveformZeroLineOptions zeroLine: WaveformZeroLineOptions
frameWatermarkVisible: boolean frameWatermarkVisible: boolean
annotations: WaveformAnnotation[] annotations: WaveformAnnotation[]

View File

@@ -0,0 +1,77 @@
import { computed, ref } from 'vue'
import type { WaveformXAxisLabelFormatter } from '../types'
export type DemoXAxisLabelFormat = 'number' | 'datetime'
export type DemoXAxisLabelTimeZone = 'local' | 'Asia/Shanghai' | 'UTC'
const formatOptions = [
{ label: '数值', value: 'number' as const },
{ label: '固定时间', value: 'datetime' as const },
]
const timeZoneOptions = [
{ label: '中国标准时间', value: 'Asia/Shanghai' as const },
{ label: '本地时区', value: 'local' as const },
{ label: 'UTC', value: 'UTC' as const },
]
function pad(value: number, length = 2): string {
return String(value).padStart(length, '0')
}
export function formatDemoTimestamp(
value: number,
timeZone: DemoXAxisLabelTimeZone,
showMilliseconds: boolean,
): string {
const offset = timeZone === 'Asia/Shanghai' ? 8 * 60 * 60 * 1000 : 0
const date = new Date(value + offset)
if (!Number.isFinite(date.getTime())) return String(value)
const useUtcFields = timeZone !== 'local'
const year = useUtcFields ? date.getUTCFullYear() : date.getFullYear()
const month = (useUtcFields ? date.getUTCMonth() : date.getMonth()) + 1
const day = useUtcFields ? date.getUTCDate() : date.getDate()
const hours = useUtcFields ? date.getUTCHours() : date.getHours()
const minutes = useUtcFields ? date.getUTCMinutes() : date.getMinutes()
const seconds = useUtcFields ? date.getUTCSeconds() : date.getSeconds()
const milliseconds = useUtcFields ? date.getUTCMilliseconds() : date.getMilliseconds()
const suffix = showMilliseconds ? `.${pad(milliseconds, 3)}` : ''
return `${year}-${pad(month)}-${pad(day)} ${pad(hours)}:${pad(minutes)}:${pad(seconds)}${suffix}`
}
export function useDemoXAxisLabelControls() {
const xAxisLabelFormatterEnabled = ref(false)
const xAxisLabelFormat = ref<DemoXAxisLabelFormat>('number')
const xAxisLabelMultiplier = ref(1)
const xAxisLabelFractionDigits = ref(3)
const xAxisLabelTimeZone = ref<DemoXAxisLabelTimeZone>('Asia/Shanghai')
const xAxisLabelShowMilliseconds = ref(false)
const xAxisLabelFormatter = computed<WaveformXAxisLabelFormatter | undefined>(() => {
if (!xAxisLabelFormatterEnabled.value) return undefined
if (xAxisLabelFormat.value === 'datetime') {
return (value) =>
formatDemoTimestamp(value, xAxisLabelTimeZone.value, xAxisLabelShowMilliseconds.value)
}
const multiplier = Number.isFinite(xAxisLabelMultiplier.value) ? xAxisLabelMultiplier.value : 1
const fractionDigits = Number.isFinite(xAxisLabelFractionDigits.value)
? Math.min(12, Math.max(0, Math.trunc(xAxisLabelFractionDigits.value)))
: 0
return (value) => (value * multiplier).toFixed(fractionDigits)
})
return {
controlModel: {
xAxisLabelFormatterEnabled,
xAxisLabelFormat,
xAxisLabelFormatOptions: formatOptions,
xAxisLabelMultiplier,
xAxisLabelFractionDigits,
xAxisLabelTimeZone,
xAxisLabelTimeZoneOptions: timeZoneOptions,
xAxisLabelShowMilliseconds,
},
xAxisLabelFormatter,
}
}

View File

@@ -0,0 +1,86 @@
import { flushPromises, mount } from '@vue/test-utils'
import { InputNumber, Select } from 'ant-design-vue'
import { describe, expect, it } from 'vitest'
import App from '../App.vue'
import { WaveformChart } from '../components'
import { formatDemoTimestamp } from './useDemoXAxisLabelControls'
describe('X-axis label demo controls', () => {
it('formats timestamps in fixed UTC and China-standard-time formats', () => {
expect(formatDemoTimestamp(0, 'UTC', false)).toBe('1970-01-01 00:00:00')
expect(formatDemoTimestamp(123, 'UTC', true)).toBe('1970-01-01 00:00:00.123')
expect(formatDemoTimestamp(0, 'Asia/Shanghai', false)).toBe('1970-01-01 08:00:00')
expect(formatDemoTimestamp(Number.NaN, 'UTC', false)).toBe('NaN')
})
it('configures numeric and timestamp labels from the sidebar', async () => {
const wrapper = mount(App)
await flushPromises()
const chart = wrapper.getComponent(WaveformChart)
expect(chart.props('axes')).toEqual({
x: { lineVisible: false },
y: { lineVisible: false },
})
expect(wrapper.find('[aria-label="自定义 X 轴 Label"]').exists()).toBe(true)
expect(wrapper.find('[aria-label="X 轴 Label 运算倍率"]').exists()).toBe(false)
expect(wrapper.find('[aria-label="X 轴 Label 小数位数"]').exists()).toBe(false)
expect(wrapper.find('[aria-label="X 轴 Label 格式类型"]').exists()).toBe(false)
await wrapper.get('[aria-label="自定义 X 轴 Label"]').trigger('click')
await flushPromises()
const inputNumbers = wrapper.findAllComponents(InputNumber)
const multiplierInput = inputNumbers.find((input) =>
input.find('[aria-label="X 轴 Label 运算倍率"]').exists(),
)
const fractionDigitsInput = inputNumbers.find((input) =>
input.find('[aria-label="X 轴 Label 小数位数"]').exists(),
)
expect(multiplierInput).toBeDefined()
expect(fractionDigitsInput).toBeDefined()
expect(multiplierInput?.props('value')).toBe(1)
expect(fractionDigitsInput?.props('value')).toBe(3)
expect(wrapper.get('.waveform-chart__axis-endpoint--start').text()).toMatch(/\.000$/)
multiplierInput?.vm.$emit('update:value', 2)
fractionDigitsInput?.vm.$emit('update:value', 2)
await flushPromises()
const formatter = chart.props('axes')?.x?.labelFormatter
expect(formatter).toBeTypeOf('function')
expect(formatter?.(1.234, {} as never)).toBe('2.47')
expect(wrapper.get('.waveform-chart__axis-endpoint--start').text()).toMatch(/\.00$/)
const formatSelect = wrapper
.findAllComponents(Select)
.find((select) => select.find('[aria-label="X 轴 Label 格式类型"]').exists())
expect(formatSelect?.props('value')).toBe('number')
formatSelect?.vm.$emit('update:value', 'datetime')
await flushPromises()
expect(wrapper.find('[aria-label="X 轴 Label 运算倍率"]').exists()).toBe(false)
expect(wrapper.find('[aria-label="X 轴 Label 小数位数"]').exists()).toBe(false)
expect(wrapper.find('[aria-label="X 轴 Label 时区"]').exists()).toBe(true)
expect(wrapper.find('[aria-label="X 轴 Label 显示毫秒"]').exists()).toBe(true)
expect(chart.props('axes')?.x?.labelFormatter?.(0, {} as never)).toBe('1970-01-01 08:00:00')
await wrapper.get('[aria-label="X 轴 Label 显示毫秒"]').trigger('click')
await flushPromises()
expect(chart.props('axes')?.x?.labelFormatter?.(123, {} as never)).toBe(
'1970-01-01 08:00:00.123',
)
const timeZoneSelect = wrapper
.findAllComponents(Select)
.find((select) => select.find('[aria-label="X 轴 Label 时区"]').exists())
timeZoneSelect?.vm.$emit('update:value', 'UTC')
await flushPromises()
expect(chart.props('axes')?.x?.labelFormatter?.(123, {} as never)).toBe(
'1970-01-01 00:00:00.123',
)
await wrapper.get('[aria-label="自定义 X 轴 Label"]').trigger('click')
await flushPromises()
expect(chart.props('axes')?.x?.labelFormatter).toBeUndefined()
expect(wrapper.find('[aria-label="X 轴 Label 运算倍率"]').exists()).toBe(false)
wrapper.unmount()
})
})

View File

@@ -17,12 +17,16 @@ export type {
WaveformAnnotationStyle, WaveformAnnotationStyle,
WaveformAnnotation, WaveformAnnotation,
WaveformRenderingOptions, WaveformRenderingOptions,
WaveformPlotMargin,
WaveformTitleTextStyle, WaveformTitleTextStyle,
WaveformTitleOptions, WaveformTitleOptions,
WaveformLegendPosition, WaveformLegendPosition,
WaveformLegendOrientation, WaveformLegendOrientation,
WaveformLegendOptions, WaveformLegendOptions,
WaveformFrameStyle, WaveformFrameStyle,
WaveformXAxisLabelKind,
WaveformXAxisLabelFormatterContext,
WaveformXAxisLabelFormatter,
WaveformAxesOptions, WaveformAxesOptions,
WaveformZeroLineOptions, WaveformZeroLineOptions,
// 数据类型 // 数据类型

View File

@@ -133,6 +133,14 @@ body {
grid-template-columns: minmax(0, 1fr) auto; grid-template-columns: minmax(0, 1fr) auto;
} }
.x-axis-label-controls {
display: grid;
gap: 10px;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid #eaecf0;
}
.grid-line-color-picker, .grid-line-color-picker,
.grid-line-color-picker .vc-color-wrap { .grid-line-color-picker .vc-color-wrap {
width: 48px; width: 48px;
@@ -187,6 +195,7 @@ body {
} }
.frame-style-controls, .frame-style-controls,
.plot-margin-controls,
.auxiliary-style-controls { .auxiliary-style-controls {
display: grid; display: grid;
gap: 10px; gap: 10px;

View File

@@ -74,6 +74,14 @@ export interface WaveformRenderingOptions {
errorBarMinSpacing?: number errorBarMinSpacing?: number
} }
/** Pixel margins reserved above and below the waveform plotting area. */
export interface WaveformPlotMargin {
/** Space between the top of the drawing SVG and the plotting area. Defaults to 18. */
top?: number
/** Space between the plotting area and the bottom of the drawing SVG. Defaults to 52. */
bottom?: number
}
/** Text styling for the chart-level title. */ /** Text styling for the chart-level title. */
export interface WaveformTitleTextStyle { export interface WaveformTitleTextStyle {
color?: string color?: string
@@ -121,10 +129,31 @@ export interface WaveformFrameStyle {
backgroundColor?: string backgroundColor?: string
} }
export type WaveformXAxisLabelKind = 'tick' | 'start' | 'end'
/** Context passed to custom X-axis label formatters. */
export interface WaveformXAxisLabelFormatterContext {
kind: WaveformXAxisLabelKind
/** X coordinate in the source data, before time-unit conversion. */
rawValue: number
timeUnit: 's' | 'ms'
/** Current visible X domain in source coordinates. */
domain: [number, number]
/** Current visible X domain converted to the selected display unit. */
displayDomain: [number, number]
}
export type WaveformXAxisLabelFormatter = (
value: number,
context: WaveformXAxisLabelFormatterContext,
) => string
/** Controls axis baseline visibility while preserving tick marks and axis text. */ /** Controls axis baseline visibility while preserving tick marks and axis text. */
export interface WaveformAxesOptions { export interface WaveformAxesOptions {
x?: { x?: {
lineVisible?: boolean lineVisible?: boolean
/** Formats display-unit X values for ticks and visible-range endpoints. */
labelFormatter?: WaveformXAxisLabelFormatter
} }
y?: { y?: {
lineVisible?: boolean lineVisible?: boolean

View File

@@ -12,12 +12,16 @@ export type {
WaveformAnnotationStyle, WaveformAnnotationStyle,
WaveformAnnotation, WaveformAnnotation,
WaveformRenderingOptions, WaveformRenderingOptions,
WaveformPlotMargin,
WaveformTitleTextStyle, WaveformTitleTextStyle,
WaveformTitleOptions, WaveformTitleOptions,
WaveformLegendPosition, WaveformLegendPosition,
WaveformLegendOrientation, WaveformLegendOrientation,
WaveformLegendOptions, WaveformLegendOptions,
WaveformFrameStyle, WaveformFrameStyle,
WaveformXAxisLabelKind,
WaveformXAxisLabelFormatterContext,
WaveformXAxisLabelFormatter,
WaveformAxesOptions, WaveformAxesOptions,
WaveformZeroLineOptions, WaveformZeroLineOptions,
} from './chart' } from './chart'

View File

@@ -1,71 +1,112 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import type { WaveformXAxisLabelFormatter, WaveformXAxisLabelFormatterContext } from '../types'
import { import {
formatAnnotationTime, formatAnnotationTime,
formatAxisTime, formatAxisTime,
formatAxisTimeExponent,
formatEndpointTime, formatEndpointTime,
formatPlainNumber, formatPlainNumber,
formatScientificAxisExponent, formatScientificAxisExponent,
formatScientificAxisLabel, formatScientificAxisLabel,
formatTooltipNumber, formatTooltipNumber,
formatTooltipTime,
formatXAxisLabel,
resolveScientificAxisExponent,
shouldUseScientificAxisLabel, shouldUseScientificAxisLabel,
} from './formatters' } from './formatters'
describe('waveform number formatters', () => { describe('waveform number formatters', () => {
it('uses the reference Y-axis scientific notation boundaries', () => { it('uses the reference Y-axis scientific notation boundaries', () => {
expect(shouldUseScientificAxisLabel(0)).toBe(false) expect(shouldUseScientificAxisLabel(0)).toBe(false)
expect(shouldUseScientificAxisLabel(0.009)).toBe(true) expect(shouldUseScientificAxisLabel(0.000999)).toBe(true)
expect(shouldUseScientificAxisLabel(0.01)).toBe(false) expect(shouldUseScientificAxisLabel(0.001)).toBe(false)
expect(shouldUseScientificAxisLabel(99.99)).toBe(false) expect(shouldUseScientificAxisLabel(999.999)).toBe(false)
expect(shouldUseScientificAxisLabel(100)).toBe(true) expect(shouldUseScientificAxisLabel(1000)).toBe(true)
}) })
it('shares one separate exponent across an axis', () => { it('prefixes one shared exponent to the largest visible tick', () => {
const positiveAxis = { axisMin: 0, axisMax: 254 } const positiveAxis = { axisMin: 1000, axisMax: 3000 }
expect(formatScientificAxisLabel(127, positiveAxis)).toBe('1.27') expect(formatScientificAxisLabel(1000, positiveAxis)).toBe('1')
expect(formatScientificAxisLabel(254, positiveAxis)).toBe('2.54') expect(formatScientificAxisLabel(2000, positiveAxis)).toBe('2')
expect(formatScientificAxisExponent(0, 254)).toBe('E+02') expect(formatScientificAxisLabel(3000, positiveAxis)).toBe('E+03 3')
expect(formatScientificAxisLabel(2000, { ...positiveAxis, topTickValue: 2000 })).toBe('E+03 2')
expect(formatScientificAxisExponent(1000, 3000)).toBe('E+03')
expect(formatScientificAxisExponent(0, 1e120)).toBe('E+120') expect(formatScientificAxisExponent(0, 1e120)).toBe('E+120')
const tinyAxis = { axisMin: 0, axisMax: 0.0002 } const tinyAxis = { axisMin: 0.0001, axisMax: 0.0003 }
expect(formatScientificAxisLabel(0.0001, tinyAxis)).toBe('1.00') expect(formatScientificAxisLabel(0.0001, tinyAxis)).toBe('1')
expect(formatScientificAxisLabel(0.0002, tinyAxis)).toBe('2.00') expect(formatScientificAxisLabel(0.0002, tinyAxis)).toBe('2')
expect(formatScientificAxisExponent(0, 0.0002)).toBe('E-04') expect(formatScientificAxisLabel(0.0003, tinyAxis)).toBe('E-04 3')
expect(formatScientificAxisExponent(0.0001, 0.0003)).toBe('E-04')
const negativeAxis = { axisMin: -254, axisMax: 0 }
expect(formatScientificAxisLabel(-254, negativeAxis)).toBe('-2.54')
expect(formatScientificAxisLabel(0, negativeAxis)).toBe('0.00')
expect(formatScientificAxisExponent(-254, 0)).toBe('E+02')
}) })
it('keeps plain axes at two decimals and removes negative zero', () => { it('derives the exponent from Math.max(axisMin, axisMax)', () => {
expect(formatScientificAxisLabel(99.99, { axisMin: 0, axisMax: 99.99 })).toBe('99.99') expect(resolveScientificAxisExponent(-9000, -1000)).toBe(3)
expect(formatScientificAxisLabel(0.01, { axisMin: 0, axisMax: 0.01 })).toBe('0.01') expect(resolveScientificAxisExponent(-10_000, 3000)).toBe(3)
expect(formatScientificAxisLabel(-0.001, { axisMin: -1, axisMax: 1 })).toBe('0.00') expect(resolveScientificAxisExponent(-1000, 0)).toBeNull()
expect(resolveScientificAxisExponent(0, 0)).toBeNull()
})
it('keeps five significant digits, removes trailing zeroes, and limits decimals to four', () => {
expect(formatScientificAxisLabel(123.456, { axisMin: 0, axisMax: 999 })).toBe('123.46')
expect(formatScientificAxisLabel(1234.56, { axisMin: 0, axisMax: 9999 })).toBe('1.2346')
expect(formatScientificAxisLabel(3000, { axisMin: 1000, axisMax: 3000 })).toBe('E+03 3')
expect(formatScientificAxisLabel(-0, { axisMin: -1, axisMax: 1 })).toBe('0')
expect(formatScientificAxisLabel(Number.NaN)).toBe('NaN') expect(formatScientificAxisLabel(Number.NaN)).toBe('NaN')
expect(formatScientificAxisLabel(Number.POSITIVE_INFINITY)).toBe('Infinity') expect(formatScientificAxisLabel(Number.POSITIVE_INFINITY)).toBe('Infinity')
expect(formatScientificAxisExponent(0, 0)).toBeNull() expect(formatScientificAxisExponent(0, 0)).toBeNull()
}) })
it('formats X-axis ticks and endpoints from the selected display unit', () => { it('formats X-axis ticks and endpoints as complete plain values in the display unit', () => {
const domain: [number, number] = [0, 1] const domain: [number, number] = [0, 1]
expect(formatAxisTime(0.5, 'ms', domain)).toBe('0.50') expect(formatAxisTime(0.5004, 'ms', domain)).toBe('500.4')
expect(formatEndpointTime(1, domain, 'ms')).toBe('1.00') expect(formatEndpointTime(1, domain, 'ms')).toBe('1000')
expect(formatAxisTimeExponent(domain, 'ms')).toBe('E+03') expect(formatAxisTime(0.5, 's', domain)).toBe('0.5')
expect(formatAxisTime(0.5, 's', domain)).toBe('0.50') expect(formatEndpointTime(1, domain, 's')).toBe('1')
expect(formatEndpointTime(1, domain, 's')).toBe('1.00')
expect(formatAxisTimeExponent(domain, 's')).toBeNull()
const tinyDomain: [number, number] = [0, 0.000001] const tinyDomain: [number, number] = [0, 0.000001]
expect(formatEndpointTime(0.000001, tinyDomain, 's')).toBe('1.00') expect(formatEndpointTime(0.000001, tinyDomain, 's')).toBe('0.000001')
expect(formatAxisTimeExponent(tinyDomain, 's')).toBe('E-06') expect(formatAxisTime(-0.000001, 's', tinyDomain)).toBe('-0.000001')
expect(formatAxisTime(-0, 's')).toBe('0')
expect(formatAxisTime(1e21, 's')).toBe('1000000000000000000000')
expect(formatAxisTime(Number.POSITIVE_INFINITY, 's')).toBe('Infinity')
})
it('passes display values and complete source context to X-axis label formatters', () => {
const domain: [number, number] = [0.125, 1.875]
const formatter: WaveformXAxisLabelFormatter = (value, context) =>
`${context.kind}:${value / 10}`
expect(formatXAxisLabel(0.5, domain, 'ms', 'tick', formatter)).toBe('tick:50')
expect(formatXAxisLabel(domain[0], domain, 'ms', 'start', formatter)).toBe('start:12.5')
expect(formatXAxisLabel(domain[1], domain, 'ms', 'end', formatter)).toBe('end:187.5')
let receivedContext: WaveformXAxisLabelFormatterContext | undefined
formatXAxisLabel(0.5, domain, 'ms', 'tick', (value, context) => {
receivedContext = context
return String(value)
})
expect(receivedContext).toEqual({
kind: 'tick',
rawValue: 0.5,
timeUnit: 'ms',
domain: [0.125, 1.875],
displayDomain: [125, 1875],
})
expect(() =>
formatXAxisLabel(0.5, domain, 's', 'tick', () => {
throw new Error('formatter failed')
}),
).toThrow('formatter failed')
}) })
it('formats tooltip and raw values for their display contexts', () => { it('formats tooltip and raw values for their display contexts', () => {
expect(formatTooltipNumber(12345.67891)).toBe('12,345.6789') expect(formatTooltipNumber(12345.67891)).toBe('12,345.6789')
expect(formatTooltipNumber(-0)).toBe('0') expect(formatTooltipNumber(-0)).toBe('0')
expect(formatTooltipNumber(Number.POSITIVE_INFINITY)).toBe('Infinity') expect(formatTooltipNumber(Number.POSITIVE_INFINITY)).toBe('Infinity')
expect(formatTooltipTime(1.234567, 's')).toBe('1.2346')
expect(formatTooltipTime(1, 'ms')).toBe('1,000')
expect(formatPlainNumber(0.0000001)).toBe('0.0000001') expect(formatPlainNumber(0.0000001)).toBe('0.0000001')
expect(formatPlainNumber(1e21)).toBe('1000000000000000000000') expect(formatPlainNumber(1e21)).toBe('1000000000000000000000')
expect(formatPlainNumber(-0)).toBe('0') expect(formatPlainNumber(-0)).toBe('0')

View File

@@ -1,22 +1,26 @@
import type { WaveformXAxisLabelFormatter, WaveformXAxisLabelKind } from '../types'
/** /**
* 时间单位类型 * 时间单位类型
*/ */
export type TimeUnit = 'ms' | 's' export type TimeUnit = 'ms' | 's'
export interface ScientificAxisLabelOptions { export interface ScientificAxisLabelOptions {
/** @deprecated Y-axis labels always use five significant digits before localization. */
precision?: number precision?: number
axisMin?: number axisMin?: number
axisMax?: number axisMax?: number
}
/** @deprecated Use ScientificAxisLabelOptions. */
export type ScientificYAxisLabelOptions = ScientificAxisLabelOptions & {
topTickValue?: number topTickValue?: number
} }
const DEFAULT_Y_AXIS_PRECISION = 2 /** @deprecated Use ScientificAxisLabelOptions. */
const SCIENTIFIC_MIN_ABSOLUTE_VALUE = 0.01 export type ScientificYAxisLabelOptions = ScientificAxisLabelOptions
const SCIENTIFIC_MAX_PLAIN_ABSOLUTE_VALUE = 100
const SCIENTIFIC_MIN_ABSOLUTE_VALUE = 0.001
const SCIENTIFIC_MAX_PLAIN_ABSOLUTE_VALUE = 1000
const Y_AXIS_NUMBER_FORMATTER = new Intl.NumberFormat('zh-CN', {
maximumFractionDigits: 4,
})
const TOOLTIP_NUMBER_FORMATTER = new Intl.NumberFormat('zh-CN', { const TOOLTIP_NUMBER_FORMATTER = new Intl.NumberFormat('zh-CN', {
maximumFractionDigits: 4, maximumFractionDigits: 4,
}) })
@@ -38,9 +42,10 @@ export function shouldUseScientificAxisLabel(maxAbsoluteValue: number): boolean
export function resolveScientificAxisExponent(axisMin?: number, axisMax?: number): number | null { export function resolveScientificAxisExponent(axisMin?: number, axisMax?: number): number | null {
if (typeof axisMin !== 'number' || typeof axisMax !== 'number') return null if (typeof axisMin !== 'number' || typeof axisMax !== 'number') return null
const maxAbsoluteValue = Math.max(Math.abs(axisMin), Math.abs(axisMax)) const maxValue = Math.max(axisMin, axisMax)
return shouldUseScientificAxisLabel(maxAbsoluteValue) const absoluteMaxValue = Math.abs(maxValue)
? Math.floor(Math.log10(maxAbsoluteValue)) return shouldUseScientificAxisLabel(absoluteMaxValue)
? Math.floor(Math.log10(absoluteMaxValue))
: null : null
} }
@@ -49,6 +54,13 @@ function formatExponent(exponent: number): string {
return `E${sign}${Math.abs(exponent).toString().padStart(2, '0')}` return `E${sign}${Math.abs(exponent).toString().padStart(2, '0')}`
} }
function formatYAxisNumber(value: number): string {
if (Object.is(value, -0)) return '0'
const roundedValue = value === 0 ? 0 : Number(value.toPrecision(5))
const formatted = Y_AXIS_NUMBER_FORMATTER.format(roundedValue)
return formatted === '-0' ? '0' : formatted
}
/** Format a Y-axis tick, sharing one exponent derived from the complete axis domain. */ /** Format a Y-axis tick, sharing one exponent derived from the complete axis domain. */
export function formatScientificAxisLabel( export function formatScientificAxisLabel(
value: number, value: number,
@@ -56,10 +68,13 @@ export function formatScientificAxisLabel(
): string { ): string {
if (!Number.isFinite(value)) return String(value) if (!Number.isFinite(value)) return String(value)
const precision = options.precision ?? DEFAULT_Y_AXIS_PRECISION
const exponent = resolveScientificAxisExponent(options.axisMin, options.axisMax) const exponent = resolveScientificAxisExponent(options.axisMin, options.axisMax)
const scaledValue = exponent === null ? value : value / 10 ** exponent const scaledValue = exponent === null ? value : value / 10 ** exponent
return formatFixedNumber(scaledValue, precision) const formattedValue = formatYAxisNumber(scaledValue)
const topTickValue = options.topTickValue ?? options.axisMax
return exponent !== null && value === topTickValue
? `${formatExponent(exponent)} ${formattedValue}`
: formattedValue
} }
/** Return the shared E-style multiplier for an axis, or null for a plain axis. */ /** Return the shared E-style multiplier for an axis, or null for a plain axis. */
@@ -119,6 +134,26 @@ export function displayTime(value: number, timeUnit: TimeUnit): number {
return timeUnit === 'ms' ? value * 1000 : value return timeUnit === 'ms' ? value * 1000 : value
} }
/** Format an X-axis value without changing its source coordinate. */
export function formatXAxisLabel(
rawValue: number,
domain: [number, number],
timeUnit: TimeUnit,
kind: WaveformXAxisLabelKind,
formatter?: WaveformXAxisLabelFormatter,
): string {
const value = displayTime(rawValue, timeUnit)
if (!formatter) return formatPlainNumber(value)
return formatter(value, {
kind,
rawValue,
timeUnit,
domain: [domain[0], domain[1]],
displayDomain: [displayTime(domain[0], timeUnit), displayTime(domain[1], timeUnit)],
})
}
/** /**
* 计算端点标签的小数位数 * 计算端点标签的小数位数
* @param domain 数据域 [最小值, 最大值] * @param domain 数据域 [最小值, 最大值]
@@ -134,54 +169,33 @@ export function endpointFractionDigits(domain: [number, number], timeUnit: TimeU
} }
/** /**
* 按完整 X 轴显示域格式化端点时间 * X 轴端点时间格式化为当前显示单位下的普通十进制文本
* @param value 时间值(秒) * @param value 时间值(秒)
* @param domain 数据域 * @param _domain 数据域(为保持现有调用签名而保留)
* @param timeUnit 时间单位 * @param timeUnit 时间单位
* @returns 格式化的时间字符串 * @returns 格式化的时间字符串
*/ */
export function formatEndpointTime( export function formatEndpointTime(
value: number, value: number,
domain: [number, number], _domain: [number, number],
timeUnit: TimeUnit, timeUnit: TimeUnit,
): string { ): string {
const [axisMin, axisMax] = domain.map((domainValue) => displayTime(domainValue, timeUnit)) as [ return formatXAxisLabel(value, _domain, timeUnit, 'end')
number,
number,
]
return formatScientificAxisLabel(displayTime(value, timeUnit), {
axisMin,
axisMax,
})
} }
/** /**
* 按完整 X 轴显示域格式化时间刻度 * X 轴时间刻度格式化为当前显示单位下的普通十进制文本
* @param value 时间值(秒) * @param value 时间值(秒)
* @param timeUnit 时间单位 * @param timeUnit 时间单位
* @param _domain 数据域(为保持现有调用签名而保留)
* @returns 格式化的时间字符串 * @returns 格式化的时间字符串
*/ */
export function formatAxisTime( export function formatAxisTime(
value: number, value: number,
timeUnit: TimeUnit, timeUnit: TimeUnit,
domain?: [number, number], _domain?: [number, number],
): string { ): string {
const displayValue = displayTime(value, timeUnit) return formatXAxisLabel(value, _domain ?? [value, value], timeUnit, 'tick')
const displayDomain = domain?.map((domainValue) => displayTime(domainValue, timeUnit)) as
[number, number] | undefined
return formatScientificAxisLabel(displayValue, {
axisMin: displayDomain?.[0],
axisMax: displayDomain?.[1],
})
}
/** Return the shared multiplier for an X-axis domain in its selected display unit. */
export function formatAxisTimeExponent(
domain: [number, number],
timeUnit: TimeUnit,
): string | null {
const [axisMin, axisMax] = domain.map((value) => displayTime(value, timeUnit)) as [number, number]
return formatScientificAxisExponent(axisMin, axisMax)
} }
/** /**
@@ -191,11 +205,7 @@ export function formatAxisTimeExponent(
* @returns 格式化的时间字符串 * @returns 格式化的时间字符串
*/ */
export function formatTooltipTime(value: number, timeUnit: TimeUnit): string { export function formatTooltipTime(value: number, timeUnit: TimeUnit): string {
const displayValue = displayTime(value, timeUnit) return formatTooltipNumber(displayTime(value, timeUnit))
return displayValue.toLocaleString('zh-CN', {
minimumFractionDigits: 4,
maximumFractionDigits: 4,
})
} }
/** Format an annotation X coordinate in the selected display time unit. */ /** Format an annotation X coordinate in the selected display time unit. */

View File

@@ -11,7 +11,7 @@ export {
endpointFractionDigits, endpointFractionDigits,
formatEndpointTime, formatEndpointTime,
formatAxisTime, formatAxisTime,
formatAxisTimeExponent, formatXAxisLabel,
formatTooltipTime, formatTooltipTime,
formatAnnotationTime, formatAnnotationTime,
formatPlainNumber, formatPlainNumber,