import { html, css, LitElement, PropertyValueMap } from 'lit'
import { customElement, property, state } from 'lit/decorators.js'
import tinycolor, { ColorInput } from 'tinycolor2'
import { Duration } from 'luxon'
import * as echarts from 'echarts/core'
import {
TitleComponent,
TooltipComponent,
GridComponent,
DataZoomComponent,
LegendComponent,
ToolboxComponent
} from 'echarts/components'
import { CustomChart } from 'echarts/charts'
import { CanvasRenderer } from 'echarts/renderers'
echarts.use([
TitleComponent,
TooltipComponent,
GridComponent,
DataZoomComponent,
CustomChart,
CanvasRenderer,
LegendComponent,
// The option template declares a toolbox with the zoom `restore` button and
// applyData() shows it whenever axis.xAxisZoom is on. Without registering
// the component here ECharts logs "Component toolbox is used but not
// imported" and drops it, so the reset button never appears.
ToolboxComponent
])
import { Color, StateHistoryConfiguration } from './definition-schema'
import { CustomSeriesOption, CustomSeriesRenderItemReturn, EChartsOption, SeriesOption } from 'echarts'
type Theme = {
theme_name: string
theme_object: any
}
@customElement('widget-statehistory-versionplaceholder')
export class WidgetStateHistory extends LitElement {
@property({ type: Object })
inputData?: StateHistoryConfiguration
@property({ type: Object })
theme?: Theme
@state()
private canvasList: Map<
string,
{ echart?: echarts.ECharts; series: SeriesOption[]; doomed?: boolean; element?: HTMLDivElement }
> = new Map()
@state() private themeBgColor?: string
@state() private themeTitleColor?: string
@state() private themeSubtitleColor?: string
boxes?: HTMLDivElement[]
origWidth: number = 0
origHeight: number = 0
template: EChartsOption
modifier: number = 1
version: string = 'versionplaceholder'
chartContainer: HTMLDivElement | null | undefined
resizeObserver?: ResizeObserver
constructor() {
super()
this.renderItem = this.renderItem.bind(this)
this.template = {
// ECharts paints the registered theme's own canvas `backgroundColor`
// (chalk = `rgba(41,52,65,1)`) as an opaque slab over the wrapper's
// background, so the tile shows the theme colour behind the chart and
// the host's `--re-tile-background-color` everywhere else. Keeping the
// canvas transparent lets the wrapper show through; it already falls
// back to the theme's colour when nothing overrides it, so no theme
// loses its background. Set here rather than filtered out of
// registerTheme() — theme registration is global by name, so another
// widget on the page can re-register the same theme with it.
backgroundColor: 'transparent',
tooltip: {},
title: {
text: 'Profile',
left: 'left'
},
legend: {
data: [
{
name: 'RUNNING',
itemStyle: {
color: '#27ae60' // green
}
},
{
name: 'STOPPED',
itemStyle: {
color: '#c0392b' // red
}
}
],
textStyle: {
fontSize: 12
},
// Always a single row: overflowing items page instead of
// wrapping, so the legend has a fixed height that applyData()
// can reserve above the plot. A wrapping legend grew downward
// over the title and the state bars on narrow tiles.
type: 'scroll',
orient: 'horizontal',
left: 'center',
top: 0, // set per build in applyData()
selectedMode: false
},
toolbox: {
show: true,
// Match the legend's padding so the button lines up with the
// legend row it shares (ECharts 6 defaults this to 15).
padding: 5,
feature: {
// dataZoom: {
// yAxisIndex: 'none'
// },
// dataView: { readOnly: false },
restore: {}
// saveAsImage: {}
}
},
dataZoom: [
{
type: 'slider',
filterMode: 'weakFilter',
showDataShadow: false,
// Anchored to the bottom edge. This used to be a hardcoded
// `top: 400`, which placed the slider 400px down from the top
// of the widget regardless of its actual height — landing in
// the middle of the chart at any size other than that one.
bottom: 0,
labelFormatter: ''
},
{
type: 'inside',
filterMode: 'weakFilter'
}
],
grid: {
top: 50,
bottom: 10,
left: 10,
right: 10,
containLabel: true
},
xAxis: {
type: 'time',
scale: true,
// Without these the axis name defaults to nameLocation 'end',
// which puts it past the right edge of the axis where the grid
// clips it — so the configured X-Axis Label was never visible.
nameLocation: 'middle',
nameGap: 27
},
yAxis: {
type: 'category',
axisLine: {
show: false
}
},
series: [
{
type: 'custom',
name: 'RUNNING',
renderItem: undefined,
itemStyle: {
opacity: 0.8
},
encode: {
x: [1, 2],
y: 0
},
data: []
}
]
} as EChartsOption
}
renderItem(params: any, api: any): CustomSeriesRenderItemReturn {
const asset = api.value(0)
const start = api.coord([api.value(1), asset])
const end = api.coord([api.value(2), asset])
const height = api.size([0, 1])?.[1] * 0.6
const rectShape = echarts.graphic.clipRectByRect(
{
x: start[0],
y: start[1] - height / 2,
width: end[0] - start[0],
height: height
},
{
x: params.coordSys.x,
y: params.coordSys.y,
width: params.coordSys.width,
height: params.coordSys.height
}
)
// console.log('renderItem', params, api, rectShape)
return (
rectShape && {
type: 'rect',
transition: ['shape'],
shape: rectShape,
style: api.style()
}
)
}
stateToColor(state: string): string {
if (!this.inputData?.stateMap) return '#ccc'
const colors = this.inputData.stateMap
return (colors.find((s) => s.name == state)?.color || '#ccc') as string
}
update(changedProperties: PropertyValueMap | Map): void {
if (changedProperties.has('inputData') && this.chartContainer) {
this.transformData()
this.applyData()
}
if (changedProperties.has('theme')) {
this.registerTheme(this.theme)
this.deleteCharts()
this.transformData()
this.applyData()
}
super.update(changedProperties)
}
protected firstUpdated(_changedProperties: PropertyValueMap | Map): void {
this.chartContainer = this.shadowRoot?.querySelector('.chart-container')
this.transformData()
this.applyData()
this.registerTheme(this.theme)
// Add ResizeObserver for chart container
if (this.chartContainer) {
this.resizeObserver = new ResizeObserver(() => {
this.canvasList.forEach((chart) => {
chart.echart?.resize()
})
})
this.resizeObserver.observe(this.chartContainer)
}
}
/**
* Resolved text colour for the chart canvas.
*
* The themeTitleColor field holds a var() chain, which the browser resolves
* for CSS but ECharts cannot — it paints to a canvas and needs a real
* colour. Read at the point of use rather than cached, so a board style
* edit reaches the next chart update instead of waiting for a theme change.
*/
private resolvedTextColor(): string | undefined {
return (
getComputedStyle(this).getPropertyValue('--re-text-color').trim() ||
this.theme?.theme_object?.title?.textStyle?.color
)
}
registerTheme(theme?: Theme) {
this.themeBgColor = `var(--re-tile-background-color, ${this.theme?.theme_object?.backgroundColor || 'transparent'})`
this.themeTitleColor = `var(--re-text-color, ${this.theme?.theme_object?.title?.textStyle?.color || 'inherit'})`
this.themeSubtitleColor = `var(--re-text-color, ${this.theme?.theme_object?.title?.subtextStyle?.color || this.theme?.theme_object?.title?.textStyle?.color || 'inherit'})`
if (!theme || !theme.theme_object || !theme.theme_name) return
// Strip component keys for components we don't register, so ECharts
// doesn't merge them into the chart option as defaults.
const excludeKeys = [
'parallel',
'parallelAxis',
'geo',
'timeline',
'markPoint',
'markLine',
'markArea',
'brush',
'calendar',
'singleAxis',
'polar',
'radar',
'axisPointer',
'visualMap'
]
const filteredTheme = Object.fromEntries(
Object.entries(theme.theme_object).filter(([key]) => !excludeKeys.includes(key))
)
echarts.registerTheme(theme.theme_name, filteredTheme)
}
transformData() {
if (!this?.inputData?.dataseries?.length) return
// reset all existing chart dataseries
this.canvasList.forEach((chartM) => {
chartM.series = []
chartM.doomed = true
})
this.inputData.dataseries.forEach((ds, l) => {
ds.chartName ??= ''
ds.data ??= []
// pivot data
const distincts = [...new Set(ds.data.map((d) => d.pivot ?? ''))].sort()
distincts.forEach((piv, i) => {
const prefix = piv ?? ''
const label = ds.label ?? ''
const name = prefix + (!!prefix && !!label ? ' - ' : '') + label
const data =
(distincts.length === 1 ? ds.data : ds.data?.filter((d) => d.pivot === piv)) ?? []
const data1 = data
.map((d) => ({ ...d, tsp: new Date(d.tsp ?? '') }))
.sort((a, b) => a.tsp.getTime() - b.tsp.getTime())
const maxDateInData =
data1.length > 0 ? Math.max(...data1.map((d) => d.tsp.getTime())) : new Date().getTime()
const maxDate = Math.min(new Date().getTime(), maxDateInData + 60 * 60 * 1000)
const data2 = data1.map((d, j) => ({
name: d.state,
value: [
name,
d.tsp,
data1[j + 1]?.tsp ?? maxDate,
Duration.fromMillis(
(data1[j + 1]?.tsp.getTime() ?? new Date().getTime()) - d.tsp.getTime()
).toFormat('h:mm:ss')
],
itemStyle: {
normal: {
color: this.stateToColor(d.state ?? '')
}
}
}))
// preparing the echarts series option for later application
const pds: any = {
type: 'custom',
name: name,
renderItem: this.renderItem,
itemStyle: {
opacity: 0.8
},
encode: {
x: [1, 2],
y: 0
},
data: data2 ?? []
}
let chartName = ds.chartName ?? ''
chartName = chartName.replace('#split#', prefix)
const chart = this.setupChart(chartName)
chart?.series.push(pds)
})
})
const doomedCharts: string[] = []
// remove all doomed charts
this.canvasList.forEach((chart, label) => {
if (!chart.doomed) return
chart.echart?.dispose()
chart.element?.remove()
doomedCharts.push(label)
})
doomedCharts.forEach((label) => this.canvasList.delete(label))
}
/**
* Assign properties onto a single-instance ECharts component.
*
* The option template holds `title`, `tooltip`, `xAxis`, `legend` and
* `toolbox` as plain objects, but on every update after the first the option
* comes from `getOption()`, which normalises each of them to a one-element
* array. Writing `option.xAxis.name = …` then sets a property on the Array
* itself and ECharts silently ignores it, so config changes (axis label,
* legend toggle, zoom tool) appeared to do nothing until the chart was
* recreated. Going through here keeps both shapes working.
*/
private setComponent(option: any, key: string, props: Record) {
const list = Array.isArray(option[key]) ? option[key] : [option[key] ?? {}]
if (!list.length) list.push({})
Object.assign(list[0], props)
option[key] = list
}
applyData() {
const modifier = 1
this.canvasList.forEach((chart, label) => {
chart.series.sort((a, b) => ((a.name as string) > (b.name as string) ? 1 : -1))
this.requestUpdate()
// Always build the option from the template — never from getOption().
// Reading the rendered option back and handing it to setOption
// deep-clones the whole stored option on every frame, and it returns
// every component normalized to an array — which is why setComponent()
// below has to accept both shapes, and why any plain `{ ...option.x }`
// here would spread an array into `{ '0': x }` and have ECharts merge it
// back one level deeper each update until zrender's recursive merge()
// overflowed the stack. Starting from the template also drops the need to
// strip theme-merged defaults for components we never register — the
// template only names components registered in echarts.use above.
const option: any = window.structuredClone(this.template)
option.renderItem = this.renderItem
const showLegend = this.inputData?.axis?.showLegend ?? true
const showZoom = this.inputData?.axis?.xAxisZoom ?? false
// Title
this.setComponent(option, 'title', { text: label })
this.setComponent(option, 'tooltip', {
formatter: (params: any) => params.marker + params.name + ': ' + params.value[3]
})
// Axis. nameLocation/nameGap are re-applied here because on the merge
// path the option comes from getOption(), which may carry ECharts'
// own defaults rather than the template's.
const xAxisLabel = this.inputData?.axis?.xAxisLabel ?? ''
this.setComponent(option, 'xAxis', {
name: xAxisLabel,
nameLocation: 'middle',
nameGap: 27
})
this.setComponent(option, 'dataZoom', { show: showZoom, bottom: 0, top: undefined })
this.setComponent(option, 'toolbox', { show: showZoom })
// Neither the axis name nor the zoom slider is accounted for by the
// grid's `containLabel`, so both need their room reserved explicitly
// or they are drawn over the state bars.
const ZOOM_THICKNESS = 40
// The top edge is a stack of rows, each with a fixed height: the
// chart title, then the legend (a one-row scroll legend, see the
// template). The title is the chart name, which is empty unless the
// series set chartName, so only a non-empty one takes a row.
const TITLE_ROW = 30
const LEGEND_ROW = 24
const legendTop = label ? TITLE_ROW : 0
this.setComponent(option, 'grid', {
top: legendTop + (showLegend ? LEGEND_ROW : 0) + 10,
bottom: (xAxisLabel ? 30 : 10) + (showZoom ? ZOOM_THICKNESS : 0)
})
// A copy, not `chart.series` itself: the legend block below appends
// empty placeholder series, and pushing those onto the chart record
// would leave them in `chart.series` for the rest of the cycle —
// where the sort above and every `chart.series` reader would count
// them as real data series.
option.series = [...chart.series]
// The zoom-reset button shares the legend row at its right end, so
// the legend (and its pager) is narrowed to stay clear of it. Both
// widths are written because this is a merge update: a width left
// out would keep the previous build's value.
this.setComponent(option, 'toolbox', { top: legendTop, right: 0 })
this.setComponent(option, 'legend', {
show: showLegend,
top: legendTop,
width: showZoom ? '80%' : 'auto'
})
// The theme's legend colours were picked for its own background and
// vanish when the host overrides the tile colours; the pager's
// defaults (#333 text, dark icons) vanish on any dark tile.
const textColor = this.resolvedTextColor()
if (textColor) {
this.setComponent(option, 'legend', {
textStyle: { fontSize: 12, color: textColor },
pageTextStyle: { color: textColor },
pageIconColor: textColor
})
}
if (showLegend) {
const legend = this.makeLegend()
this.setComponent(option, 'legend', { data: legend.data })
option.series.push(...legend.series)
}
chart.echart?.setOption(option)
// chart.echart?.resize()
})
}
makeLegend() {
const data =
this.inputData?.stateMap?.map((s) => ({
name: s.name ?? '',
itemStyle: {
color: s.color,
borderColor: this.theme?.theme_object?.timeAxis?.splitLine?.lineStyle ?? '#ccc',
borderWidth: 1
}
})) ?? []
// otherwise the legend will not be rendered
const series =
this.inputData?.stateMap?.map((s) => ({
type: 'custom',
name: s.name ?? '',
renderItem: () => null, // no visual rendering
data: []
})) ?? []
return { data, series }
}
deleteCharts() {
this.canvasList.forEach((chart, label) => {
chart.echart?.dispose()
chart.element?.remove()
this.canvasList.delete(label)
})
}
setupChart(label: string) {
const existingChart = this.canvasList.get(label)
if (existingChart) {
delete existingChart.doomed
return existingChart
}
if (!this.chartContainer) {
console.warn('Chart container not found')
return
}
const newContainer = document.createElement('div')
newContainer.setAttribute('name', label)
newContainer.setAttribute('class', 'sizer')
this.chartContainer.appendChild(newContainer)
const newChart = echarts.init(newContainer, this.theme?.theme_name)
const chart = { echart: newChart, series: [] as SeriesOption[], element: newContainer }
this.canvasList.set(label, chart)
return chart
}
disconnectedCallback() {
if (this.resizeObserver && this.chartContainer) {
this.resizeObserver.unobserve(this.chartContainer)
this.resizeObserver.disconnect()
}
super.disconnectedCallback()
}
static styles = css`
:host {
display: block;
font-family: sans-serif;
box-sizing: border-box;
position: relative;
margin: auto;
}
.paging:not([active]) {
display: none !important;
}
.wrapper {
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
padding: 16px;
box-sizing: border-box;
gap: 12px;
}
.sizer {
flex: 1;
overflow: hidden;
position: relative;
}
.chart-container {
display: flex;
flex: 1;
overflow: hidden;
position: relative;
}
header {
display: flex;
flex-direction: column;
}
h3 {
margin: 0;
max-width: 300px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
p {
margin: 10px 0 0 0;
max-width: 300px;
font-size: 14px;
line-height: 17px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chart {
width: 600px; /* will be overriden by adjustSizes */
height: 230px;
}
.columnLayout {
flex-direction: column;
}
.no-data {
font-size: 20px;
display: flex;
height: 100%;
width: 100%;
text-align: center;
align-items: center;
justify-content: center;
}
`
render() {
return html`