@record-evolution/widget-linechart 1.7.1 → 1.7.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,27 @@
1
1
  {
2
- "title": "inputData",
3
- "description": "A versatile chart widget for visualizing data as line charts, bar charts, or scatter plots. Use this widget for time-series data, trend analysis, comparisons, and general data visualization. Supports multiple data series with different chart types in the same view, automatic pivot-based series generation, zoom controls, and flexible styling options. The ECharts-based rendering provides smooth animations and interactive tooltips. Ideal for monitoring dashboards, historical data analysis, and any scenario requiring visual data representation over continuous or categorical dimensions.",
2
+ "title": "Chart Configuration",
3
+ "description": "A versatile chart widget for visualizing data as line charts, bar charts, or scatter plots. Despite the package name, this is also THE bar chart widget: set dataseries[].type to 'bar' for vertical bars and axis.orientation to 'horizontal' for horizontal ones. Use this widget for time-series data, trend analysis, comparisons, frequency distributions, and general data visualization. Supports multiple data series with different chart types in the same view, automatic pivot-based series generation, zoom controls, and flexible styling options. The ECharts-based rendering provides smooth animations and interactive tooltips. Ideal for monitoring dashboards, historical data analysis, and any scenario requiring visual data representation over continuous or categorical dimensions.",
4
+ "aiSelection": {
5
+ "dataShape": "One or more series of (x, y) pairs. x is a timestamp, a number, or a category label and defines the position along the x-value dimension; y is the measured quantity. All series in a chart share one x dimension. An optional third column bound to data[].pivot splits a single series into one line or bar per distinct value of that column, and an optional data[].r sizes the marker for a bubble chart. Needs at least two columns of data; a single scalar has no shape to plot.",
6
+ "useWhen": [
7
+ "A quantity is measured repeatedly over time and the trend, shape or direction of change is the point (axis.timeseries: true, dataseries[].type: 'line').",
8
+ "A quantity is compared across a handful of discrete categories — counts, totals, frequency distributions, per-machine or per-site comparisons (dataseries[].type: 'bar', axis.timeseries: false). This is the correct widget for any bar chart; there is no separate bar chart widget.",
9
+ "Categories are being ranked, or their names are long enough to be unreadable side by side under a vertical axis (dataseries[].type: 'bar', axis.orientation: 'horizontal').",
10
+ "Two numeric variables are being correlated as a cloud of individual points (dataseries[].type: 'scatter'), optionally with data[].r encoding a third variable as marker size.",
11
+ "Several quantities with different units or wildly different ranges belong on one chart (assign the second group dataseries[].yAxis: 'right').",
12
+ "The same measurement is broken down by an entity — one line per machine, per site, per program (bind the breakdown column to data[].pivot)."
13
+ ],
14
+ "notFor": [
15
+ "Parts of a whole, shares, or percentages that add up to 100% — use widget-doughnut.",
16
+ "A single current number, reading or KPI with no second dimension to plot — use widget-value, or widget-gauge when it should be read against a min/max range.",
17
+ "Records the user needs to read row by row, or data with more columns than an x and a y — use widget-table.",
18
+ "Intensity or density across two categorical dimensions at once, i.e. a matrix or calendar grid — use widget-heatmap.",
19
+ "How long an entity stayed in each discrete state over time (state timelines, machine mode history, up/down periods drawn as spans). A bar chart of state durations is the wrong shape — use widget-statehistory.",
20
+ "Anything positioned by geographic coordinates — use widget-mapbox.",
21
+ "A single binary or few-valued status shown as an indicator rather than plotted — use widget-signal, or widget-switch when the user must also be able to toggle it.",
22
+ "Free text, headings or explanatory prose — use widget-label, widget-textbox or widget-markdown."
23
+ ]
24
+ },
4
25
  "type": "object",
5
26
  "properties": {
6
27
  "title": {
@@ -73,9 +94,18 @@
73
94
  "dataDrivenDisabled": true,
74
95
  "order": 4
75
96
  },
97
+ "orientation": {
98
+ "title": "Bar Direction",
99
+ "description": "The direction in which bars grow and the chart is read. 'vertical' (the default) places the x-values (categories or timestamps) along the horizontal axis at the bottom and the measured y-values along the vertical axis, so bars grow upwards. 'horizontal' swaps the two axes: the x-values run down the left-hand vertical axis, first data point at the top, and the y-values grow to the right, producing a horizontal bar chart. Choose 'horizontal' for ranked or frequency charts, and whenever category names are long enough to be unreadable when squeezed under a vertical axis. This setting affects only the drawing direction, not the meaning of the other options: 'X-Axis Label' always names the x-value dimension and 'Y-Axis Label' always names the measured values, whichever side of the chart they end up on. Applies to all series in the chart, including line and scatter series.",
100
+ "type": "string",
101
+ "enum": ["vertical", "horizontal"],
102
+ "default": "vertical",
103
+ "dataDrivenDisabled": true,
104
+ "order": 4.5
105
+ },
76
106
  "columnLayout": {
77
- "title": "Vertical Layout",
78
- "description": "When multiple charts are drawn (via chartName grouping), this controls their arrangement. Enabled = stacked vertically, Disabled = arranged horizontally side by side.",
107
+ "title": "Stack Multiple Charts Vertically",
108
+ "description": "Controls how SEPARATE charts are arranged relative to each other when the series are grouped into more than one chart via 'Chart Name' (dataseries[].advanced.chartName). Enabled = the charts are stacked one above the other, Disabled = the charts sit side by side. This is a page-layout option and has no effect on a single chart; to change the direction bars grow in, use 'Bar Direction' (axis.orientation) instead.",
79
109
  "type": "boolean",
80
110
  "dataDrivenDisabled": true,
81
111
  "order": 5
@@ -218,6 +248,14 @@
218
248
  "none"
219
249
  ],
220
250
  "order": 5
251
+ },
252
+ "showValueLabels": {
253
+ "title": "Show Value Labels",
254
+ "description": "When enabled, each data point's y-value is printed as text directly on the chart: above the top of every bar in a vertical bar chart, just past the end of the bar in a horizontal one, and above the marker for line and scatter series. Enable this whenever the reader is meant to read exact numbers off the chart rather than estimate them against the axis — the usual expectation for bar charts of counts, totals or frequencies, and the setting to use when someone asks for the values to be shown on or above the bars. Numbers are rounded to two decimal places; non-numeric values are printed unchanged. Leave it disabled for charts with many data points, where the labels would overlap each other.",
255
+ "type": "boolean",
256
+ "default": false,
257
+ "dataDrivenDisabled": true,
258
+ "order": 6
221
259
  }
222
260
  }
223
261
  },
@@ -30,7 +30,7 @@ echarts.use([
30
30
  LegacyGridContainLabel
31
31
  ])
32
32
 
33
- import { InputData } from './definition-schema'
33
+ import { ChartConfiguration } from './definition-schema'
34
34
  import { EChartsOption, SeriesOption } from 'echarts'
35
35
  import { TitleOption } from 'echarts/types/dist/shared'
36
36
 
@@ -43,12 +43,19 @@ type SeriesOptionX = SeriesOption & {
43
43
  minDate?: number
44
44
  maxDate?: number
45
45
  drawOrder: number
46
+ xAxisIndex?: number
46
47
  yAxisIndex?: number
48
+ // Which value axis (0 = primary, 1 = secondary) this series belongs to,
49
+ // independent of orientation. In a vertical chart the value axes are the
50
+ // y axes (left/right), in a horizontal one they are the x axes (bottom/top),
51
+ // so xAxisIndex/yAxisIndex alone cannot be read without knowing the
52
+ // orientation — this field can.
53
+ valueAxisIndex?: number
47
54
  }
48
55
  @customElement('widget-linechart-versionplaceholder')
49
56
  export class WidgetLinechart extends LitElement {
50
57
  @property({ type: Object })
51
- inputData?: InputData
58
+ inputData?: ChartConfiguration
52
59
 
53
60
  @property({ type: Object })
54
61
  theme?: Theme
@@ -270,9 +277,49 @@ export class WidgetLinechart extends LitElement {
270
277
  echarts.registerTheme(theme.theme_name, filteredTheme)
271
278
  }
272
279
 
280
+ /** True when bars grow to the right instead of upwards (axis.orientation). */
281
+ isHorizontal(): boolean {
282
+ return this.inputData?.axis?.orientation === 'horizontal'
283
+ }
284
+
285
+ /**
286
+ * Index of the measured y-value inside a data point's `value` tuple.
287
+ *
288
+ * A point is stored as `[x, y, r]` for a vertical chart. A horizontal chart
289
+ * swaps the first two entries to `[y, x, r]`, because ECharts always maps
290
+ * tuple index 0 to the x axis and index 1 to the y axis — and in a
291
+ * horizontal chart the measured value is what belongs on the x axis.
292
+ */
293
+ private valueIndex(): number {
294
+ return this.isHorizontal() ? 0 : 1
295
+ }
296
+
297
+ /** Index of the x-value (category/timestamp) inside a data point's `value` tuple. */
298
+ private categoryIndex(): number {
299
+ return this.isHorizontal() ? 1 : 0
300
+ }
301
+
302
+ /**
303
+ * Formatter for the on-chart value labels (styling.showValueLabels).
304
+ * Rounds to two decimals to match the numeric axis labels, and leaves
305
+ * categorical y-values untouched.
306
+ */
307
+ private valueLabelFormatter(valueIdx: number) {
308
+ return (params: any) => {
309
+ const raw = Array.isArray(params?.value) ? params.value[valueIdx] : params?.value
310
+ if (raw === undefined || raw === null || raw === '') return ''
311
+ const num = Number(raw)
312
+ return isNaN(num) ? String(raw) : String(Math.round(num * 100) / 100)
313
+ }
314
+ }
315
+
273
316
  transformData() {
274
317
  if (!this?.inputData?.dataseries?.length) return
275
318
 
319
+ const horizontal = this.isHorizontal()
320
+ const valueIdx = this.valueIndex()
321
+ const categoryIdx = this.categoryIndex()
322
+
276
323
  // reset all existing chart dataseries
277
324
  this.canvasList.forEach((chartM) => {
278
325
  chartM.series = []
@@ -309,22 +356,29 @@ export class WidgetLinechart extends LitElement {
309
356
  : derivedBgColors[i]
310
357
  : undefined
311
358
  const data = distincts.length === 1 ? ds.data : ds.data?.filter((d) => d.pivot === piv)
359
+ // The value tuple is [x, y, r] for a vertical chart and [y, x, r]
360
+ // for a horizontal one — ECharts always reads index 0 as the x
361
+ // axis, and in a horizontal chart the measured value lives there.
362
+ const toTuple = (x: any, y: any, r: any) => (horizontal ? [y, x, r] : [x, y, r])
312
363
  let data2 = this.inputData?.axis?.timeseries
313
- ? (data?.map((d) => ({ name: d.x, value: [new Date(d.x ?? '').getTime(), d.y, d.r] })) ??
314
- [])
315
- : (data?.map((d) => ({ name: d.x, value: [d.x, d.y, d.r] })) ?? [])
364
+ ? (data?.map((d) => ({
365
+ name: d.x,
366
+ value: toTuple(new Date(d.x ?? '').getTime(), d.y, d.r)
367
+ })) ?? [])
368
+ : (data?.map((d) => ({ name: d.x, value: toTuple(d.x, d.y, d.r) })) ?? [])
316
369
 
317
370
  let minDate: number = 0,
318
371
  maxDate: number = 0,
319
372
  extraData: (string | number | undefined)[][] = []
320
373
  if (this.xAxisType() === 'time' && data2) {
321
- const dates = data2.map((d: any) => d.value[0] as number)
374
+ const dates = data2.map((d: any) => d.value[categoryIdx] as number)
322
375
  minDate = Math.min(...dates)
323
376
  maxDate = Math.max(...dates)
324
377
  // extraData = (pds?.data as any[][])?.filter((d: any) => d[0] < minDate) ?? []
325
378
  // data2.unshift(...extraData) // add old data to the beginning of the new data
326
379
  // data2 = [...extraData, ...data2] // leave old data in for smooth shifting animation and delete after draw
327
380
  }
381
+ const valueAxisIndex = ds.yAxis === 'right' ? 1 : 0
328
382
  const pds: SeriesOptionX = {
329
383
  id: name,
330
384
  name: name,
@@ -346,9 +400,21 @@ export class WidgetLinechart extends LitElement {
346
400
  symbol: ds.styling?.pointStyle ?? 'circle',
347
401
  symbolSize: (d: any[]) => d[2] ?? 0,
348
402
  showSymbol: ds.styling?.pointStyle === 'none' ? false : true,
403
+ label: {
404
+ show: ds.styling?.showValueLabels ?? false,
405
+ // Bars grow upwards when vertical and rightwards when
406
+ // horizontal, so the label sits past the growing end.
407
+ position: horizontal ? 'right' : 'top',
408
+ fontSize: 12,
409
+ formatter: this.valueLabelFormatter(valueIdx)
410
+ },
349
411
  data: data2 ?? [],
350
412
  drawOrder: ds.advanced?.drawOrder ?? 0,
351
- yAxisIndex: ds.yAxis === 'right' ? 1 : 0
413
+ valueAxisIndex: valueAxisIndex,
414
+ // The secondary value axis is the right y axis when vertical
415
+ // and the top x axis when horizontal.
416
+ xAxisIndex: horizontal ? valueAxisIndex : 0,
417
+ yAxisIndex: horizontal ? 0 : valueAxisIndex
352
418
  }
353
419
  let chartName = ds.advanced?.chartName ?? ''
354
420
  chartName = chartName.replace('#split#', prefix)
@@ -485,6 +551,7 @@ export class WidgetLinechart extends LitElement {
485
551
  const modifier = 1
486
552
  // Sort chartContainer children by drawOrder and label
487
553
  if (!this.chartContainer) return
554
+ const horizontal = this.isHorizontal()
488
555
  for (const canvas of this.canvasList.values()) {
489
556
  if (canvas.element) this.chartContainer.appendChild(canvas.element)
490
557
  }
@@ -513,17 +580,37 @@ export class WidgetLinechart extends LitElement {
513
580
  yAxisScalingRight: this.inputData?.axis?.yAxisScalingRight,
514
581
  xAxisType: this.xAxisType(),
515
582
  yAxisType: this.yAxisType(),
583
+ // Flipping the orientation swaps which option key holds the
584
+ // single category axis and which holds the value-axis array,
585
+ // so it must force a notMerge rebuild rather than a merge.
586
+ orientation: this.inputData?.axis?.orientation ?? 'vertical',
516
587
  seriesCount: chart.series.length,
517
588
  seriesNames: chart.series.map((s) => s.name).join(','),
518
- seriesAxes: chart.series.map((s) => s.yAxisIndex ?? 0).join(',')
589
+ seriesAxes: chart.series.map((s) => s.valueAxisIndex ?? 0).join(','),
590
+ seriesLabels: chart.series.map((s) => ((s as any).label?.show ? 1 : 0)).join(',')
519
591
  })
520
592
  const configChanged = chart.lastConfig !== currentConfig
521
593
  chart.lastConfig = currentConfig
522
594
 
523
- // Use efficient merge for data-only updates, full rebuild for config changes
524
- const option: any = configChanged
525
- ? window.structuredClone(this.template)
526
- : (chart.echart?.getOption() ?? window.structuredClone(this.template))
595
+ // Always build the option from the template never from getOption().
596
+ //
597
+ // getOption() returns every component normalized to an *array*, while
598
+ // everything below treats the single-instance ones (title, xAxis, grid,
599
+ // legend, toolbox) as plain objects. `{ ...option.grid }` on an array
600
+ // yields `{ '0': grid }`, which ECharts then deep-merges back into its
601
+ // stored option — burying a copy one level deeper on *every* update. The
602
+ // stored option grew one level per frame until zrender's recursive
603
+ // merge()/clone() ran out of stack, so any board left open on a live
604
+ // time series eventually died with "Maximum call stack size exceeded".
605
+ // Assignments like `option.title.text` landed on the array object for the
606
+ // same reason and were silently dropped, which is why the title, axis
607
+ // name, legend and zoom toolbox stopped tracking config on that path.
608
+ //
609
+ // A template clone carries only the keys we actually set, so a merge
610
+ // update still preserves ECharts-side state (zoom position, animation
611
+ // continuity) — and it skips deep-cloning every series' data each frame.
612
+ // `configChanged` still decides merge vs. rebuild via notMerge below.
613
+ const option: any = window.structuredClone(this.template)
527
614
 
528
615
  // Title
529
616
  option.title.text = label
@@ -553,19 +640,21 @@ export class WidgetLinechart extends LitElement {
553
640
  }
554
641
  }
555
642
 
556
- option.dataZoom[0].show = this.inputData?.axis?.xAxisZoom ?? false
557
- option.toolbox.show = this.inputData?.axis?.xAxisZoom ?? false
643
+ const showZoom = this.inputData?.axis?.xAxisZoom ?? false
644
+ option.dataZoom[0].show = showZoom
645
+ option.toolbox.show = showZoom
558
646
 
559
647
  // Y axes: index 0 = left (primary), index 1 = right (secondary).
560
- // The template holds an array, but getOption() (merge path) also returns
561
- // component options normalized to arrays handle both shapes.
648
+ // The template holds an array; normalize anyway so a template edit
649
+ // that drops down to a single axis object cannot silently break this.
562
650
  const yAxes: any[] = Array.isArray(option.yAxis) ? option.yAxis : [option.yAxis ?? {}]
563
651
  while (yAxes.length < 2) yAxes.push({})
564
652
  option.yAxis = yAxes
565
653
 
566
- const rightAxisUsed = chart.series.some((s) => (s.yAxisIndex ?? 0) === 1)
654
+ const hasValueLabels = chart.series.some((s) => !!(s as any).label?.show)
655
+ const rightAxisUsed = chart.series.some((s) => (s.valueAxisIndex ?? 0) === 1)
567
656
  const leftAxisUsed =
568
- chart.series.length === 0 || chart.series.some((s) => (s.yAxisIndex ?? 0) === 0)
657
+ chart.series.length === 0 || chart.series.some((s) => (s.valueAxisIndex ?? 0) === 0)
569
658
  const showLeftAxis = showYAxis && leftAxisUsed
570
659
  const showRightAxis = showYAxis && rightAxisUsed
571
660
 
@@ -606,6 +695,42 @@ export class WidgetLinechart extends LitElement {
606
695
  })
607
696
  if (numericAxisLabel) yAxes[1].axisLabel = numericAxisLabel
608
697
 
698
+ // Orientation swap. Everything above builds one category axis (the
699
+ // x-values) and a two-entry array of value axes (the y-values). A
700
+ // vertical chart puts them where they were built; a horizontal one
701
+ // moves the category axis onto `yAxis` and the value axes onto
702
+ // `xAxis`. The axis *labels* keep their meaning either way:
703
+ // 'X-Axis Label' names the x-value dimension wherever it is drawn.
704
+ if (horizontal) {
705
+ const catAxis: any = Array.isArray(option.xAxis) ? option.xAxis[0] : option.xAxis
706
+ option.yAxis = {
707
+ ...catAxis,
708
+ position: 'left',
709
+ // Read top-to-bottom, so the first data point is the top
710
+ // bar — the convention for ranked horizontal bar charts.
711
+ inverse: true,
712
+ // 'start' is the top of an inverted axis, which keeps the
713
+ // axis name clear of the value axis running along the bottom.
714
+ // When a secondary value axis is in use it is drawn along
715
+ // the top, so the name has to clear its tick labels too.
716
+ nameLocation: 'start',
717
+ nameGap: showRightAxis ? 30 : 10,
718
+ nameTextStyle: { align: 'left' }
719
+ }
720
+ option.xAxis = yAxes.map((axis: any, index: number) => ({
721
+ ...axis,
722
+ position: index === 0 ? 'bottom' : 'top',
723
+ nameLocation: 'middle',
724
+ nameGap: 27,
725
+ nameTextStyle: { align: 'center' }
726
+ }))
727
+ // Zooming still applies to the x-value dimension, which now
728
+ // lives on the y axis.
729
+ option.dataZoom = [
730
+ { ...(option.dataZoom?.[0] ?? {}), xAxisIndex: undefined, yAxisIndex: [0] }
731
+ ]
732
+ }
733
+
609
734
  option.series = chart.series
610
735
  option.legend.show = showLegend
611
736
 
@@ -616,6 +741,13 @@ export class WidgetLinechart extends LitElement {
616
741
  const topPadding =
617
742
  (showTitle || hasYAxisLabel || hasYAxisLabelRight ? 30 : 0) +
618
743
  (showLegend && hasYAxisLabelRight ? 25 : 0)
744
+ // An ECharts slider dataZoom is ~30px thick and sits outside the
745
+ // grid's own bookkeeping, so nothing reserves room for it. Left
746
+ // unaccounted it is drawn straight over the plot, clipping the foot
747
+ // of every bar and washing out the axis labels underneath.
748
+ const ZOOM_THICKNESS = 40
749
+ const zoomPadBottom = showZoom && !horizontal ? ZOOM_THICKNESS : 0
750
+ const zoomPadRight = showZoom && horizontal ? ZOOM_THICKNESS : 0
619
751
  option.grid = {
620
752
  ...option.grid,
621
753
  show: showBox,
@@ -623,12 +755,31 @@ export class WidgetLinechart extends LitElement {
623
755
  borderWidth: showBox ? 1 : 0,
624
756
  borderColor: this.themeTitleColor ?? '#ccc',
625
757
  top: topPadding,
626
- bottom: showXAxis ? 20 : 0,
627
- left: showLeftAxis ? 20 : 0,
628
- right: 0,
758
+ // The category axis sits at the bottom when vertical and on the
759
+ // left when horizontal; the value axis is the other way round.
760
+ // The zoom slider is not accounted for by `containLabel`, so its
761
+ // thickness has to be reserved here as well — see ZOOM_THICKNESS.
762
+ bottom: ((horizontal ? showLeftAxis : showXAxis) ? 20 : 0) + zoomPadBottom,
763
+ left: (horizontal ? showXAxis : showLeftAxis) ? 20 : 0,
764
+ // A horizontal chart grows towards the right edge, so the last
765
+ // axis tick and any value label printed past the end of a bar
766
+ // need room that `containLabel` does not reserve for them.
767
+ right: (horizontal ? (hasValueLabels ? 45 : 15) : 0) + zoomPadRight,
629
768
  containLabel: showXAxis || showYAxis
630
769
  }
631
770
 
771
+ // Anchor the slider to the edge whose dimension it zooms: the bottom
772
+ // for a vertical chart, the right-hand side for a horizontal one
773
+ // (where it controls the y axis and ECharts draws it upright).
774
+ if (showZoom) {
775
+ Object.assign(
776
+ option.dataZoom[0],
777
+ horizontal
778
+ ? { right: 0, top: topPadding, bottom: 20, left: undefined }
779
+ : { bottom: 0, left: undefined, right: undefined, top: undefined }
780
+ )
781
+ }
782
+
632
783
  // Calculate animation duration based on update frequency
633
784
  const animationDuration = this.updateThresholdMs //this.calculateAnimationDuration(chart, label)
634
785
  option.animation = true