2 Commits

Author SHA1 Message Date
李启源
233b9b0aa2 feat(坐标轴): 支持显示纵轴单位
All checks were successful
Package component / package (push) Successful in 4m18s
2026-08-18 10:06:36 +08:00
李启源
5c71c28429 feat(视口): 支持恢复受控缩放范围
All checks were successful
Package component / package (push) Successful in 4m38s
2026-08-18 09:08:36 +08:00
16 changed files with 303 additions and 31 deletions

View File

@@ -253,6 +253,20 @@ X 轴且包含多个轨道时使用按稳定 track ID 索引的 `yRanges`。平
重置组件内部缩放并触发 `zoom-reset`;调用方应在事件中取消区间请求并恢复首次完整数据。
外部重置按钮也可以通过模板引用调用组件公开的 `resetViewport()` 方法,然后执行相同的数据恢复逻辑。
宿主在替换局部数据后如果需要恢复之前保存的 X 视口,可以调用公开的
`setViewportDomain(domain, trackIndex?)`。传入范围使用原始秒坐标,组件会按当前数据边界、
`xDomainStrategy``minZoomSpan``minVisiblePoints` 重新约束;无效范围会被忽略。共享
X 轴模式直接传入一个范围,独立模式可以按实际 `trackIndex` 分别设置(省略 `trackIndex`
时应用到当前所有图框):
```ts
chartRef.value?.setViewportDomain(previousDomain)
chartRef.value?.setViewportDomain(trackDomain, trackIndex)
```
建议在 `data` 引用更新后调用该方法。组件也会在数据引用变更时重投影当前内部视口,避免新
数据边界使旧 transform 失效;`resetViewport(trackIndex?)` 的既有行为保持不变。
没有显式配置初始范围时,可以通过 `xDomainStrategy` 将数据范围扩展为便于阅读的视口端点。
默认的 `{ type: 'data' }` 保持数据最小值和最大值不变;`type: 'nice'` 使用固定刻度数量计算
易读边界且只扩展视口不修改原始点位、tooltip、标注或缩放事件值
@@ -284,6 +298,34 @@ X 轴且包含多个轨道时使用按稳定 track ID 索引的 `yRanges`。平
Tooltip 每个系列按 `炮号:通道 (x:值 y:值)` 格式显示。`WaveformSeries.shotNo` 为空或未提供时,
炮号显示为“未配置炮号”Tooltip 不显示单位和误差附加文本。
### 纵轴单位
可在每个 `WaveformSeries` 上设置可选的 `unit`,单位会显示在对应 Y 轴的顶部刻度中:
```ts
const chartData = {
kind: 'series',
series: [
{
id: 'voltage',
name: '电压',
unit: 'V',
data: {
kind: 'points',
points: [
{ x: 0, y: 1200 },
{ x: 1, y: 3000 },
],
},
},
],
} satisfies WaveformData
```
当 Y 轴使用科学计数法时,顶部刻度格式为 `E+03 (V) 3`;未使用科学计数法时,格式为
`(V) 3`。单位为空、全为空白或未配置时,不显示 `(单位)`,例如仍显示为 `E+03 3``3`
多 Y 轴模式下,每根轴使用该轴首个 series 的单位。
### 线型、点型与误差棒
每条序列可以独立设置连线方式、数据点符号和误差棒:
@@ -655,7 +697,7 @@ async function importAnnotationFile(file: File) {
字段无效时会抛出 `TypeError`,不会返回部分结果。导入包含未知 `seriesId` 的标注是允许的,
对应曲线加载后会恢复显示。文件选择、错误提示和下载由业务层实现。
X 轴刻度和左右端点先按 `timeUnit` 转换为秒或毫秒,再显示为不带分组符和科学计数法的完整普通十进制值,并可通过 `axes.x.labelFormatter` 自定义。Y 轴会根据完整显示域选择格式:最大绝对值在 `[0.01, 100)` 时显示两位普通小数;大于等于 `100`,或大于 `0` 且小于 `0.01` 时,刻度显示两位缩放值,并在轴末端单独显示共享倍率 `E±NN`,多 Y 轴分别计算倍率。tooltip 使用最多 4 位小数的本地化普通数字并省略无意义尾零;标注编辑器的 X 坐标跟随 `timeUnit` 并固定 3 位小数Y 坐标显示完整普通十进制。所有格式化都只发生在展示层,内部坐标值保持原始精度。
X 轴刻度和左右端点先按 `timeUnit` 转换为秒或毫秒,再显示为不带分组符和科学计数法的完整普通十进制值,并可通过 `axes.x.labelFormatter` 自定义。Y 轴会根据完整显示域选择格式:最大绝对值在 `[0.01, 100)` 时显示两位普通小数;大于等于 `100`,或大于 `0` 且小于 `0.01` 时,刻度显示两位缩放值,并在顶部刻度单独显示共享倍率 `E±NN`。如果 series 配置了 `unit`,顶部刻度会在倍率后追加 `(单位)`;没有科学计数法时也会显示 `(单位)`,多 Y 轴分别使用对应轴首个 series 的单位。tooltip 使用最多 4 位小数的本地化普通数字并省略无意义尾零;标注编辑器的 X 坐标跟随 `timeUnit` 并固定 3 位小数Y 坐标显示完整普通十进制。所有格式化都只发生在展示层,内部坐标值保持原始精度。
标注框默认布局在采样点正上方,只做绘图区边界裁剪;文本框通过连接箭头指向标注位置,多个标注重叠时可通过拖动手动避让。
## 事件

View File

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

View File

@@ -36,6 +36,7 @@ const controller = useWaveformChartController(props as ResolvedWaveformChartProp
defineExpose({
resetViewport: controller.resetViewport,
setViewportDomain: controller.setViewportDomain,
})
</script>

View File

@@ -103,6 +103,7 @@ export function axisTextMetrics(
domain: [number, number],
nice = true,
tickValues?: number[],
unit?: string,
): { tickTextWidth: number } {
const scale = scaleLinear(domain, [1, 0])
if (nice) scale.nice()
@@ -112,7 +113,7 @@ export function axisTextMetrics(
const maximumTickCharacters = Math.max(
1,
...values.map(
(value) => formatScientificAxisLabel(value, { axisMin, axisMax, topTickValue }).length,
(value) => formatScientificAxisLabel(value, { axisMin, axisMax, topTickValue, unit }).length,
),
)
return {
@@ -122,7 +123,8 @@ export function axisTextMetrics(
export function measureYAxisGroupClearance(group: YAxisSeriesGroup): number {
return (
axisTextMetrics(group.domain, !group.fixed).tickTextWidth +
axisTextMetrics(group.domain, !group.fixed, undefined, group.seriesList[0]?.unit)
.tickTextWidth +
Y_AXIS_TICK_PADDING +
Y_AXIS_LABEL_GAP +
Y_AXIS_LABEL_BAND_WIDTH +
@@ -132,7 +134,8 @@ export function measureYAxisGroupClearance(group: YAxisSeriesGroup): number {
function measureYAxisGroupTickClearance(group: YAxisSeriesGroup): number {
return (
axisTextMetrics(group.domain, !group.fixed).tickTextWidth +
axisTextMetrics(group.domain, !group.fixed, undefined, group.seriesList[0]?.unit)
.tickTextWidth +
Y_AXIS_TICK_PADDING +
Y_AXIS_OUTER_PADDING
)

View File

@@ -135,7 +135,12 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou
const tickValues = Array.from(
new Set([axisStart, ...visibleMajorTicks, ...(showAxisEnd ? [axisEnd] : [])]),
)
const { tickTextWidth } = axisTextMetrics(group.domain, !group.fixed, tickValues)
const { tickTextWidth } = axisTextMetrics(
group.domain,
!group.fixed,
tickValues,
group.seriesList[0]?.unit,
)
const clearance =
tickTextWidth +
Y_AXIS_TICK_PADDING +

View File

@@ -259,6 +259,9 @@ export function useWaveformChartController(
trackLayouts,
innerWidth,
innerHeight,
sharedZoomDomain,
initialXDomain,
sharedTransform,
activeInteractionMode,
hiddenSeriesIdSet,
internalHiddenSeriesIds,

View File

@@ -39,6 +39,9 @@ interface LifecycleContext {
trackLayouts: ComputedRef<TrackLayout[]>
innerWidth: ComputedRef<number>
innerHeight: ComputedRef<number>
sharedZoomDomain: ComputedRef<[number, number]>
initialXDomain: ComputedRef<[number, number]>
sharedTransform: ShallowRef<ZoomTransform>
activeInteractionMode: ComputedRef<string | undefined>
hiddenSeriesIdSet: ComputedRef<Set<string>>
internalHiddenSeriesIds: Ref<Set<string>>
@@ -93,6 +96,9 @@ export function useWaveformChartLifecycle(context: LifecycleContext) {
trackLayouts,
innerWidth,
innerHeight,
sharedZoomDomain,
initialXDomain,
sharedTransform,
activeInteractionMode,
hiddenSeriesIdSet,
internalHiddenSeriesIds,
@@ -144,25 +150,24 @@ export function useWaveformChartLifecycle(context: LifecycleContext) {
emit('page-change', nextPage, pageCount.value)
}
let pendingSharedXDomain: [number, number] | undefined
let pendingIndependentXDomains: Array<[number, number] | undefined> | undefined
function handleBeforeDataReferenceChange() {
if (props.displayMode !== 'independent') return
if (props.displayMode === 'independent') {
pendingSharedXDomain = undefined
pendingIndependentXDomains = trackLayouts.value.map((track) => {
const configuredDomain =
props.initialXDomains?.[track.series.trackId ?? track.series.id] ??
props.initialXDomains?.[track.series.id] ??
props.initialXDomain
if (
!configuredDomain ||
!Number.isFinite(configuredDomain[0]) ||
!Number.isFinite(configuredDomain[1]) ||
configuredDomain[0] === configuredDomain[1]
) {
return undefined
}
return track.xScale.domain() as [number, number]
const current = track.xScale.domain() as [number, number]
const boundary = resolveInitialTrackDomain(track)
return current[1] - current[0] < boundary[1] - boundary[0] - 1e-12 ? current : undefined
})
return
}
pendingIndependentXDomains = undefined
const current = sharedZoomDomain.value
const boundary = initialXDomain.value
pendingSharedXDomain =
current[1] - current[0] < boundary[1] - boundary[0] - 1e-12 ? [...current] : undefined
}
function handleDataReferenceChange() {
@@ -186,10 +191,15 @@ export function useWaveformChartLifecycle(context: LifecycleContext) {
})
independentTransforms.value = nextTransforms
pendingIndependentXDomains = undefined
void nextTick(configureZoom)
return
} else if (props.displayMode !== 'independent' && pendingSharedXDomain) {
const boundary = initialXDomain.value
const groups = trackLayouts.value
.filter((track) => track.hasVisibleSeries)
.map((track) => track.seriesList)
const domain = constrainZoomDomain(pendingSharedXDomain, boundary, groups, props)
sharedTransform.value = transformForDomain(domain, boundary, innerWidth.value)
pendingSharedXDomain = undefined
}
pendingIndependentXDomains = undefined
configureZoom()
})
}

View File

@@ -112,7 +112,11 @@ export function useWaveformLayout(context: LayoutContext) {
.flatMap((track) =>
resolveYAxisSeriesGroups(track, props.overlayMode, props.yDomain, props.yDomains),
)
.map((group) => axisTextMetrics(group.domain, !group.fixed).tickTextWidth)
.map(
(group) =>
axisTextMetrics(group.domain, !group.fixed, undefined, group.seriesList[0]?.unit)
.tickTextWidth,
)
const tickTextWidth = Math.max(Y_AXIS_CHARACTER_WIDTH, ...axisText)
const tickClearance = tickTextWidth + Y_AXIS_TICK_PADDING + Y_AXIS_OUTER_PADDING
const labelCenterX = -(

View File

@@ -11,6 +11,7 @@ import type {
import type { AnnotationSeriesCandidate } from '../annotation'
import { tryReleasePointerCapture } from './pointerCapture'
import { transitionViewportInteraction } from './viewportInteractionState'
import { createViewportDomainSetter } from './viewportDomain'
import { constrainZoomDomain, transformForDomain } from './zoomConstraints'
interface ViewportContext {
props: ResolvedWaveformChartProps
@@ -344,6 +345,20 @@ export function useWaveformViewport(context: ViewportContext) {
editorSeriesOptions.value = []
void nextTick(configureZoom)
}
const setViewportDomain = createViewportDomainSetter({
props,
trackLayouts,
initialXDomain,
resolveInitialTrackDomain,
innerWidth,
sharedTransform,
independentTransforms,
cancelPendingZoom,
cancelViewportDrag,
clearHover,
editorSeriesOptions,
configureZoom,
})
const requestViewportReset = (event: MouseEvent) => {
if (isPresentationMode.value || !props.zoomable || !isZoomMode.value) return
if (props.displayMode === 'independent') {
@@ -372,6 +387,7 @@ export function useWaveformViewport(context: ViewportContext) {
finishViewportDrag,
cancelViewportDrag,
resetViewport,
setViewportDomain,
requestViewportReset,
}
}

View File

@@ -0,0 +1,60 @@
import { nextTick, type ComputedRef, type Ref, type ShallowRef } from 'vue'
import type { ZoomTransform } from 'd3'
import type { TrackLayout } from '../core/types'
import type { ResolvedWaveformChartProps } from '../core/waveformChartTypes'
import type { AnnotationSeriesCandidate } from '../annotation'
import { constrainZoomDomain, transformForDomain } from './zoomConstraints'
interface ViewportDomainContext {
props: ResolvedWaveformChartProps
trackLayouts: ComputedRef<TrackLayout[]>
initialXDomain: ComputedRef<[number, number]>
resolveInitialTrackDomain: (track: TrackLayout) => [number, number]
innerWidth: ComputedRef<number>
sharedTransform: ShallowRef<ZoomTransform>
independentTransforms: ShallowRef<ZoomTransform[]>
cancelPendingZoom: () => void
cancelViewportDrag: () => void
clearHover: () => void
editorSeriesOptions: Ref<AnnotationSeriesCandidate[]>
configureZoom: () => void
}
export function createViewportDomainSetter(context: ViewportDomainContext) {
return (domain: [number, number], trackIndex?: number) => {
if (!Number.isFinite(domain[0]) || !Number.isFinite(domain[1]) || domain[0] === domain[1])
return
context.cancelPendingZoom()
context.cancelViewportDrag()
if (context.props.displayMode === 'independent') {
const indexes =
trackIndex === undefined
? context.trackLayouts.value.map((track) => track.index)
: [trackIndex]
const nextTransforms = [...context.independentTransforms.value]
indexes.forEach((index) => {
const track = context.trackLayouts.value.find((item) => item.index === index)
if (!track) return
const boundary = context.resolveInitialTrackDomain(track)
const constrained = constrainZoomDomain(domain, boundary, [track.seriesList], context.props)
nextTransforms[index] = transformForDomain(constrained, boundary, track.width)
})
context.independentTransforms.value = nextTransforms
} else {
const boundary = context.initialXDomain.value
const groups = context.trackLayouts.value
.filter((track) => track.hasVisibleSeries)
.map((track) => track.seriesList)
const constrained = constrainZoomDomain(domain, boundary, groups, context.props)
context.sharedTransform.value = transformForDomain(
constrained,
boundary,
context.innerWidth.value,
)
}
context.clearHover()
context.editorSeriesOptions.value = []
void nextTick(context.configureZoom)
}
}

View File

@@ -44,9 +44,10 @@ function renderAxes() {
if (!element) return
const [axisMin, axisMax] = axis.scale.domain()
const topTickValue = Math.max(...axis.tickValues)
const unit = axis.seriesList[0]?.unit
const yAxis = (axis.side === 'left' ? axisLeft(axis.scale) : axisRight(axis.scale))
.tickFormat((value) =>
formatScientificAxisLabel(Number(value), { axisMin, axisMax, topTickValue }),
formatScientificAxisLabel(Number(value), { axisMin, axisMax, topTickValue, unit }),
)
.tickSize(-4)
.tickPadding(7)

View File

@@ -158,6 +158,12 @@ describe('WaveformChart', () => {
expect(paths[0].attributes('data-series-name')).toBe('BT2_2M')
const yAxisLabels = wrapper.findAll('.waveform-chart__y-axis-label')
expect(yAxisLabels.map((label) => label.text())).toEqual(['BT2_2M', 'BT1_2M'])
expect(
wrapper
.findAll('.waveform-chart__axis--y .tick text')
.map((label) => label.text())
.some((label) => label.includes('(T)')),
).toBe(true)
expect(yAxisLabels.map((label) => label.attributes('fill'))).toEqual(['#0960bd', '#ff7f0e'])
const labelX = Number(
yAxisLabels[0].attributes('transform')?.match(/^translate\(([-\d.]+),/)?.[1],

View File

@@ -15,6 +15,7 @@ describe('WaveformChart', () => {
{
trackId: 'shared-frame',
name: '普通量程',
unit: 'V',
data: {
kind: 'points',
points: [
@@ -26,6 +27,7 @@ describe('WaveformChart', () => {
{
trackId: 'shared-frame',
name: '大量程',
unit: 'A',
data: {
kind: 'points',
points: [
@@ -37,6 +39,7 @@ describe('WaveformChart', () => {
{
trackId: 'shared-frame',
name: '小量程',
unit: 'T',
data: {
kind: 'points',
points: [
@@ -57,7 +60,11 @@ describe('WaveformChart', () => {
.map((label) => label.text())
.filter((label) => label.startsWith('E')),
),
).toEqual([[], [expect.stringMatching(/^E\+03 /)], [expect.stringMatching(/^E-04 /)]])
).toEqual([
[],
[expect.stringMatching(/^E\+03 \(A\) /)],
[expect.stringMatching(/^E-04 \(T\) /)],
])
})
it('reprojects annotations with the Y axis assigned to their series', async () => {

View File

@@ -119,6 +119,108 @@ describe('WaveformChart viewport reset', () => {
expect(wrapper.findAll('.waveform-chart__axis-endpoint--end')[0].text()).toBe('1000')
})
it('keeps a controlled shared viewport when the data reference changes', async () => {
const wrapper = await mountSizedChart(
{
kind: 'points',
points: [
{ x: 0, y: 0 },
{ x: 1, y: 1 },
],
},
{ displayMode: 'separated' },
)
const chart = wrapper.vm as unknown as {
setViewportDomain: (domain: [number, number], trackIndex?: number) => void
}
chart.setViewportDomain([0.25, 0.75])
await flushPromises()
const startBefore = Number(wrapper.get('.waveform-chart__axis-endpoint--start').text())
const endBefore = Number(wrapper.get('.waveform-chart__axis-endpoint--end').text())
expect(startBefore).toBeGreaterThan(0)
expect(endBefore).toBeLessThan(1000)
await wrapper.setProps({
data: {
kind: 'points',
points: [
{ x: 0, y: 10 },
{ x: 1, y: 11 },
],
},
})
await flushPromises()
expect(Number(wrapper.get('.waveform-chart__axis-endpoint--start').text())).toBeCloseTo(
startBefore,
10,
)
expect(Number(wrapper.get('.waveform-chart__axis-endpoint--end').text())).toBeCloseTo(
endBefore,
10,
)
})
it('keeps independently controlled track viewports when the data reference changes', async () => {
const wrapper = await mountSizedChart(gridSeries(2), {
displayMode: 'independent',
grid: { rowCount: 1, columnCount: 2 },
})
const chart = wrapper.vm as unknown as {
setViewportDomain: (domain: [number, number], trackIndex?: number) => void
}
chart.setViewportDomain([0.2, 0.8], 0)
chart.setViewportDomain([0.1, 0.9], 1)
await flushPromises()
const endpoints = () =>
wrapper.findAll('.waveform-chart__track').map((track) => ({
start: track.get('.waveform-chart__axis-endpoint--start').text(),
end: track.get('.waveform-chart__axis-endpoint--end').text(),
}))
const endpointsBefore = endpoints()
expect(Number(endpointsBefore[0].start)).toBeGreaterThan(0)
expect(Number(endpointsBefore[0].end)).toBeLessThan(1000)
expect(Number(endpointsBefore[1].start)).toBeGreaterThan(0)
expect(Number(endpointsBefore[1].end)).toBeLessThan(1000)
await wrapper.setProps({ data: gridSeries(2) })
await flushPromises()
const endpointsAfter = endpoints()
endpointsAfter.forEach((endpoint, index) => {
expect(Number(endpoint.start)).toBeCloseTo(Number(endpointsBefore[index].start), 10)
expect(Number(endpoint.end)).toBeCloseTo(Number(endpointsBefore[index].end), 10)
})
})
it('ignores invalid domains and applies viewport constraints', async () => {
const wrapper = await mountSizedChart(
{
kind: 'points',
points: [
{ x: 0, y: 0 },
{ x: 1, y: 1 },
],
},
{ displayMode: 'separated', minZoomSpan: 0.2 },
)
const chart = wrapper.vm as unknown as {
setViewportDomain: (domain: [number, number], trackIndex?: number) => void
}
chart.setViewportDomain([Number.NaN, 0.5])
await flushPromises()
expect(wrapper.get('.waveform-chart__axis-endpoint--start').text()).toBe('0')
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('1000')
chart.setViewportDomain([0.49, 0.51])
await flushPromises()
const start = Number(wrapper.get('.waveform-chart__axis-endpoint--start').text())
const end = Number(wrapper.get('.waveform-chart__axis-endpoint--end').text())
expect(end - start).toBeGreaterThanOrEqual(200)
})
it('resets shared and independent explicit domains to their included nice bounds', async () => {
const shared = await mountSizedChart(
{

View File

@@ -40,6 +40,15 @@ describe('waveform number formatters', () => {
expect(formatScientificAxisExponent(0.0001, 0.0003)).toBe('E-04')
})
it('appends a unit to the top tick with or without scientific notation', () => {
expect(formatScientificAxisLabel(3000, { axisMin: 1000, axisMax: 3000, unit: 'V' })).toBe(
'E+03 (V) 3',
)
expect(formatScientificAxisLabel(3, { axisMin: 0, axisMax: 3, unit: 'A' })).toBe('(A) 3')
expect(formatScientificAxisLabel(2, { axisMin: 0, axisMax: 3, unit: 'A' })).toBe('2')
expect(formatScientificAxisLabel(3, { axisMin: 0, axisMax: 3, unit: ' ' })).toBe('3')
})
it('derives the exponent from the largest absolute endpoint', () => {
expect(resolveScientificAxisExponent(-9000, -1000)).toBe(3)
expect(resolveScientificAxisExponent(-100_000, -3000)).toBe(5)

View File

@@ -11,6 +11,7 @@ export interface ScientificAxisLabelOptions {
axisMin?: number
axisMax?: number
topTickValue?: number
unit?: string
}
/** @deprecated Use ScientificAxisLabelOptions. */
@@ -71,9 +72,11 @@ export function formatScientificAxisLabel(
const scaledValue = exponent === null ? value : value / 10 ** exponent
const formattedValue = formatYAxisNumber(scaledValue)
const topTickValue = options.topTickValue ?? options.axisMax
return exponent !== null && value === topTickValue
? `${formatExponent(exponent)} ${formattedValue}`
: formattedValue
if (value !== topTickValue) return formattedValue
const unit = options.unit?.trim()
const unitLabel = unit ? `(${unit}) ` : ''
const exponentLabel = exponent === null ? '' : `${formatExponent(exponent)} `
return `${exponentLabel}${unitLabel}${formattedValue}`
}
/** Return the shared E-style multiplier for an axis, or null for a plain axis. */