Charts: Add oh-state-series to render state transitions over time (#3179)

This implements a new series type to show state transitions on a timeline diagram. 

The additional settings in the oh-state-series (in addition to the oh-time-series) are:
- yValue (optional) - where the timeline center should be on the y axis.
  Note, if using categories, it will simply be the index of the category, defaults to 0.
- yHeight (optional) - the unit (in relation to the y-axis coordiate system) of the height of the timeline, defaults to .6.
- mapState (optional) - a function to classify item states to a set of "string" states
- stateColor (optional) - a map of specified colors to use in the graph for each state.

---------

Signed-off-by: Jeff James <jeff@james-online.com>
pull/3195/head
jsjames 2025-05-20 15:41:54 -07:00 committed by GitHub
parent 5d01ce3efc
commit d17072ae31
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 185 additions and 11 deletions

View File

@ -186,7 +186,8 @@ export default {
{ value: 'day', label: 'Hours of day' },
{ value: 'week', label: 'Days of week' },
{ value: 'month', label: 'Days of month' },
{ value: 'year', label: 'Months of year' }
{ value: 'year', label: 'Months of year' },
{ value: 'values', label: 'Values' }
]
},
{
@ -229,6 +230,16 @@ export default {
return configuration.categoryType === 'year'
}
},
{
name: 'data',
label: 'Category Values',
type: 'TEXT',
description: 'Category values to display',
multiple: true,
visible: (value, configuration, configDescription, parameters) => {
return configuration.categoryType === 'values'
}
},
gridIndexParameter
]
}
@ -307,6 +318,31 @@ export default {
}
},
'oh-state-series': {
label: 'State Series',
props: {
parameterGroups: [componentRelationsGroup, actionGroup()],
parameters: [
...seriesParameters,
{
name: 'yValue',
label: 'Y Value',
type: 'DECIMAL',
description: 'The position the state timeline should appear on the Y axis (in graph coordinates). If Y axis is a category axis, this should be the index of the category'
},
{
name: 'yHeight',
label: 'Y Height',
type: 'DECIMAL',
description: 'The height the state timeline bar in graph coordinates (default is 0.6)'
},
xAxisIndexParameter,
yAxisIndexParameter,
...actionParams()
]
}
},
'oh-aggregate-series': {
label: 'Aggregate Series',
docLink: 'https://echarts.apache.org/en/option.html#series',

View File

@ -64,6 +64,9 @@
<f7-list-button color="blue" @click="addSeries('oh-aggregate-series', gridIdx)">
Add Aggregate Series
</f7-list-button>
<f7-list-button color="blue" @click="addSeries('oh-state-series', gridIdx)">
Add State Series
</f7-list-button>
</f7-list>
</f7-card>
</div>
@ -378,7 +381,7 @@ export default {
let firstXAxis = this.context.component.slots.xAxis.find(a => a.config.gridIndex === gridIdx)
let firstYAxis = this.context.component.slots.yAxis.find(a => a.config.gridIndex === gridIdx)
if (!firstXAxis) {
if (type === 'oh-time-series') {
if (type === 'oh-time-series' || type === 'oh-state-series') {
this.addAxis(gridIdx, 'xAxis', 'oh-time-axis')
firstXAxis = this.context.component.slots.xAxis.find(a => a.config.gridIndex === gridIdx)
automaticAxisCreated = true
@ -392,6 +395,11 @@ export default {
this.addAxis(gridIdx, 'yAxis', 'oh-value-axis')
firstYAxis = this.context.component.slots.yAxis.find(a => a.config.gridIndex === gridIdx)
automaticAxisCreated = true
} else if (type === 'oh-state-series') {
this.addAxis(gridIdx, 'yAxis', 'oh-category-axis')
firstYAxis = this.context.component.slots.yAxis.find(a => a.config.gridIndex === gridIdx)
firstYAxis.config.categoryType = 'values'
automaticAxisCreated = true
} else {
this.$f7.dialog.alert('Please add at least one X axis and one Y axis')
return
@ -406,16 +414,27 @@ export default {
}).open()
}
this.context.component.slots.series.push({
let component = {
component: type,
config: {
name: 'Series ' + (this.context.component.slots.series.length + 1),
gridIndex: gridIdx,
xAxisIndex: this.context.component.slots.xAxis.indexOf(firstXAxis),
yAxisIndex: this.context.component.slots.yAxis.indexOf(firstYAxis),
type: 'line'
yAxisIndex: this.context.component.slots.yAxis.indexOf(firstYAxis)
}
})
}
if (type === 'oh-state-series') {
if (firstYAxis.config.categoryType === 'values') {
firstYAxis.config.data = firstYAxis.config.data || []
firstYAxis.config.data.unshift(component.config.name)
component.config.yValue = firstYAxis.config.data.length - 1
}
} else {
component.config.type = 'line'
}
this.context.component.slots.series.push(component)
},
configureSeries (ev, series, context) {
let el = ev.target

View File

@ -22,7 +22,7 @@ export default {
let axis = chartWidget.evaluateExpression(ComponentId.get(component), component.config)
axis.type = 'category'
axis.data = []
axis.data = axis.data || []
switch (config.categoryType) {
case 'hour':
axis.name = 'min'

View File

@ -13,6 +13,7 @@ import OhDataSeries from './series/oh-data-series'
import OhTimeSeries from './series/oh-time-series'
import OhAggregateSeries from './series/oh-aggregate-series'
import OhCalendarSeries from './series/oh-calendar-series'
import OhStateSeries from './series/oh-state-series'
// Other components
import OhChartTooltip from './misc/oh-chart-tooltip'
@ -35,7 +36,8 @@ const seriesComponents = {
'oh-data-series': OhDataSeries,
'oh-time-series': OhTimeSeries,
'oh-aggregate-series': OhAggregateSeries,
'oh-calendar-series': OhCalendarSeries
'oh-calendar-series': OhCalendarSeries,
'oh-state-series': OhStateSeries
}
export default {

View File

@ -0,0 +1,116 @@
import ComponentId from '../../component-id'
import Framework7 from 'framework7'
import { graphic } from 'echarts/core'
function renderState (params, api) {
const yValue = api.value(0)
const start = api.coord([api.value(1), yValue])
const duration = api.value(2)
const end = api.coord([api.value(1) + duration, yValue])
const state = api.value(3)
const yHeight = api.value(4)
if (state === 'UNDEF' || state === 'NULL') return
const height = api.size([0, 1])[1] * yHeight
const rectShape = graphic.clipRectByRect(
{
x: start[0],
y: start[1] - height / 2,
width: end[0] - start[0],
height
},
{
x: params.coordSys.x,
y: params.coordSys.y,
width: params.coordSys.width,
height: params.coordSys.height
}
)
return (
rectShape && {
type: 'rect',
shape: rectShape,
style: api.style({})
}
)
}
export default {
neededItems (component, chart) {
let series = chart.evaluateExpression(ComponentId.get(component), component.config)
return [
series.item
]
},
get (component, points, startTime, endTime, chart) {
let series = chart.evaluateExpression(ComponentId.get(component), component.config)
series.type = 'custom'
series.renderItem = renderState
series.encode = {
x: -1, // don't filter by x value when zooming
y: 0,
tooltip: [3],
itemName: 3
}
series.colorBy = 'data'
series.label = series.label || { }
if (series.label.show === undefined) series.label.show = true
series.label.position = series.label.position || 'insideLeft'
series.label.formatter = series.label.formatter || '{@[3]}'
series.labelLayout = series.labelLayout || { hideOverlap: true }
series.tooltip = series.tooltip || { }
if (series.tooltip.formatter === undefined) {
series.tooltip.formatter = (params) => {
let durationSec = params.value[2] / 1000
let hours = Math.floor(durationSec / 3600).toString().padStart(2, '0')
let minutes = Math.floor((durationSec - (hours * 3600)) / 60).toString().padStart(2, '0')
return params.seriesName + '<br />' + params.marker + params.name + '&#9;(' + hours + ':' + minutes + ')'
}
}
series.data = []
if (series.item) {
let itemPoints = points.find(p => p.name === series.item).data
if (series.mapState) {
for (let i = 0; i < itemPoints.length; i++) {
itemPoints[i].state = series.mapState(itemPoints[i].state)
}
}
let data = []
let itemStartTime = null
// fill data array - combine state updates where state does not change
for (let i = 0; i < itemPoints.length; i++) {
if (itemPoints[i + 1] && itemPoints[i].state === itemPoints[i + 1].state) {
itemStartTime = itemStartTime || new Date(itemPoints[i].time)
continue
}
itemStartTime = itemStartTime || new Date(itemPoints[i].time)
let itemEndTime = new Date(itemPoints[i + 1] ? itemPoints[i + 1].time : endTime)
let itemDuration = itemEndTime - itemStartTime
let stateColor = (series.stateColor) ? series.stateColor[itemPoints[i].state] : null
data.push({
value: [series.yValue || 0, itemStartTime, itemDuration, itemPoints[i].state, series.yHeight || 0.6],
itemStyle: {
color: stateColor
}
})
itemStartTime = null
}
series.data = data
series.id = `oh-state-series#${series.item}#${Framework7.utils.id()}`
}
if (!series.tooltip) {
series.tooltip = { show: true }
}
return series
}
}

View File

@ -48,16 +48,17 @@ dayjs.extend(LocalizedFormat)
import { use, registerLocale } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { LineChart, BarChart, GaugeChart, HeatmapChart, PieChart, ScatterChart } from 'echarts/charts'
import { LineChart, BarChart, GaugeChart, HeatmapChart, PieChart, ScatterChart, CustomChart } from 'echarts/charts'
import { LabelLayout } from 'echarts/features'
import {
TitleComponent, LegendComponent, LegendScrollComponent, GridComponent, SingleAxisComponent, ToolboxComponent, TooltipComponent,
DataZoomComponent, MarkLineComponent, MarkPointComponent, MarkAreaComponent, VisualMapComponent, CalendarComponent
} from 'echarts/components'
import VChart from 'vue-echarts'
use([CanvasRenderer, LineChart, BarChart, GaugeChart, HeatmapChart, PieChart, ScatterChart, TitleComponent,
use([CanvasRenderer, LineChart, BarChart, GaugeChart, HeatmapChart, PieChart, ScatterChart, CustomChart, TitleComponent,
LegendComponent, LegendScrollComponent, GridComponent, SingleAxisComponent, ToolboxComponent, TooltipComponent, DataZoomComponent,
MarkLineComponent, MarkPointComponent, MarkAreaComponent, VisualMapComponent, CalendarComponent])
MarkLineComponent, MarkPointComponent, MarkAreaComponent, VisualMapComponent, CalendarComponent, LabelLayout])
const ECHARTS_LOCALE = i18n.locale.split('-')[0].toUpperCase()