Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7637f411d7 | ||
|
|
8987cae846 | ||
|
|
233b9b0aa2 | ||
|
|
5c71c28429 |
66
README.md
66
README.md
@@ -114,8 +114,8 @@ const data = ref<WaveformData>({
|
|||||||
| `xDomainStrategy` | `WaveformXDomainStrategy` | `{ type: 'data' }` | 自动 X 轴视口范围策略 |
|
| `xDomainStrategy` | `WaveformXDomainStrategy` | `{ type: 'data' }` | 自动 X 轴视口范围策略 |
|
||||||
| `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, fillIncompleteLastRow: false }` | 网格和分页 |
|
||||||
| `axes` | `WaveformAxesOptions` | 轴线均显示 | X/Y 轴基线显隐与 X 轴 label 格式化 |
|
| `axes` | `WaveformAxesOptions` | 轴线均显示 | X/Y 轴基线、Y 轴分割数与 X 轴 label 格式化 |
|
||||||
| `rendering` | `WaveformRenderingOptions` | `{}` | 降采样与点/误差棒间距 |
|
| `rendering` | `WaveformRenderingOptions` | `{}` | 降采样与点/误差棒间距 |
|
||||||
| `title` / `legend` / `frameStyle` | 对应公开类型 | 未设置 | 标题、图例和图框样式 |
|
| `title` / `legend` / `frameStyle` | 对应公开类型 | 未设置 | 标题、图例和图框样式 |
|
||||||
| `frameNumber` | `string \| number` | 未设置 | 图框水印内容 |
|
| `frameNumber` | `string \| number` | 未设置 | 图框水印内容 |
|
||||||
@@ -208,7 +208,8 @@ import 'waveform-analysis/style.css'
|
|||||||
|
|
||||||
范围优先级为 `trackId` 配置、`seriesId` 配置、全局 `yDomain`、数据自动范围。上下限必须
|
范围优先级为 `trackId` 配置、`seriesId` 配置、全局 `yDomain`、数据自动范围。上下限必须
|
||||||
是两个有限且不相等的数字;倒序范围会自动调整为升序,无效配置会回退到下一优先级。
|
是两个有限且不相等的数字;倒序范围会自动调整为升序,无效配置会回退到下一优先级。
|
||||||
固定范围会精确作为坐标域使用,不会经过 D3 的 `nice()` 扩展。
|
固定范围作为 Y 轴刻度算法的初始范围;轴会按漂亮步长经过 D3 的 `nice()` 扩展,确保主刻度
|
||||||
|
等间距。原始数据和传入的范围值不会被修改。
|
||||||
|
|
||||||
单值轴叠加模式会合并同一根轴上所有可见系列的有效范围;多值轴模式按系列分别使用配置,
|
单值轴叠加模式会合并同一根轴上所有可见系列的有效范围;多值轴模式按系列分别使用配置,
|
||||||
超过四根轴后复用第 4 根轴的系列会取范围并集。隐藏系列不参与公共范围合并。
|
超过四根轴后复用第 4 根轴的系列会取范围并集。隐藏系列不参与公共范围合并。
|
||||||
@@ -216,6 +217,16 @@ import 'waveform-analysis/style.css'
|
|||||||
固定范围存在时,对应图框的 Y 轴不会被平移或视口重置覆盖;X 轴缩放、平移和重置保持原有
|
固定范围存在时,对应图框的 Y 轴不会被平移或视口重置覆盖;X 轴缩放、平移和重置保持原有
|
||||||
行为。运行时更新或移除 `yDomain` / `yDomains` 会立即重新布局,移除后恢复自动范围。
|
行为。运行时更新或移除 `yDomain` / `yDomains` 会立即重新布局,移除后恢复自动范围。
|
||||||
|
|
||||||
|
### Y 轴自动等分
|
||||||
|
|
||||||
|
Y 轴默认显示 5 个主刻度(包含上下端点),并使用类似 ECharts 的 `1 / 2 / 5 × 10ⁿ` 漂亮
|
||||||
|
步长,保证主刻度之间数值等间距。可以通过 `axes.y.splitNumber` 指定主刻度总数;每条
|
||||||
|
Y 轴会独立计算:
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<WaveformChart :data="chartData" :axes="{ y: { splitNumber: 5 } }" />
|
||||||
|
```
|
||||||
|
|
||||||
### 缩放后按可视区间加载数据
|
### 缩放后按可视区间加载数据
|
||||||
|
|
||||||
组件支持 Plotly 风格的矩形框选缩放:在 zoom 模式下按住鼠标左键拖拽,松开后同时缩放
|
组件支持 Plotly 风格的矩形框选缩放:在 zoom 模式下按住鼠标左键拖拽,松开后同时缩放
|
||||||
@@ -253,6 +264,20 @@ X 轴且包含多个轨道时使用按稳定 track ID 索引的 `yRanges`。平
|
|||||||
重置组件内部缩放并触发 `zoom-reset`;调用方应在事件中取消区间请求并恢复首次完整数据。
|
重置组件内部缩放并触发 `zoom-reset`;调用方应在事件中取消区间请求并恢复首次完整数据。
|
||||||
外部重置按钮也可以通过模板引用调用组件公开的 `resetViewport()` 方法,然后执行相同的数据恢复逻辑。
|
外部重置按钮也可以通过模板引用调用组件公开的 `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` 将数据范围扩展为便于阅读的视口端点。
|
没有显式配置初始范围时,可以通过 `xDomainStrategy` 将数据范围扩展为便于阅读的视口端点。
|
||||||
默认的 `{ type: 'data' }` 保持数据最小值和最大值不变;`type: 'nice'` 使用固定刻度数量计算
|
默认的 `{ type: 'data' }` 保持数据最小值和最大值不变;`type: 'nice'` 使用固定刻度数量计算
|
||||||
易读边界,且只扩展视口,不修改原始点位、tooltip、标注或缩放事件值:
|
易读边界,且只扩展视口,不修改原始点位、tooltip、标注或缩放事件值:
|
||||||
@@ -284,6 +309,34 @@ X 轴且包含多个轨道时使用按稳定 track ID 索引的 `yRanges`。平
|
|||||||
Tooltip 每个系列按 `炮号:通道 (x:值 y:值)` 格式显示。`WaveformSeries.shotNo` 为空或未提供时,
|
Tooltip 每个系列按 `炮号:通道 (x:值 y:值)` 格式显示。`WaveformSeries.shotNo` 为空或未提供时,
|
||||||
炮号显示为“未配置炮号”;Tooltip 不显示单位和误差附加文本。
|
炮号显示为“未配置炮号”;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 的单位。
|
||||||
|
|
||||||
### 线型、点型与误差棒
|
### 线型、点型与误差棒
|
||||||
|
|
||||||
每条序列可以独立设置连线方式、数据点符号和误差棒:
|
每条序列可以独立设置连线方式、数据点符号和误差棒:
|
||||||
@@ -491,6 +544,10 @@ scale 定位零线:
|
|||||||
`grid` 控制独立图框的行列数(范围 `1–10`)以及是否显示分页器。默认值为 `2` 行、
|
`grid` 控制独立图框的行列数(范围 `1–10`)以及是否显示分页器。默认值为 `2` 行、
|
||||||
`1` 列并开启分页;当图框数量超过网格容量时,分页器会显示在图表右下角。
|
`1` 列并开启分页;当图框数量超过网格容量时,分页器会显示在图表右下角。
|
||||||
|
|
||||||
|
`fillIncompleteLastRow` 默认关闭。开启后,分页容量仍由 `rowCount * columnCount` 决定;
|
||||||
|
最后一页或未满页会移除没有数据的整行,并将最后一行的实际图框等宽铺满可用宽度。例如 2 列
|
||||||
|
网格的最后一行只有一个图框时,该图框会占满整行。完整页和关闭该选项时的布局保持不变。
|
||||||
|
|
||||||
还可以通过 `trackLines` 按轨道 ID 分别控制水平/垂直网格线的显隐和颜色。颜色未配置时,
|
还可以通过 `trackLines` 按轨道 ID 分别控制水平/垂直网格线的显隐和颜色。颜色未配置时,
|
||||||
继续使用组件默认的主/次网格颜色:
|
继续使用组件默认的主/次网格颜色:
|
||||||
|
|
||||||
@@ -501,6 +558,7 @@ scale 定位零线:
|
|||||||
rowCount: 2,
|
rowCount: 2,
|
||||||
columnCount: 2,
|
columnCount: 2,
|
||||||
showPagination: true,
|
showPagination: true,
|
||||||
|
fillIncompleteLastRow: true,
|
||||||
trackLines: {
|
trackLines: {
|
||||||
voltage: {
|
voltage: {
|
||||||
horizontal: false,
|
horizontal: false,
|
||||||
@@ -655,7 +713,7 @@ async function importAnnotationFile(file: File) {
|
|||||||
字段无效时会抛出 `TypeError`,不会返回部分结果。导入包含未知 `seriesId` 的标注是允许的,
|
字段无效时会抛出 `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 坐标显示完整普通十进制。所有格式化都只发生在展示层,内部坐标值保持原始精度。
|
||||||
标注框默认布局在采样点正上方,只做绘图区边界裁剪;文本框通过连接箭头指向标注位置,多个标注重叠时可通过拖动手动避让。
|
标注框默认布局在采样点正上方,只做绘图区边界裁剪;文本框通过连接箭头指向标注位置,多个标注重叠时可通过拖动手动避让。
|
||||||
|
|
||||||
## 事件
|
## 事件
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "waveform-analysis",
|
"name": "waveform-analysis",
|
||||||
"version": "0.1.38",
|
"version": "0.1.42",
|
||||||
"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",
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ const controller = useWaveformChartController(props as ResolvedWaveformChartProp
|
|||||||
|
|
||||||
defineExpose({
|
defineExpose({
|
||||||
resetViewport: controller.resetViewport,
|
resetViewport: controller.resetViewport,
|
||||||
|
setViewportDomain: controller.setViewportDomain,
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,33 @@
|
|||||||
|
/* The modal is teleported to body. Keep its entire subtree namespaced so host
|
||||||
|
application resets (for example `button { ... }`) cannot change the editor. */
|
||||||
|
:global(.waveform-annotation-editor-root),
|
||||||
|
:global(.waveform-annotation-editor-root *) {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.waveform-annotation-editor-root) {
|
||||||
|
color: #344054;
|
||||||
|
font-family:
|
||||||
|
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
|
||||||
|
'Noto Sans', sans-serif;
|
||||||
|
line-height: 1.5715;
|
||||||
|
text-align: left;
|
||||||
|
text-transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.waveform-annotation-editor-root button),
|
||||||
|
:global(.waveform-annotation-editor-root input),
|
||||||
|
:global(.waveform-annotation-editor-root select),
|
||||||
|
:global(.waveform-annotation-editor-root textarea) {
|
||||||
|
font-family: inherit;
|
||||||
|
letter-spacing: normal;
|
||||||
|
text-transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.waveform-annotation-editor-root button) {
|
||||||
|
appearance: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.waveform-annotation-editor__content {
|
.waveform-annotation-editor__content {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
|
|||||||
@@ -126,6 +126,7 @@ function handleSeriesChange(event: Event) {
|
|||||||
cancel-text="取消"
|
cancel-text="取消"
|
||||||
ok-text="保存标注"
|
ok-text="保存标注"
|
||||||
:ok-button-props="{ disabled: !canConfirm }"
|
:ok-button-props="{ disabled: !canConfirm }"
|
||||||
|
root-class-name="waveform-annotation-editor-root"
|
||||||
wrap-class-name="waveform-annotation-editor"
|
wrap-class-name="waveform-annotation-editor"
|
||||||
@cancel="emit('cancel')"
|
@cancel="emit('cancel')"
|
||||||
@ok="confirm"
|
@ok="confirm"
|
||||||
|
|||||||
@@ -18,13 +18,14 @@ const modalStub = defineComponent({
|
|||||||
'cancelText',
|
'cancelText',
|
||||||
'okText',
|
'okText',
|
||||||
'okButtonProps',
|
'okButtonProps',
|
||||||
|
'rootClassName',
|
||||||
],
|
],
|
||||||
emits: ['cancel', 'ok'],
|
emits: ['cancel', 'ok'],
|
||||||
setup(props, { emit, slots }) {
|
setup(props, { emit, slots }) {
|
||||||
return () => {
|
return () => {
|
||||||
const title = slots.title?.() ?? []
|
const title = slots.title?.() ?? []
|
||||||
const titleId = (title[0]?.props as { id?: string } | undefined)?.id
|
const titleId = (title[0]?.props as { id?: string } | undefined)?.id
|
||||||
return h('div', { class: 'ant-modal-root' }, [
|
return h('div', { class: ['ant-modal-root', props.rootClassName] }, [
|
||||||
h(
|
h(
|
||||||
'div',
|
'div',
|
||||||
{
|
{
|
||||||
@@ -96,6 +97,18 @@ describe('waveform annotation controls', () => {
|
|||||||
expect(second.get('[role="dialog"]').attributes('aria-labelledby')).toBe(secondTitleId)
|
expect(second.get('[role="dialog"]').attributes('aria-labelledby')).toBe(secondTitleId)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('uses a dedicated root class to isolate teleported modal styles', () => {
|
||||||
|
const wrapper = mountEditor({
|
||||||
|
props: {
|
||||||
|
annotation: { id: 'isolated', seriesId: 'a', x: 1, y: 2, text: '说明' },
|
||||||
|
mode: 'add',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(wrapper.get('.ant-modal-root').classes()).toContain('waveform-annotation-editor-root')
|
||||||
|
expect(wrapper.get('.ant-modal-wrap').classes()).toContain('waveform-annotation-editor')
|
||||||
|
})
|
||||||
|
|
||||||
it.each(['add', 'edit'] as const)('shows the X-axis snapping hint in %s mode', (mode) => {
|
it.each(['add', 'edit'] as const)('shows the X-axis snapping hint in %s mode', (mode) => {
|
||||||
const wrapper = mountEditor({
|
const wrapper = mountEditor({
|
||||||
props: {
|
props: {
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ function track(seriesList: DisplaySeries[]): DisplayTrack {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('fixed Y-domain layout', () => {
|
describe('fixed Y-domain layout', () => {
|
||||||
it('uses an exact global fixed domain without applying nice bounds', () => {
|
it('expands a global fixed domain to nice equal intervals', () => {
|
||||||
const sourceTrack = track([series('a', 0, 100)])
|
const sourceTrack = track([series('a', 0, 100)])
|
||||||
const result = buildTrackLayouts({
|
const result = buildTrackLayouts({
|
||||||
cells: [
|
cells: [
|
||||||
@@ -53,7 +53,13 @@ describe('fixed Y-domain layout', () => {
|
|||||||
series: sourceTrack,
|
series: sourceTrack,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
grid: { rowCount: 1, columnCount: 1, showPagination: false, trackLines: {} },
|
grid: {
|
||||||
|
rowCount: 1,
|
||||||
|
columnCount: 1,
|
||||||
|
showPagination: false,
|
||||||
|
fillIncompleteLastRow: false,
|
||||||
|
trackLines: {},
|
||||||
|
},
|
||||||
displayMode: 'independent',
|
displayMode: 'independent',
|
||||||
overlayMode: 'single-axis',
|
overlayMode: 'single-axis',
|
||||||
independentTransforms: [zoomIdentity],
|
independentTransforms: [zoomIdentity],
|
||||||
@@ -66,10 +72,92 @@ describe('fixed Y-domain layout', () => {
|
|||||||
showCompactEmptyTracks: false,
|
showCompactEmptyTracks: false,
|
||||||
})[0]
|
})[0]
|
||||||
|
|
||||||
expect(result?.yScale.domain()).toEqual([3, 97])
|
expect(result?.yScale.domain()).toEqual([0, 100])
|
||||||
expect(result?.yAxes[0]?.tickValues).toContain(3)
|
expect(result?.yAxes[0]?.tickValues).toContain(0)
|
||||||
expect(result?.yAxes[0]?.tickValues).toContain(97)
|
expect(result?.yAxes[0]?.tickValues).toContain(100)
|
||||||
expect(result?.seriesPaths[0]?.yScale.domain()).toEqual([3, 97])
|
expect(result?.seriesPaths[0]?.yScale.domain()).toEqual([0, 100])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses the configured split number for fixed domains', () => {
|
||||||
|
const result = buildTrackLayouts({
|
||||||
|
cells: [
|
||||||
|
{
|
||||||
|
slotIndex: 0,
|
||||||
|
row: 0,
|
||||||
|
column: 0,
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
width: 120,
|
||||||
|
height: 100,
|
||||||
|
plotHeight: 100,
|
||||||
|
cellHeight: 130,
|
||||||
|
xAxisBand: 30,
|
||||||
|
series: track([series('a', 0, 100)]),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
grid: {
|
||||||
|
rowCount: 1,
|
||||||
|
columnCount: 1,
|
||||||
|
showPagination: false,
|
||||||
|
fillIncompleteLastRow: false,
|
||||||
|
trackLines: {},
|
||||||
|
},
|
||||||
|
displayMode: 'independent',
|
||||||
|
overlayMode: 'single-axis',
|
||||||
|
independentTransforms: [zoomIdentity],
|
||||||
|
sharedZoomDomain: [0, 1],
|
||||||
|
fixedYDomain: [3, 97],
|
||||||
|
yAxisSplitNumber: 5,
|
||||||
|
timeUnit: 'ms',
|
||||||
|
rendering: DEFAULT_WAVEFORM_RENDERING_OPTIONS,
|
||||||
|
hideSecondaryLabels: false,
|
||||||
|
yAxisLabelX: -50,
|
||||||
|
showCompactEmptyTracks: false,
|
||||||
|
})[0]
|
||||||
|
|
||||||
|
expect(result?.yAxes[0]?.majorTicks).toEqual([0, 25, 50, 75, 100])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('defaults to five ticks and supports a two-tick axis', () => {
|
||||||
|
const build = (splitNumber?: number) =>
|
||||||
|
buildTrackLayouts({
|
||||||
|
cells: [
|
||||||
|
{
|
||||||
|
slotIndex: 0,
|
||||||
|
row: 0,
|
||||||
|
column: 0,
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
width: 120,
|
||||||
|
height: 100,
|
||||||
|
plotHeight: 100,
|
||||||
|
cellHeight: 130,
|
||||||
|
xAxisBand: 30,
|
||||||
|
series: track([series('a', 3, 97)]),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
grid: {
|
||||||
|
rowCount: 1,
|
||||||
|
columnCount: 1,
|
||||||
|
showPagination: false,
|
||||||
|
fillIncompleteLastRow: false,
|
||||||
|
trackLines: {},
|
||||||
|
},
|
||||||
|
displayMode: 'independent',
|
||||||
|
overlayMode: 'single-axis',
|
||||||
|
independentTransforms: [zoomIdentity],
|
||||||
|
sharedZoomDomain: [0, 1],
|
||||||
|
fixedYDomain: [3, 97],
|
||||||
|
yAxisSplitNumber: splitNumber,
|
||||||
|
timeUnit: 'ms',
|
||||||
|
rendering: DEFAULT_WAVEFORM_RENDERING_OPTIONS,
|
||||||
|
hideSecondaryLabels: false,
|
||||||
|
yAxisLabelX: -50,
|
||||||
|
showCompactEmptyTracks: false,
|
||||||
|
})[0]?.yAxes[0]?.majorTicks
|
||||||
|
|
||||||
|
expect(build()).toHaveLength(5)
|
||||||
|
expect(build(2)).toEqual([0, 100])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('resolves track, series, and global fixed-domain precedence', () => {
|
it('resolves track, series, and global fixed-domain precedence', () => {
|
||||||
|
|||||||
@@ -15,12 +15,14 @@ describe('waveform grid helpers', () => {
|
|||||||
rowCount: 2,
|
rowCount: 2,
|
||||||
columnCount: 1,
|
columnCount: 1,
|
||||||
showPagination: true,
|
showPagination: true,
|
||||||
|
fillIncompleteLastRow: false,
|
||||||
trackLines: {},
|
trackLines: {},
|
||||||
})
|
})
|
||||||
expect(normalizeGridOptions({ rowCount: 0, columnCount: 99 })).toEqual({
|
expect(normalizeGridOptions({ rowCount: 0, columnCount: 99 })).toEqual({
|
||||||
rowCount: 1,
|
rowCount: 1,
|
||||||
columnCount: 10,
|
columnCount: 10,
|
||||||
showPagination: true,
|
showPagination: true,
|
||||||
|
fillIncompleteLastRow: false,
|
||||||
trackLines: {},
|
trackLines: {},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -131,4 +133,50 @@ describe('waveform grid helpers', () => {
|
|||||||
expect(cells[1].left).toBe(232)
|
expect(cells[1].left).toBe(232)
|
||||||
expect(cells[2].top).toBe(cells[0].plotHeight + X_AXIS_BAND + 14)
|
expect(cells[2].top).toBe(cells[0].plotHeight + X_AXIS_BAND + 14)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('fills incomplete final rows without changing page capacity', () => {
|
||||||
|
const oneColumn = resolveGridCellGeometry(
|
||||||
|
400,
|
||||||
|
400,
|
||||||
|
normalizeGridOptions({ rowCount: 4, columnCount: 1, fillIncompleteLastRow: true }),
|
||||||
|
'separated',
|
||||||
|
[true, true, true],
|
||||||
|
)
|
||||||
|
expect(oneColumn).toHaveLength(3)
|
||||||
|
expect(oneColumn.map((cell) => cell.width)).toEqual([400, 400, 400])
|
||||||
|
expect(oneColumn.at(-1)?.isLastRow).toBe(true)
|
||||||
|
|
||||||
|
const twoColumns = resolveGridCellGeometry(
|
||||||
|
400,
|
||||||
|
300,
|
||||||
|
normalizeGridOptions({ rowCount: 2, columnCount: 2, fillIncompleteLastRow: true }),
|
||||||
|
'separated',
|
||||||
|
[true, true, true],
|
||||||
|
)
|
||||||
|
expect(twoColumns).toHaveLength(3)
|
||||||
|
expect(twoColumns[2]).toMatchObject({ row: 1, column: 0, left: 0, width: 400 })
|
||||||
|
|
||||||
|
const threeColumns = resolveGridCellGeometry(
|
||||||
|
400,
|
||||||
|
300,
|
||||||
|
normalizeGridOptions({ rowCount: 2, columnCount: 3, fillIncompleteLastRow: true }),
|
||||||
|
'separated',
|
||||||
|
[true, true, true, true, true],
|
||||||
|
)
|
||||||
|
expect(threeColumns).toHaveLength(5)
|
||||||
|
expect(threeColumns.slice(3).map((cell) => cell.width)).toEqual([192, 192])
|
||||||
|
expect(threeColumns.slice(3).map((cell) => cell.left)).toEqual([0, 208])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps full pages and the disabled option byte-for-byte compatible', () => {
|
||||||
|
const base = normalizeGridOptions({ rowCount: 2, columnCount: 2 })
|
||||||
|
const filled = normalizeGridOptions({ rowCount: 2, columnCount: 2, fillIncompleteLastRow: true })
|
||||||
|
const fullSlots = [true, true, true, true]
|
||||||
|
expect(resolveGridCellGeometry(400, 300, filled, 'independent', fullSlots)).toEqual(
|
||||||
|
resolveGridCellGeometry(400, 300, base, 'independent', fullSlots),
|
||||||
|
)
|
||||||
|
expect(resolveGridCellGeometry(400, 300, base, 'independent', [true, true, true])).toHaveLength(
|
||||||
|
4,
|
||||||
|
)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export interface WaveformGridOptions {
|
|||||||
rowCount?: number
|
rowCount?: number
|
||||||
columnCount?: number
|
columnCount?: number
|
||||||
showPagination?: boolean
|
showPagination?: boolean
|
||||||
|
fillIncompleteLastRow?: boolean
|
||||||
trackLines?: WaveformGridTrackLines
|
trackLines?: WaveformGridTrackLines
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,6 +34,7 @@ export interface NormalizedWaveformGridOptions {
|
|||||||
rowCount: number
|
rowCount: number
|
||||||
columnCount: number
|
columnCount: number
|
||||||
showPagination: boolean
|
showPagination: boolean
|
||||||
|
fillIncompleteLastRow: boolean
|
||||||
trackLines: Record<string, NormalizedWaveformGridLineOptions>
|
trackLines: Record<string, NormalizedWaveformGridLineOptions>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,6 +50,7 @@ export interface GridCellGeometry {
|
|||||||
plotHeight: number
|
plotHeight: number
|
||||||
cellHeight: number
|
cellHeight: number
|
||||||
xAxisBand: number
|
xAxisBand: number
|
||||||
|
isLastRow?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizeCount = (value: unknown, fallback: number) => {
|
const normalizeCount = (value: unknown, fallback: number) => {
|
||||||
@@ -78,6 +81,7 @@ export function normalizeGridOptions(options?: WaveformGridOptions): NormalizedW
|
|||||||
rowCount: normalizeCount(options?.rowCount, 2),
|
rowCount: normalizeCount(options?.rowCount, 2),
|
||||||
columnCount: normalizeCount(options?.columnCount, 1),
|
columnCount: normalizeCount(options?.columnCount, 1),
|
||||||
showPagination: options?.showPagination ?? true,
|
showPagination: options?.showPagination ?? true,
|
||||||
|
fillIncompleteLastRow: options?.fillIncompleteLastRow ?? false,
|
||||||
trackLines,
|
trackLines,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -114,20 +118,23 @@ export function resolveGridCellGeometry(
|
|||||||
horizontalGap?: number,
|
horizontalGap?: number,
|
||||||
showXAxis = true,
|
showXAxis = true,
|
||||||
): GridCellGeometry[] {
|
): GridCellGeometry[] {
|
||||||
|
const pageSize = getPageSize(options)
|
||||||
|
const seriesCount = slotHasSeries.filter(Boolean).length
|
||||||
|
const filledLastRow = options.fillIncompleteLastRow && seriesCount > 0 && seriesCount < pageSize
|
||||||
|
const rowCount = filledLastRow ? Math.ceil(seriesCount / options.columnCount) : options.rowCount
|
||||||
const defaultGap = getGridGap(displayMode)
|
const defaultGap = getGridGap(displayMode)
|
||||||
const columnGap = Number.isFinite(horizontalGap)
|
const columnGap = Number.isFinite(horizontalGap)
|
||||||
? Math.max(0, horizontalGap as number)
|
? Math.max(0, horizontalGap as number)
|
||||||
: defaultGap
|
: defaultGap
|
||||||
const totalHorizontalGap = Math.max(0, options.columnCount - 1) * columnGap
|
|
||||||
const axisRows = new Set<number>()
|
const axisRows = new Set<number>()
|
||||||
if (!showXAxis) {
|
if (!showXAxis) {
|
||||||
// Net view uses the full drawing area for waveform pixels.
|
// Net view uses the full drawing area for waveform pixels.
|
||||||
} else if (displayMode === 'independent') {
|
} else if (displayMode === 'independent') {
|
||||||
for (let row = 0; row < options.rowCount; row += 1) axisRows.add(row)
|
for (let row = 0; row < rowCount; row += 1) axisRows.add(row)
|
||||||
} else if (displayMode === 'compact') {
|
} else if (displayMode === 'compact') {
|
||||||
// Compact tracks share one continuous plot stack. Reserve the X-axis band
|
// Compact tracks share one continuous plot stack. Reserve the X-axis band
|
||||||
// only beneath the final grid row, including when that row is empty.
|
// only beneath the final grid row, including when that row is empty.
|
||||||
axisRows.add(options.rowCount - 1)
|
axisRows.add(rowCount - 1)
|
||||||
} else {
|
} else {
|
||||||
for (let column = 0; column < options.columnCount; column += 1) {
|
for (let column = 0; column < options.columnCount; column += 1) {
|
||||||
for (let slotIndex = getPageSize(options) - 1; slotIndex >= 0; slotIndex -= 1) {
|
for (let slotIndex = getPageSize(options) - 1; slotIndex >= 0; slotIndex -= 1) {
|
||||||
@@ -139,17 +146,22 @@ export function resolveGridCellGeometry(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const totalVerticalGap = Math.max(0, options.rowCount - 1) * defaultGap
|
const totalVerticalGap = Math.max(0, rowCount - 1) * defaultGap
|
||||||
const totalAxisBand = axisRows.size * X_AXIS_BAND
|
const totalAxisBand = axisRows.size * X_AXIS_BAND
|
||||||
const width = Math.max(1, (innerWidth - totalHorizontalGap) / options.columnCount)
|
|
||||||
const plotHeight = Math.max(
|
const plotHeight = Math.max(
|
||||||
1,
|
1,
|
||||||
(innerHeight - totalVerticalGap - totalAxisBand) / options.rowCount,
|
(innerHeight - totalVerticalGap - totalAxisBand) / rowCount,
|
||||||
)
|
)
|
||||||
|
|
||||||
return Array.from({ length: getPageSize(options) }, (_, slotIndex) => {
|
return Array.from({ length: filledLastRow ? seriesCount : pageSize }, (_, slotIndex) => {
|
||||||
const row = Math.floor(slotIndex / options.columnCount)
|
const row = Math.floor(slotIndex / options.columnCount)
|
||||||
const column = slotIndex % options.columnCount
|
const column = slotIndex % options.columnCount
|
||||||
|
const rowSeriesCount =
|
||||||
|
filledLastRow && row === rowCount - 1
|
||||||
|
? seriesCount - row * options.columnCount
|
||||||
|
: options.columnCount
|
||||||
|
const rowGap = Math.max(0, rowSeriesCount - 1) * columnGap
|
||||||
|
const width = Math.max(1, (innerWidth - rowGap) / rowSeriesCount)
|
||||||
const xAxisBand = axisRows.has(row) ? X_AXIS_BAND : 0
|
const xAxisBand = axisRows.has(row) ? X_AXIS_BAND : 0
|
||||||
const cellHeight = plotHeight + xAxisBand
|
const cellHeight = plotHeight + xAxisBand
|
||||||
const top = Array.from({ length: row }, (_, previousRow) => {
|
const top = Array.from({ length: row }, (_, previousRow) => {
|
||||||
@@ -167,6 +179,7 @@ export function resolveGridCellGeometry(
|
|||||||
plotHeight,
|
plotHeight,
|
||||||
cellHeight,
|
cellHeight,
|
||||||
xAxisBand,
|
xAxisBand,
|
||||||
|
isLastRow: row === rowCount - 1,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
findClosestTrackAtPointer,
|
findClosestTrackAtPointer,
|
||||||
MAX_MULTI_Y_AXIS_COUNT,
|
MAX_MULTI_Y_AXIS_COUNT,
|
||||||
measureYAxisGroupClearance,
|
measureYAxisGroupClearance,
|
||||||
|
resolveYAxisTickCount,
|
||||||
} from './layout'
|
} from './layout'
|
||||||
|
|
||||||
function series(id: string, minimum: number, maximum: number): DisplaySeries {
|
function series(id: string, minimum: number, maximum: number): DisplaySeries {
|
||||||
@@ -64,7 +65,13 @@ function layoutForSeries(
|
|||||||
series: sourceTrack,
|
series: sourceTrack,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
grid: { rowCount: 1, columnCount: 1, showPagination: false, trackLines: {} },
|
grid: {
|
||||||
|
rowCount: 1,
|
||||||
|
columnCount: 1,
|
||||||
|
showPagination: false,
|
||||||
|
fillIncompleteLastRow: false,
|
||||||
|
trackLines: {},
|
||||||
|
},
|
||||||
displayMode: 'independent',
|
displayMode: 'independent',
|
||||||
overlayMode: 'single-axis',
|
overlayMode: 'single-axis',
|
||||||
independentTransforms: [transform],
|
independentTransforms: [transform],
|
||||||
@@ -106,7 +113,13 @@ describe('multi-value Y-axis grouping', () => {
|
|||||||
series: sourceTrack,
|
series: sourceTrack,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
grid: { rowCount: 1, columnCount: 1, showPagination: false, trackLines: {} },
|
grid: {
|
||||||
|
rowCount: 1,
|
||||||
|
columnCount: 1,
|
||||||
|
showPagination: false,
|
||||||
|
fillIncompleteLastRow: false,
|
||||||
|
trackLines: {},
|
||||||
|
},
|
||||||
displayMode: 'independent',
|
displayMode: 'independent',
|
||||||
overlayMode: 'single-axis',
|
overlayMode: 'single-axis',
|
||||||
independentTransforms: [zoomIdentity],
|
independentTransforms: [zoomIdentity],
|
||||||
@@ -119,8 +132,15 @@ describe('multi-value Y-axis grouping', () => {
|
|||||||
showCompactEmptyTracks: false,
|
showCompactEmptyTracks: false,
|
||||||
})[0]
|
})[0]
|
||||||
|
|
||||||
expect(result?.yScale.domain()).toEqual([25, 75])
|
expect(result?.yScale.domain()).toEqual([20, 80])
|
||||||
expect(result?.seriesPaths[0]?.yScale.domain()).toEqual([25, 75])
|
expect(result?.seriesPaths[0]?.yScale.domain()).toEqual([20, 80])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('normalizes configured Y-axis split counts', () => {
|
||||||
|
expect(resolveYAxisTickCount(100, 0)).toBe(2)
|
||||||
|
expect(resolveYAxisTickCount(100, 4.8)).toBe(4)
|
||||||
|
expect(resolveYAxisTickCount(100, Number.NaN)).toBe(5)
|
||||||
|
expect(resolveYAxisTickCount(220)).toBe(5)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('keeps every overlaid series on one axis in single-axis mode', () => {
|
it('keeps every overlaid series on one axis in single-axis mode', () => {
|
||||||
@@ -209,7 +229,13 @@ describe('multi-value Y-axis grouping', () => {
|
|||||||
series: track([series('left', 1000, 3000), series('right', 1000, 3000)]),
|
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,
|
||||||
|
fillIncompleteLastRow: false,
|
||||||
|
trackLines: {},
|
||||||
|
},
|
||||||
displayMode: 'independent',
|
displayMode: 'independent',
|
||||||
overlayMode: 'multi-axis',
|
overlayMode: 'multi-axis',
|
||||||
independentTransforms: [zoomIdentity],
|
independentTransforms: [zoomIdentity],
|
||||||
|
|||||||
@@ -20,6 +20,13 @@ const Y_AXIS_OUTER_PADDING = 4
|
|||||||
const Y_AXIS_LABEL_GAP = 6
|
const Y_AXIS_LABEL_GAP = 6
|
||||||
const Y_AXIS_LABEL_BAND_WIDTH = 24
|
const Y_AXIS_LABEL_BAND_WIDTH = 24
|
||||||
|
|
||||||
|
export function resolveYAxisTickCount(_plotHeight: number, splitNumber?: number): number {
|
||||||
|
if (typeof splitNumber === 'number' && Number.isFinite(splitNumber)) {
|
||||||
|
return Math.max(2, Math.floor(splitNumber))
|
||||||
|
}
|
||||||
|
return 5
|
||||||
|
}
|
||||||
|
|
||||||
export interface YAxisSeriesGroup {
|
export interface YAxisSeriesGroup {
|
||||||
index: number
|
index: number
|
||||||
side: 'left' | 'right'
|
side: 'left' | 'right'
|
||||||
@@ -103,16 +110,22 @@ export function axisTextMetrics(
|
|||||||
domain: [number, number],
|
domain: [number, number],
|
||||||
nice = true,
|
nice = true,
|
||||||
tickValues?: number[],
|
tickValues?: number[],
|
||||||
|
unit?: string,
|
||||||
|
tickCount = 10,
|
||||||
): { tickTextWidth: number } {
|
): { tickTextWidth: number } {
|
||||||
|
const effectiveTickCount =
|
||||||
|
typeof tickCount === 'number' && Number.isFinite(tickCount)
|
||||||
|
? Math.max(2, Math.floor(tickCount))
|
||||||
|
: 10
|
||||||
const scale = scaleLinear(domain, [1, 0])
|
const scale = scaleLinear(domain, [1, 0])
|
||||||
if (nice) scale.nice()
|
if (nice) scale.nice(effectiveTickCount)
|
||||||
const [axisMin, axisMax] = scale.domain()
|
const [axisMin, axisMax] = scale.domain()
|
||||||
const values = tickValues ?? Array.from(new Set([axisMin, ...scale.ticks(10), axisMax]))
|
const values = tickValues ?? scale.ticks(effectiveTickCount)
|
||||||
const topTickValue = Math.max(...values)
|
const topTickValue = Math.max(...values)
|
||||||
const maximumTickCharacters = Math.max(
|
const maximumTickCharacters = Math.max(
|
||||||
1,
|
1,
|
||||||
...values.map(
|
...values.map(
|
||||||
(value) => formatScientificAxisLabel(value, { axisMin, axisMax, topTickValue }).length,
|
(value) => formatScientificAxisLabel(value, { axisMin, axisMax, topTickValue, unit }).length,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
@@ -120,9 +133,10 @@ export function axisTextMetrics(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function measureYAxisGroupClearance(group: YAxisSeriesGroup): number {
|
export function measureYAxisGroupClearance(group: YAxisSeriesGroup, tickCount?: number): number {
|
||||||
return (
|
return (
|
||||||
axisTextMetrics(group.domain, !group.fixed).tickTextWidth +
|
axisTextMetrics(group.domain, true, undefined, group.seriesList[0]?.unit, tickCount)
|
||||||
|
.tickTextWidth +
|
||||||
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 +
|
||||||
@@ -130,9 +144,10 @@ export function measureYAxisGroupClearance(group: YAxisSeriesGroup): number {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function measureYAxisGroupTickClearance(group: YAxisSeriesGroup): number {
|
function measureYAxisGroupTickClearance(group: YAxisSeriesGroup, tickCount?: number): number {
|
||||||
return (
|
return (
|
||||||
axisTextMetrics(group.domain, !group.fixed).tickTextWidth +
|
axisTextMetrics(group.domain, true, undefined, group.seriesList[0]?.unit, tickCount)
|
||||||
|
.tickTextWidth +
|
||||||
Y_AXIS_TICK_PADDING +
|
Y_AXIS_TICK_PADDING +
|
||||||
Y_AXIS_OUTER_PADDING
|
Y_AXIS_OUTER_PADDING
|
||||||
)
|
)
|
||||||
@@ -143,13 +158,14 @@ export function measureTrackYAxisClearance(
|
|||||||
overlayMode: WaveformOverlayMode,
|
overlayMode: WaveformOverlayMode,
|
||||||
yDomain?: WaveformYDomain,
|
yDomain?: WaveformYDomain,
|
||||||
yDomains?: Record<string, WaveformYDomain>,
|
yDomains?: Record<string, WaveformYDomain>,
|
||||||
|
tickCount?: number,
|
||||||
): { left: number; right: number } {
|
): { left: number; right: number } {
|
||||||
return resolveYAxisSeriesGroups(track, overlayMode, yDomain, yDomains).reduce(
|
return resolveYAxisSeriesGroups(track, overlayMode, yDomain, yDomains).reduce(
|
||||||
(clearance, group) => {
|
(clearance, group) => {
|
||||||
clearance[group.side] +=
|
clearance[group.side] +=
|
||||||
overlayMode === 'multi-axis' || track.visibleSeries.length === 1
|
overlayMode === 'multi-axis' || track.visibleSeries.length === 1
|
||||||
? measureYAxisGroupClearance(group)
|
? measureYAxisGroupClearance(group, tickCount)
|
||||||
: measureYAxisGroupTickClearance(group)
|
: measureYAxisGroupTickClearance(group, tickCount)
|
||||||
return clearance
|
return clearance
|
||||||
},
|
},
|
||||||
{ left: 0, right: 0 },
|
{ left: 0, right: 0 },
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import {
|
|||||||
type GridCellGeometry,
|
type GridCellGeometry,
|
||||||
type NormalizedWaveformGridOptions,
|
type NormalizedWaveformGridOptions,
|
||||||
} from './grid'
|
} from './grid'
|
||||||
import { axisTextMetrics, resolveYAxisSeriesGroups } from './layout'
|
import { axisTextMetrics, resolveYAxisSeriesGroups, resolveYAxisTickCount } from './layout'
|
||||||
import type { DisplaySeries, DisplayTrack, TrackLayout, WaveformYAxisLayout } from './types'
|
import type { DisplaySeries, DisplayTrack, TrackLayout, WaveformYAxisLayout } from './types'
|
||||||
import { applyXDomainStrategy } from './xDomain'
|
import { applyXDomainStrategy } from './xDomain'
|
||||||
import {
|
import {
|
||||||
@@ -54,6 +54,7 @@ export interface BuildTrackLayoutsOptions {
|
|||||||
yDomains?: Record<string, [number, number]>
|
yDomains?: Record<string, [number, number]>
|
||||||
timeUnit: 's' | 'ms'
|
timeUnit: 's' | 'ms'
|
||||||
xAxisLabelFormatter?: WaveformXAxisLabelFormatter
|
xAxisLabelFormatter?: WaveformXAxisLabelFormatter
|
||||||
|
yAxisSplitNumber?: number
|
||||||
rendering: ResolvedWaveformRenderingOptions
|
rendering: ResolvedWaveformRenderingOptions
|
||||||
hideSecondaryLabels: boolean
|
hideSecondaryLabels: boolean
|
||||||
yAxisLabelX: number
|
yAxisLabelX: number
|
||||||
@@ -124,18 +125,27 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou
|
|||||||
)
|
)
|
||||||
const sideOffsets = { left: 0, right: 0 }
|
const sideOffsets = { left: 0, right: 0 }
|
||||||
const yAxes: WaveformYAxisLayout[] = yAxisGroups.map((group) => {
|
const yAxes: WaveformYAxisLayout[] = yAxisGroups.map((group) => {
|
||||||
|
const tickCount = resolveYAxisTickCount(cell.plotHeight, options.yAxisSplitNumber)
|
||||||
|
const niceCount = Math.max(1, tickCount - 1)
|
||||||
const scale = scaleLinear(group.domain, [cell.plotHeight, 0])
|
const scale = scaleLinear(group.domain, [cell.plotHeight, 0])
|
||||||
if (!group.fixed) scale.nice()
|
scale.nice(niceCount)
|
||||||
const majorTicks = scale.ticks(Math.max(2, Math.floor(cell.plotHeight / 55)))
|
|
||||||
const [axisStart, axisEnd] = scale.domain()
|
const [axisStart, axisEnd] = scale.domain()
|
||||||
|
const majorTicks = Array.from(
|
||||||
|
{ length: tickCount },
|
||||||
|
(_, index) => axisStart + ((axisEnd - axisStart) * index) / (tickCount - 1),
|
||||||
|
)
|
||||||
const showAxisEnd = options.displayMode !== 'compact' || cell.row === 0
|
const showAxisEnd = options.displayMode !== 'compact' || cell.row === 0
|
||||||
const visibleMajorTicks = showAxisEnd
|
const visibleMajorTicks = showAxisEnd
|
||||||
? majorTicks
|
? majorTicks
|
||||||
: majorTicks.filter((tick) => tick !== axisEnd)
|
: majorTicks.filter((tick) => tick !== axisEnd)
|
||||||
const tickValues = Array.from(
|
const tickValues = visibleMajorTicks
|
||||||
new Set([axisStart, ...visibleMajorTicks, ...(showAxisEnd ? [axisEnd] : [])]),
|
const { tickTextWidth } = axisTextMetrics(
|
||||||
|
scale.domain() as [number, number],
|
||||||
|
false,
|
||||||
|
tickValues,
|
||||||
|
group.seriesList[0]?.unit,
|
||||||
|
tickCount,
|
||||||
)
|
)
|
||||||
const { tickTextWidth } = axisTextMetrics(group.domain, !group.fixed, tickValues)
|
|
||||||
const clearance =
|
const clearance =
|
||||||
tickTextWidth +
|
tickTextWidth +
|
||||||
Y_AXIS_TICK_PADDING +
|
Y_AXIS_TICK_PADDING +
|
||||||
@@ -256,7 +266,7 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou
|
|||||||
(isEmpty || hasVisibleSeries) &&
|
(isEmpty || hasVisibleSeries) &&
|
||||||
(options.displayMode === 'independent' ||
|
(options.displayMode === 'independent' ||
|
||||||
(options.displayMode === 'compact'
|
(options.displayMode === 'compact'
|
||||||
? cell.row === options.grid.rowCount - 1
|
? (cell.isLastRow ?? cell.row === options.grid.rowCount - 1)
|
||||||
: bottomCells.has(cell.slotIndex))),
|
: bottomCells.has(cell.slotIndex))),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -259,6 +259,9 @@ export function useWaveformChartController(
|
|||||||
trackLayouts,
|
trackLayouts,
|
||||||
innerWidth,
|
innerWidth,
|
||||||
innerHeight,
|
innerHeight,
|
||||||
|
sharedZoomDomain,
|
||||||
|
initialXDomain,
|
||||||
|
sharedTransform,
|
||||||
activeInteractionMode,
|
activeInteractionMode,
|
||||||
hiddenSeriesIdSet,
|
hiddenSeriesIdSet,
|
||||||
internalHiddenSeriesIds,
|
internalHiddenSeriesIds,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
|
|
||||||
import type { AnnotationSeriesCandidate } from '../annotation'
|
import type { AnnotationSeriesCandidate } from '../annotation'
|
||||||
import type { useWaveformAnnotationInteraction } from '../annotation'
|
import type { useWaveformAnnotationInteraction } from '../annotation'
|
||||||
|
import type { NormalizedWaveformGridOptions } from './grid'
|
||||||
import type { DisplaySeries, DisplayTrack, TrackLayout } from './types'
|
import type { DisplaySeries, DisplayTrack, TrackLayout } from './types'
|
||||||
import type { ResolvedWaveformChartProps, WaveformChartEmit } from './waveformChartTypes'
|
import type { ResolvedWaveformChartProps, WaveformChartEmit } from './waveformChartTypes'
|
||||||
import { constrainZoomDomain, transformForDomain } from '../interaction/zoomConstraints'
|
import { constrainZoomDomain, transformForDomain } from '../interaction/zoomConstraints'
|
||||||
@@ -33,12 +34,15 @@ interface LifecycleContext {
|
|||||||
currentPage: Ref<number>
|
currentPage: Ref<number>
|
||||||
pageCount: ComputedRef<number>
|
pageCount: ComputedRef<number>
|
||||||
pagedTracks: ComputedRef<Array<DisplayTrack | undefined>>
|
pagedTracks: ComputedRef<Array<DisplayTrack | undefined>>
|
||||||
gridOptions: ComputedRef<{ rowCount: number; columnCount: number }>
|
gridOptions: ComputedRef<NormalizedWaveformGridOptions>
|
||||||
chartSeries: ComputedRef<DisplaySeries[]>
|
chartSeries: ComputedRef<DisplaySeries[]>
|
||||||
chartTracks: ComputedRef<DisplayTrack[]>
|
chartTracks: ComputedRef<DisplayTrack[]>
|
||||||
trackLayouts: ComputedRef<TrackLayout[]>
|
trackLayouts: ComputedRef<TrackLayout[]>
|
||||||
innerWidth: ComputedRef<number>
|
innerWidth: ComputedRef<number>
|
||||||
innerHeight: ComputedRef<number>
|
innerHeight: ComputedRef<number>
|
||||||
|
sharedZoomDomain: ComputedRef<[number, number]>
|
||||||
|
initialXDomain: ComputedRef<[number, number]>
|
||||||
|
sharedTransform: ShallowRef<ZoomTransform>
|
||||||
activeInteractionMode: ComputedRef<string | undefined>
|
activeInteractionMode: ComputedRef<string | undefined>
|
||||||
hiddenSeriesIdSet: ComputedRef<Set<string>>
|
hiddenSeriesIdSet: ComputedRef<Set<string>>
|
||||||
internalHiddenSeriesIds: Ref<Set<string>>
|
internalHiddenSeriesIds: Ref<Set<string>>
|
||||||
@@ -93,6 +97,9 @@ export function useWaveformChartLifecycle(context: LifecycleContext) {
|
|||||||
trackLayouts,
|
trackLayouts,
|
||||||
innerWidth,
|
innerWidth,
|
||||||
innerHeight,
|
innerHeight,
|
||||||
|
sharedZoomDomain,
|
||||||
|
initialXDomain,
|
||||||
|
sharedTransform,
|
||||||
activeInteractionMode,
|
activeInteractionMode,
|
||||||
hiddenSeriesIdSet,
|
hiddenSeriesIdSet,
|
||||||
internalHiddenSeriesIds,
|
internalHiddenSeriesIds,
|
||||||
@@ -144,25 +151,24 @@ export function useWaveformChartLifecycle(context: LifecycleContext) {
|
|||||||
emit('page-change', nextPage, pageCount.value)
|
emit('page-change', nextPage, pageCount.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let pendingSharedXDomain: [number, number] | undefined
|
||||||
let pendingIndependentXDomains: Array<[number, number] | undefined> | undefined
|
let pendingIndependentXDomains: Array<[number, number] | undefined> | undefined
|
||||||
|
|
||||||
function handleBeforeDataReferenceChange() {
|
function handleBeforeDataReferenceChange() {
|
||||||
if (props.displayMode !== 'independent') return
|
if (props.displayMode === 'independent') {
|
||||||
|
pendingSharedXDomain = undefined
|
||||||
pendingIndependentXDomains = trackLayouts.value.map((track) => {
|
pendingIndependentXDomains = trackLayouts.value.map((track) => {
|
||||||
const configuredDomain =
|
const current = track.xScale.domain() as [number, number]
|
||||||
props.initialXDomains?.[track.series.trackId ?? track.series.id] ??
|
const boundary = resolveInitialTrackDomain(track)
|
||||||
props.initialXDomains?.[track.series.id] ??
|
return current[1] - current[0] < boundary[1] - boundary[0] - 1e-12 ? current : undefined
|
||||||
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]
|
|
||||||
})
|
})
|
||||||
|
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() {
|
function handleDataReferenceChange() {
|
||||||
@@ -186,10 +192,15 @@ export function useWaveformChartLifecycle(context: LifecycleContext) {
|
|||||||
})
|
})
|
||||||
independentTransforms.value = nextTransforms
|
independentTransforms.value = nextTransforms
|
||||||
pendingIndependentXDomains = undefined
|
pendingIndependentXDomains = undefined
|
||||||
void nextTick(configureZoom)
|
} else if (props.displayMode !== 'independent' && pendingSharedXDomain) {
|
||||||
return
|
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()
|
configureZoom()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -210,6 +221,7 @@ export function useWaveformChartLifecycle(context: LifecycleContext) {
|
|||||||
currentPage,
|
currentPage,
|
||||||
() => gridOptions.value.rowCount,
|
() => gridOptions.value.rowCount,
|
||||||
() => gridOptions.value.columnCount,
|
() => gridOptions.value.columnCount,
|
||||||
|
() => gridOptions.value.fillIncompleteLastRow,
|
||||||
activeInteractionMode,
|
activeInteractionMode,
|
||||||
],
|
],
|
||||||
async () => {
|
async () => {
|
||||||
@@ -229,7 +241,14 @@ export function useWaveformChartLifecycle(context: LifecycleContext) {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
watch([pageCount, () => props.grid?.rowCount, () => props.grid?.columnCount], () => {
|
watch(
|
||||||
|
[
|
||||||
|
pageCount,
|
||||||
|
() => props.grid?.rowCount,
|
||||||
|
() => props.grid?.columnCount,
|
||||||
|
() => props.grid?.fillIncompleteLastRow,
|
||||||
|
],
|
||||||
|
() => {
|
||||||
const previousPage = currentPage.value
|
const previousPage = currentPage.value
|
||||||
currentPage.value =
|
currentPage.value =
|
||||||
currentPage.value > pageCount.value
|
currentPage.value > pageCount.value
|
||||||
@@ -242,7 +261,8 @@ export function useWaveformChartLifecycle(context: LifecycleContext) {
|
|||||||
}
|
}
|
||||||
clearHover()
|
clearHover()
|
||||||
void nextTick(configureZoom)
|
void nextTick(configureZoom)
|
||||||
})
|
},
|
||||||
|
)
|
||||||
|
|
||||||
watch(activeInteractionMode, () => {
|
watch(activeInteractionMode, () => {
|
||||||
editorSeriesOptions.value = []
|
editorSeriesOptions.value = []
|
||||||
|
|||||||
@@ -112,7 +112,16 @@ 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) => axisTextMetrics(group.domain, !group.fixed).tickTextWidth)
|
.map(
|
||||||
|
(group) =>
|
||||||
|
axisTextMetrics(
|
||||||
|
group.domain,
|
||||||
|
true,
|
||||||
|
undefined,
|
||||||
|
group.seriesList[0]?.unit,
|
||||||
|
props.axes?.y?.splitNumber,
|
||||||
|
).tickTextWidth,
|
||||||
|
)
|
||||||
const tickTextWidth = Math.max(Y_AXIS_CHARACTER_WIDTH, ...axisText)
|
const tickTextWidth = Math.max(Y_AXIS_CHARACTER_WIDTH, ...axisText)
|
||||||
const tickClearance = tickTextWidth + Y_AXIS_TICK_PADDING + Y_AXIS_OUTER_PADDING
|
const tickClearance = tickTextWidth + Y_AXIS_TICK_PADDING + Y_AXIS_OUTER_PADDING
|
||||||
const labelCenterX = -(
|
const labelCenterX = -(
|
||||||
@@ -157,6 +166,7 @@ export function useWaveformLayout(context: LayoutContext) {
|
|||||||
props.overlayMode,
|
props.overlayMode,
|
||||||
props.yDomain,
|
props.yDomain,
|
||||||
props.yDomains,
|
props.yDomains,
|
||||||
|
props.axes?.y?.splitNumber,
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
left: Math.max(maximum.left, clearance.left),
|
left: Math.max(maximum.left, clearance.left),
|
||||||
@@ -298,6 +308,7 @@ export function useWaveformLayout(context: LayoutContext) {
|
|||||||
: sharedYDomains.value,
|
: sharedYDomains.value,
|
||||||
timeUnit: props.timeUnit,
|
timeUnit: props.timeUnit,
|
||||||
xAxisLabelFormatter: props.axes?.x?.labelFormatter,
|
xAxisLabelFormatter: props.axes?.x?.labelFormatter,
|
||||||
|
yAxisSplitNumber: props.axes?.y?.splitNumber,
|
||||||
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,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import type {
|
|||||||
import type { AnnotationSeriesCandidate } from '../annotation'
|
import type { AnnotationSeriesCandidate } from '../annotation'
|
||||||
import { tryReleasePointerCapture } from './pointerCapture'
|
import { tryReleasePointerCapture } from './pointerCapture'
|
||||||
import { transitionViewportInteraction } from './viewportInteractionState'
|
import { transitionViewportInteraction } from './viewportInteractionState'
|
||||||
|
import { createViewportDomainSetter } from './viewportDomain'
|
||||||
import { constrainZoomDomain, transformForDomain } from './zoomConstraints'
|
import { constrainZoomDomain, transformForDomain } from './zoomConstraints'
|
||||||
interface ViewportContext {
|
interface ViewportContext {
|
||||||
props: ResolvedWaveformChartProps
|
props: ResolvedWaveformChartProps
|
||||||
@@ -344,6 +345,20 @@ export function useWaveformViewport(context: ViewportContext) {
|
|||||||
editorSeriesOptions.value = []
|
editorSeriesOptions.value = []
|
||||||
void nextTick(configureZoom)
|
void nextTick(configureZoom)
|
||||||
}
|
}
|
||||||
|
const setViewportDomain = createViewportDomainSetter({
|
||||||
|
props,
|
||||||
|
trackLayouts,
|
||||||
|
initialXDomain,
|
||||||
|
resolveInitialTrackDomain,
|
||||||
|
innerWidth,
|
||||||
|
sharedTransform,
|
||||||
|
independentTransforms,
|
||||||
|
cancelPendingZoom,
|
||||||
|
cancelViewportDrag,
|
||||||
|
clearHover,
|
||||||
|
editorSeriesOptions,
|
||||||
|
configureZoom,
|
||||||
|
})
|
||||||
const requestViewportReset = (event: MouseEvent) => {
|
const requestViewportReset = (event: MouseEvent) => {
|
||||||
if (isPresentationMode.value || !props.zoomable || !isZoomMode.value) return
|
if (isPresentationMode.value || !props.zoomable || !isZoomMode.value) return
|
||||||
if (props.displayMode === 'independent') {
|
if (props.displayMode === 'independent') {
|
||||||
@@ -372,6 +387,7 @@ export function useWaveformViewport(context: ViewportContext) {
|
|||||||
finishViewportDrag,
|
finishViewportDrag,
|
||||||
cancelViewportDrag,
|
cancelViewportDrag,
|
||||||
resetViewport,
|
resetViewport,
|
||||||
|
setViewportDomain,
|
||||||
requestViewportReset,
|
requestViewportReset,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
60
src/components/interaction/viewportDomain.ts
Normal file
60
src/components/interaction/viewportDomain.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,9 +44,10 @@ function renderAxes() {
|
|||||||
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 topTickValue = Math.max(...axis.tickValues)
|
||||||
|
const unit = axis.seriesList[0]?.unit
|
||||||
const yAxis = (axis.side === 'left' ? axisLeft(axis.scale) : axisRight(axis.scale))
|
const yAxis = (axis.side === 'left' ? axisLeft(axis.scale) : axisRight(axis.scale))
|
||||||
.tickFormat((value) =>
|
.tickFormat((value) =>
|
||||||
formatScientificAxisLabel(Number(value), { axisMin, axisMax, topTickValue }),
|
formatScientificAxisLabel(Number(value), { axisMin, axisMax, topTickValue, unit }),
|
||||||
)
|
)
|
||||||
.tickSize(-4)
|
.tickSize(-4)
|
||||||
.tickPadding(7)
|
.tickPadding(7)
|
||||||
|
|||||||
@@ -65,9 +65,9 @@ describe('WaveformChart', () => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
expect(endTicks[0]).toHaveLength(1)
|
expect(endTicks[0]).toHaveLength(1)
|
||||||
expect(Number(endTicks[0][0].text())).toBe(0.9)
|
expect(Number(endTicks[0][0].text())).toBe(1)
|
||||||
expect(endTicks[1]).toHaveLength(1)
|
expect(endTicks[1]).toHaveLength(1)
|
||||||
expect(Number(endTicks[1][0].text())).toBe(1.9)
|
expect(Number(endTicks[1][0].text())).toBe(2)
|
||||||
expect(endTicks[2]).toHaveLength(0)
|
expect(endTicks[2]).toHaveLength(0)
|
||||||
expect(endTicks[3]).toHaveLength(0)
|
expect(endTicks[3]).toHaveLength(0)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -158,6 +158,12 @@ describe('WaveformChart', () => {
|
|||||||
expect(paths[0].attributes('data-series-name')).toBe('BT2_2M')
|
expect(paths[0].attributes('data-series-name')).toBe('BT2_2M')
|
||||||
const yAxisLabels = wrapper.findAll('.waveform-chart__y-axis-label')
|
const yAxisLabels = wrapper.findAll('.waveform-chart__y-axis-label')
|
||||||
expect(yAxisLabels.map((label) => label.text())).toEqual(['BT2_2M', 'BT1_2M'])
|
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'])
|
expect(yAxisLabels.map((label) => label.attributes('fill'))).toEqual(['#0960bd', '#ff7f0e'])
|
||||||
const labelX = Number(
|
const labelX = Number(
|
||||||
yAxisLabels[0].attributes('transform')?.match(/^translate\(([-\d.]+),/)?.[1],
|
yAxisLabels[0].attributes('transform')?.match(/^translate\(([-\d.]+),/)?.[1],
|
||||||
@@ -255,7 +261,7 @@ describe('WaveformChart', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
expect(startTicks).toHaveLength(1)
|
expect(startTicks).toHaveLength(1)
|
||||||
expect(Number(startTicks[0].text())).toBe(0.1)
|
expect(Number(startTicks[0].text())).toBe(0)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -291,7 +297,7 @@ describe('WaveformChart', () => {
|
|||||||
{ displayMode },
|
{ displayMode },
|
||||||
)
|
)
|
||||||
|
|
||||||
const expectedEndValues = [0.9, 4.9]
|
const expectedEndValues = [1, 5]
|
||||||
wrapper.findAll('.waveform-chart__track').forEach((track, index) => {
|
wrapper.findAll('.waveform-chart__track').forEach((track, index) => {
|
||||||
const endTicks = track.findAll('.waveform-chart__axis--y .tick').filter((tick) => {
|
const endTicks = track.findAll('.waveform-chart__axis--y .tick').filter((tick) => {
|
||||||
const match = tick.attributes('transform')?.match(/translate\(0,\s*([\d.]+)\)/)
|
const match = tick.attributes('transform')?.match(/translate\(0,\s*([\d.]+)\)/)
|
||||||
|
|||||||
95
src/components/waveformChartCases/gridFill.test.ts
Normal file
95
src/components/waveformChartCases/gridFill.test.ts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import { flushPromises } from '@vue/test-utils'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import { gridSeries, mountSizedChart } from '../../test/waveformChart'
|
||||||
|
|
||||||
|
function geometry(track: { attributes: (name: string) => string | undefined }) {
|
||||||
|
return {
|
||||||
|
left: Number(track.attributes('data-track-left')),
|
||||||
|
top: Number(track.attributes('data-track-top')),
|
||||||
|
width: Number(track.attributes('data-track-width')),
|
||||||
|
height: Number(track.attributes('data-track-height')),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('WaveformChart incomplete grid rows', () => {
|
||||||
|
it.each(['independent', 'separated', 'compact'] as const)(
|
||||||
|
'fills an incomplete two by two row in %s mode',
|
||||||
|
async (displayMode) => {
|
||||||
|
const wrapper = await mountSizedChart(gridSeries(3), {
|
||||||
|
displayMode,
|
||||||
|
grid: { rowCount: 2, columnCount: 2, fillIncompleteLastRow: true },
|
||||||
|
})
|
||||||
|
const tracks = wrapper.findAll('.waveform-chart__track')
|
||||||
|
const first = geometry(tracks[0]!)
|
||||||
|
const last = geometry(tracks[2]!)
|
||||||
|
|
||||||
|
expect(tracks).toHaveLength(3)
|
||||||
|
expect(wrapper.findAll('.waveform-chart__track--empty')).toHaveLength(0)
|
||||||
|
expect(wrapper.findAll('.waveform-chart__grid-slot-placeholder')).toHaveLength(0)
|
||||||
|
expect(last.left).toBe(0)
|
||||||
|
expect(last.width).toBeGreaterThan(first.width * 1.8)
|
||||||
|
expect(last.top).toBeGreaterThan(first.top)
|
||||||
|
if (displayMode === 'independent') {
|
||||||
|
const overlays = wrapper.findAll('.waveform-chart__overlay--independent')
|
||||||
|
expect(overlays).toHaveLength(3)
|
||||||
|
expect(Number(overlays[2]?.attributes('width'))).toBe(last.width)
|
||||||
|
} else {
|
||||||
|
expect(wrapper.findAll('.waveform-chart__overlay--shared')).toHaveLength(1)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
it('removes unused one-column rows and redistributes chart height', async () => {
|
||||||
|
const base = await mountSizedChart(gridSeries(3), {
|
||||||
|
displayMode: 'independent',
|
||||||
|
grid: { rowCount: 4, columnCount: 1 },
|
||||||
|
})
|
||||||
|
const filled = await mountSizedChart(gridSeries(3), {
|
||||||
|
displayMode: 'independent',
|
||||||
|
grid: { rowCount: 4, columnCount: 1, fillIncompleteLastRow: true },
|
||||||
|
})
|
||||||
|
const baseFirst = geometry(base.findAll('.waveform-chart__track')[0]!)
|
||||||
|
const filledTracks = filled.findAll('.waveform-chart__track')
|
||||||
|
const filledFirst = geometry(filledTracks[0]!)
|
||||||
|
const filledLast = geometry(filledTracks[2]!)
|
||||||
|
|
||||||
|
expect(filledTracks).toHaveLength(3)
|
||||||
|
expect(filledFirst.height).toBeGreaterThan(baseFirst.height)
|
||||||
|
expect(filledLast.top + filledLast.height).toBeGreaterThan(baseFirst.height * 3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the final two tracks evenly distributed in a two by three grid', async () => {
|
||||||
|
const wrapper = await mountSizedChart(gridSeries(5), {
|
||||||
|
displayMode: 'separated',
|
||||||
|
grid: { rowCount: 2, columnCount: 3, fillIncompleteLastRow: true },
|
||||||
|
})
|
||||||
|
const tracks = wrapper.findAll('.waveform-chart__track').map(geometry)
|
||||||
|
|
||||||
|
expect(tracks).toHaveLength(5)
|
||||||
|
expect(tracks[3]?.left).toBe(0)
|
||||||
|
expect(tracks[3]?.width).toBeCloseTo(tracks[4]?.width ?? 0)
|
||||||
|
expect(tracks[4]?.left).toBeGreaterThan((tracks[3]?.width ?? 0) + 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps full pages unchanged and fills a partial second page', async () => {
|
||||||
|
const data = gridSeries(5)
|
||||||
|
const standard = await mountSizedChart(data, {
|
||||||
|
displayMode: 'separated',
|
||||||
|
grid: { rowCount: 2, columnCount: 2 },
|
||||||
|
})
|
||||||
|
const filled = await mountSizedChart(data, {
|
||||||
|
displayMode: 'separated',
|
||||||
|
grid: { rowCount: 2, columnCount: 2, fillIncompleteLastRow: true },
|
||||||
|
})
|
||||||
|
const standardFirstPage = standard.findAll('.waveform-chart__track').map(geometry)
|
||||||
|
expect(filled.findAll('.waveform-chart__track').map(geometry)).toEqual(standardFirstPage)
|
||||||
|
|
||||||
|
await filled.get('.ant-pagination-next button').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
const secondPage = filled.findAll('.waveform-chart__track').map(geometry)
|
||||||
|
expect(secondPage).toHaveLength(1)
|
||||||
|
expect(secondPage[0]?.left).toBe(0)
|
||||||
|
expect(secondPage[0]?.width).toBeGreaterThan(standardFirstPage[0]?.width ?? 0)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -6,6 +6,26 @@ import { resizeObservers } from '../../test/setup'
|
|||||||
import { gridSeries, mountSizedChart } from '../../test/waveformChart'
|
import { gridSeries, mountSizedChart } from '../../test/waveformChart'
|
||||||
|
|
||||||
describe('WaveformChart', () => {
|
describe('WaveformChart', () => {
|
||||||
|
it('applies the configured Y-axis split number', async () => {
|
||||||
|
const wrapper = await mountSizedChart(
|
||||||
|
{
|
||||||
|
kind: 'points',
|
||||||
|
points: [
|
||||||
|
{ x: 0, y: 3 },
|
||||||
|
{ x: 1, y: 97 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ axes: { y: { splitNumber: 5 } } },
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(
|
||||||
|
wrapper
|
||||||
|
.get('.waveform-chart__axis--y')
|
||||||
|
.findAll('.tick text')
|
||||||
|
.map((tick) => tick.text()),
|
||||||
|
).toEqual(['0', '25', '50', '75', '100'])
|
||||||
|
})
|
||||||
|
|
||||||
it('expands the Y-axis label gutter for signed values and long exponents', async () => {
|
it('expands the Y-axis label gutter for signed values and long exponents', async () => {
|
||||||
const wrapper = await mountSizedChart(
|
const wrapper = await mountSizedChart(
|
||||||
{
|
{
|
||||||
@@ -296,7 +316,7 @@ describe('WaveformChart', () => {
|
|||||||
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('1000')
|
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 nice fixed Y-domain props and returns to automatic bounds', async () => {
|
||||||
const wrapper = await mountSizedChart({
|
const wrapper = await mountSizedChart({
|
||||||
kind: 'points',
|
kind: 'points',
|
||||||
points: [
|
points: [
|
||||||
@@ -312,8 +332,8 @@ describe('WaveformChart', () => {
|
|||||||
|
|
||||||
await wrapper.setProps({ yDomain: [3, 97] })
|
await wrapper.setProps({ yDomain: [3, 97] })
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
expect(yTickLabels()).toContain('3')
|
expect(yTickLabels()).toContain('0')
|
||||||
expect(yTickLabels()).toContain('97')
|
expect(yTickLabels()).toContain('100')
|
||||||
|
|
||||||
await wrapper.setProps({ yDomain: undefined })
|
await wrapper.setProps({ yDomain: undefined })
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ describe('WaveformChart', () => {
|
|||||||
{
|
{
|
||||||
trackId: 'shared-frame',
|
trackId: 'shared-frame',
|
||||||
name: '普通量程',
|
name: '普通量程',
|
||||||
|
unit: 'V',
|
||||||
data: {
|
data: {
|
||||||
kind: 'points',
|
kind: 'points',
|
||||||
points: [
|
points: [
|
||||||
@@ -26,6 +27,7 @@ describe('WaveformChart', () => {
|
|||||||
{
|
{
|
||||||
trackId: 'shared-frame',
|
trackId: 'shared-frame',
|
||||||
name: '大量程',
|
name: '大量程',
|
||||||
|
unit: 'A',
|
||||||
data: {
|
data: {
|
||||||
kind: 'points',
|
kind: 'points',
|
||||||
points: [
|
points: [
|
||||||
@@ -37,6 +39,7 @@ describe('WaveformChart', () => {
|
|||||||
{
|
{
|
||||||
trackId: 'shared-frame',
|
trackId: 'shared-frame',
|
||||||
name: '小量程',
|
name: '小量程',
|
||||||
|
unit: 'T',
|
||||||
data: {
|
data: {
|
||||||
kind: 'points',
|
kind: 'points',
|
||||||
points: [
|
points: [
|
||||||
@@ -57,7 +60,11 @@ describe('WaveformChart', () => {
|
|||||||
.map((label) => label.text())
|
.map((label) => label.text())
|
||||||
.filter((label) => label.startsWith('E')),
|
.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 () => {
|
it('reprojects annotations with the Y axis assigned to their series', async () => {
|
||||||
|
|||||||
@@ -119,6 +119,108 @@ describe('WaveformChart viewport reset', () => {
|
|||||||
expect(wrapper.findAll('.waveform-chart__axis-endpoint--end')[0].text()).toBe('1000')
|
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 () => {
|
it('resets shared and independent explicit domains to their included nice bounds', async () => {
|
||||||
const shared = await mountSizedChart(
|
const shared = await mountSizedChart(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -174,6 +174,8 @@ export interface WaveformAxesOptions {
|
|||||||
}
|
}
|
||||||
y?: {
|
y?: {
|
||||||
lineVisible?: boolean
|
lineVisible?: boolean
|
||||||
|
/** Target total number of equal Y-axis ticks, including endpoints. Defaults to 5. */
|
||||||
|
splitNumber?: number
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,15 @@ describe('waveform number formatters', () => {
|
|||||||
expect(formatScientificAxisExponent(0.0001, 0.0003)).toBe('E-04')
|
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', () => {
|
it('derives the exponent from the largest absolute endpoint', () => {
|
||||||
expect(resolveScientificAxisExponent(-9000, -1000)).toBe(3)
|
expect(resolveScientificAxisExponent(-9000, -1000)).toBe(3)
|
||||||
expect(resolveScientificAxisExponent(-100_000, -3000)).toBe(5)
|
expect(resolveScientificAxisExponent(-100_000, -3000)).toBe(5)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export interface ScientificAxisLabelOptions {
|
|||||||
axisMin?: number
|
axisMin?: number
|
||||||
axisMax?: number
|
axisMax?: number
|
||||||
topTickValue?: number
|
topTickValue?: number
|
||||||
|
unit?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @deprecated Use ScientificAxisLabelOptions. */
|
/** @deprecated Use ScientificAxisLabelOptions. */
|
||||||
@@ -71,9 +72,11 @@ export function formatScientificAxisLabel(
|
|||||||
const scaledValue = exponent === null ? value : value / 10 ** exponent
|
const scaledValue = exponent === null ? value : value / 10 ** exponent
|
||||||
const formattedValue = formatYAxisNumber(scaledValue)
|
const formattedValue = formatYAxisNumber(scaledValue)
|
||||||
const topTickValue = options.topTickValue ?? options.axisMax
|
const topTickValue = options.topTickValue ?? options.axisMax
|
||||||
return exponent !== null && value === topTickValue
|
if (value !== topTickValue) return formattedValue
|
||||||
? `${formatExponent(exponent)} ${formattedValue}`
|
const unit = options.unit?.trim()
|
||||||
: formattedValue
|
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. */
|
/** Return the shared E-style multiplier for an axis, or null for a plain axis. */
|
||||||
|
|||||||
Reference in New Issue
Block a user