cats-charts 0.0.98 → 0.1.0

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.
@@ -3987,6 +3987,17 @@ class SankeyChart {
3987
3987
  legendSeries = [];
3988
3988
  previousReactiveSignature = '';
3989
3989
  resizeObserver;
3990
+ stopNodeDragPropagation = (event) => {
3991
+ // Labels can forward interaction to their node through __hostTarget.
3992
+ const node = event.target?.__hostTarget ?? event.target;
3993
+ if (!node?.draggable)
3994
+ return;
3995
+ // ZRender maps mouse, pointer, and touch starts to mousedown. Keep its
3996
+ // node dragging active while blocking ancestor drag handlers and the
3997
+ // browser's native drag of a draggable parent element.
3998
+ event.event?.stopPropagation();
3999
+ event.event?.preventDefault();
4000
+ };
3990
4001
  /* ------------------ CHANGE HANDLING ------------------ */
3991
4002
  ngOnChanges(changes) {
3992
4003
  if (!this.config)
@@ -4024,11 +4035,39 @@ class SankeyChart {
4024
4035
  const totalValue = this.totalSourceValue(visibleLinks, visibleNodeMeta);
4025
4036
  this.legendSeries = this.buildLegendSeries(visibleNodeMeta, visibleLinks, palette);
4026
4037
  const selected = this.readLegendSelected();
4027
- const legendEnabled = this.isLegendEnabled();
4028
- const legendConfig = styling?.legend;
4029
4038
  const nodeOpacity = this.percentToRatio(styling?.node?.opacity, 100);
4030
4039
  const labelTypography = styling?.labels?.typography;
4031
4040
  const fontColor = this.withOpacity(labelTypography?.reset ? undefined : labelTypography?.color, this.percentToRatio(labelTypography?.reset ? undefined : labelTypography?.opacity, 100), '#333');
4041
+ const label = {
4042
+ show: styling?.labels?.showLabels ?? true,
4043
+ // Keep labels within the space between Sankey columns. ECharts then
4044
+ // adds an ellipsis whenever a larger font, wider node, or long name
4045
+ // does not fit.
4046
+ width: this.resolveSankeyLabelWidth(visibleNodeMeta),
4047
+ overflow: 'truncate',
4048
+ ellipsis: '...',
4049
+ color: fontColor,
4050
+ fontFamily: labelTypography?.reset ? undefined : labelTypography?.fontFamily,
4051
+ fontSize: this.clamp(labelTypography?.reset ? undefined : labelTypography?.fontSize, 8, 40),
4052
+ fontWeight: labelTypography?.reset
4053
+ ? undefined
4054
+ : labelTypography?.bold
4055
+ ? 'bold'
4056
+ : undefined,
4057
+ fontStyle: labelTypography?.reset
4058
+ ? undefined
4059
+ : labelTypography?.italic
4060
+ ? 'italic'
4061
+ : undefined,
4062
+ textDecoration: labelTypography?.reset
4063
+ ? undefined
4064
+ : labelTypography?.underline
4065
+ ? 'underline'
4066
+ : undefined,
4067
+ position: this.resolveLabelPosition(),
4068
+ formatter: (params) => this.formatNodeLabel(String(params.name ?? ''), visibleNodeMeta.get(String(params.name ?? '')), totalValue),
4069
+ };
4070
+ const labelMargins = this.resolveSankeyLabelMargins(visibleNodes, label, visibleNodeMeta);
4032
4071
  this.chartOption = {
4033
4072
  backgroundColor: this.config?.backgroundColor || 'transparent',
4034
4073
  color: palette,
@@ -4056,11 +4095,8 @@ class SankeyChart {
4056
4095
  },
4057
4096
  series: [
4058
4097
  {
4059
- left: 16,
4060
- right: 16,
4061
- top: legendEnabled && legendConfig?.position === 'top' ? 32 : 16,
4062
- bottom: legendEnabled && legendConfig?.position === 'bottom' ? 32 : 16,
4063
4098
  ...this.config.seriesOption,
4099
+ ...labelMargins,
4064
4100
  type: 'sankey',
4065
4101
  ...((styling?.node?.thickness ?? this.config.seriesOption?.nodeWidth)
4066
4102
  ? {
@@ -4077,7 +4113,7 @@ class SankeyChart {
4077
4113
  return {
4078
4114
  ...n,
4079
4115
  name: n.name,
4080
- label: this.mergeSingleNodeLabel(n, nodeStyle?.label),
4116
+ label: this.mergeSingleNodeLabel(n, nodeStyle?.label, visibleNodeMeta.get(n.name)),
4081
4117
  itemStyle: {
4082
4118
  color: this.resolveNodeColor(n, visibleNodeMeta.get(n.name), index, palette),
4083
4119
  opacity: nodeOpacity,
@@ -4114,35 +4150,7 @@ class SankeyChart {
4114
4150
  ...(this.config.seriesOption?.lineStyle || {}),
4115
4151
  ...(styling?.lineStyle || {}),
4116
4152
  },
4117
- label: {
4118
- show: styling?.labels?.showLabels ?? true,
4119
- // Keep labels within the space between Sankey columns. ECharts then
4120
- // adds an ellipsis whenever a larger font, wider node, or long name
4121
- // does not fit.
4122
- width: this.resolveSankeyLabelWidth(visibleNodeMeta),
4123
- overflow: 'truncate',
4124
- ellipsis: '...',
4125
- color: fontColor,
4126
- fontFamily: labelTypography?.reset ? undefined : labelTypography?.fontFamily,
4127
- fontSize: this.clamp(labelTypography?.reset ? undefined : labelTypography?.fontSize, 8, 40),
4128
- fontWeight: labelTypography?.reset
4129
- ? undefined
4130
- : labelTypography?.bold
4131
- ? 'bold'
4132
- : undefined,
4133
- fontStyle: labelTypography?.reset
4134
- ? undefined
4135
- : labelTypography?.italic
4136
- ? 'italic'
4137
- : undefined,
4138
- textDecoration: labelTypography?.reset
4139
- ? undefined
4140
- : labelTypography?.underline
4141
- ? 'underline'
4142
- : undefined,
4143
- align: styling?.labels?.alignment === 'default' ? undefined : styling?.labels?.alignment,
4144
- formatter: (params) => this.formatNodeLabel(String(params.name ?? ''), visibleNodeMeta.get(String(params.name ?? '')), totalValue),
4145
- },
4153
+ label,
4146
4154
  },
4147
4155
  ],
4148
4156
  };
@@ -4349,10 +4357,14 @@ class SankeyChart {
4349
4357
  label: nodeStyle.label,
4350
4358
  };
4351
4359
  }
4352
- mergeSingleNodeLabel(node, label) {
4353
- if (!label && !node.label)
4360
+ mergeSingleNodeLabel(node, label, meta) {
4361
+ const alignment = this.config.styling?.labels?.alignment;
4362
+ const defaultSourcePosition = meta?.role === 'source' &&
4363
+ (!alignment || alignment === 'default');
4364
+ if (!label && !node.label && !defaultSourcePosition)
4354
4365
  return undefined;
4355
4366
  return {
4367
+ ...(defaultSourcePosition ? { position: 'right' } : {}),
4356
4368
  ...(label || {}),
4357
4369
  ...(node.label || {}),
4358
4370
  };
@@ -4410,9 +4422,70 @@ class SankeyChart {
4410
4422
  const nodeWidth = this.clamp(this.config.styling?.node?.thickness ?? this.config.seriesOption?.nodeWidth, 1, 50) ?? 20;
4411
4423
  const horizontalPadding = 32;
4412
4424
  const availableWidth = Math.max(chartWidth - horizontalPadding - levelCount * nodeWidth, 0);
4413
- // A label can occupy half of each gap between adjacent Sankey columns.
4414
- // Cap it so a simple two-column Sankey does not create excessively long labels.
4415
- return Math.max(32, Math.min(180, Math.floor(availableWidth / Math.max(levelCount * 2, 1))));
4425
+ // Reserve room for labels at both outer edges as well as between columns.
4426
+ return Math.max(0, Math.min(180, Math.floor(availableWidth / Math.max(levelCount * 2 + 2, 1))));
4427
+ }
4428
+ resolveSankeyLabelMargins(nodes, label, meta) {
4429
+ const width = this.chartInstance?.getWidth() ?? 0;
4430
+ const height = this.chartInstance?.getHeight() ?? 0;
4431
+ const legend = this.config.styling?.legend;
4432
+ const margin = (value, size, fallback) => {
4433
+ if (typeof value === 'number')
4434
+ return value;
4435
+ if (typeof value === 'string' && value.endsWith('%')) {
4436
+ const percent = Number.parseFloat(value);
4437
+ if (Number.isFinite(percent))
4438
+ return (size * percent) / 100;
4439
+ }
4440
+ return fallback;
4441
+ };
4442
+ const series = this.config.seriesOption;
4443
+ const margins = {
4444
+ left: margin(series?.left, width, 16),
4445
+ right: margin(series?.right, width, 16),
4446
+ top: margin(series?.top, height, this.isLegendEnabled() && legend?.position === 'top' ? 32 : 16),
4447
+ bottom: margin(series?.bottom, height, this.isLegendEnabled() && legend?.position === 'bottom' ? 32 : 16),
4448
+ };
4449
+ const measured = nodes.map((node) => {
4450
+ const override = this.mergeSingleNodeLabel(node, this.resolveSingleNodeStyle(node.name)?.label, meta.get(node.name));
4451
+ const effective = {
4452
+ ...label,
4453
+ ...override,
4454
+ };
4455
+ const font = `${effective.fontStyle ?? 'normal'} ${effective.fontWeight ?? 'normal'} ${effective.fontSize ?? 12}px ${effective.fontFamily ?? 'sans-serif'}`;
4456
+ const text = typeof effective.formatter === 'function'
4457
+ ? effective.formatter({ name: node.name, data: node })
4458
+ : typeof effective.formatter === 'string'
4459
+ ? effective.formatter.replace(/\{b\}/g, node.name)
4460
+ : node.name;
4461
+ const bounds = echarts.format.getTextRect(String(text), font, undefined, undefined, effective.padding, effective.rich, false, effective.lineHeight);
4462
+ return { effective, bounds, customWidth: override?.width };
4463
+ });
4464
+ // Size the shared label box to the text, within the existing truncation cap.
4465
+ // This makes outer node positions respond to font and formatted text changes.
4466
+ const textWidths = measured
4467
+ .filter(({ effective }) => effective.show !== false)
4468
+ .map(({ bounds }) => bounds.width);
4469
+ label.width = Math.min(label.width, Math.ceil(Math.max(0, ...textWidths)));
4470
+ const alignment = this.config.styling?.labels?.alignment;
4471
+ const useLabelMargins = alignment === 'left' || alignment === 'right';
4472
+ for (const { effective, bounds, customWidth } of measured) {
4473
+ if (effective.show === false)
4474
+ continue;
4475
+ const labelWidth = typeof customWidth === 'number' ? customWidth : label.width;
4476
+ const distance = Number(effective.distance ?? 5);
4477
+ if (effective.position === 'left' || effective.position === 'right') {
4478
+ // Default labels face into the chart; keep the outer margins at 16px.
4479
+ if (useLabelMargins) {
4480
+ const side = effective.position;
4481
+ margins[side] = Math.max(margins[side], Math.ceil(labelWidth + distance + 8));
4482
+ }
4483
+ // Split labels and large fonts also need room above and below nodes.
4484
+ margins.top = Math.max(margins.top, Math.ceil(bounds.height / 2 + 8));
4485
+ margins.bottom = Math.max(margins.bottom, Math.ceil(bounds.height / 2 + 8));
4486
+ }
4487
+ }
4488
+ return margins;
4416
4489
  }
4417
4490
  totalSourceValue(links, meta) {
4418
4491
  const sourceNames = new Set([...meta.values()].filter((node) => node.role === 'source').map((node) => node.name));
@@ -4558,6 +4631,9 @@ class SankeyChart {
4558
4631
  if (!this.chartInstance)
4559
4632
  return;
4560
4633
  this.chartInstance.off();
4634
+ const zr = this.chartInstance.getZr();
4635
+ zr.off('mousedown', this.stopNodeDragPropagation);
4636
+ zr.on('mousedown', this.stopNodeDragPropagation);
4561
4637
  this.bindSankeyDragBounds();
4562
4638
  this.events.forEach((eventName) => {
4563
4639
  if (eventName === 'contextmenu') {
@@ -4647,6 +4723,7 @@ class SankeyChart {
4647
4723
  ngOnDestroy() {
4648
4724
  this.resizeObserver?.disconnect();
4649
4725
  if (this.chartInstance) {
4726
+ this.chartInstance.getZr().off('mousedown', this.stopNodeDragPropagation);
4650
4727
  this.chartInstance.off();
4651
4728
  this.chartInstance.dispose();
4652
4729
  }