exodeui-react-native 1.0.5 → 1.0.7

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "exodeui-react-native",
3
- "version": "1.0.5",
3
+ "version": "1.0.7",
4
4
  "description": "React Native runtime for ExodeUI animations",
5
5
  "main": "index.js",
6
6
  "files": [
@@ -11,18 +11,20 @@ export interface ExodeUIViewProps {
11
11
  autoPlay?: boolean;
12
12
  fit?: Fit;
13
13
  alignment?: Alignment;
14
- onReady?: (engine: any) => void; // Changed type to any as per instruction
14
+ onReady?: (engine: any) => void;
15
15
  onTrigger?: (triggerName: string, animationName: string) => void;
16
16
  onInputUpdate?: (nameOrId: string, value: any) => void;
17
- onComponentChange?: (event: ComponentEvent) => void; // Added
18
- onToggle?: (name: string, checked: boolean) => void; // Changed value to checked
19
- onInputChange?: (name: string, text: string) => void; // Changed value to text
17
+ onComponentChange?: (event: ComponentEvent) => void;
18
+ onToggle?: (name: string, checked: boolean) => void;
19
+ onInputChange?: (name: string, text: string) => void;
20
20
  onInputFocus?: (name: string) => void;
21
21
  onInputBlur?: (name: string) => void;
22
+ onButtonClick?: (name: string, objectId: string) => void;
23
+ onGraphPointClick?: (event: { objectId: string; componentName: string; datasetIndex: number; pointIndex: number; value: number; label: string }) => void;
22
24
  }
23
25
 
24
26
  export const ExodeUIView = forwardRef<any, ExodeUIViewProps>(
25
- ({ artboard, src, style, autoPlay = true, fit = 'Contain', alignment = 'Center', onReady, onTrigger, onInputUpdate, onInputFocus, onInputBlur, onToggle, onInputChange, onComponentChange }, ref) => {
27
+ ({ artboard, src, style, autoPlay = true, fit = 'Contain', alignment = 'Center', onReady, onTrigger, onInputUpdate, onInputFocus, onInputBlur, onToggle, onInputChange, onComponentChange, onButtonClick, onGraphPointClick }, ref) => {
26
28
 
27
29
  const engineRef = useRef<ExodeUIEngine>(new ExodeUIEngine());
28
30
  const [picture, setPicture] = useState<SkPicture | null>(null);
@@ -72,6 +74,14 @@ export const ExodeUIView = forwardRef<any, ExodeUIViewProps>(
72
74
  if (onComponentChange) engineRef.current.setComponentChangeCallback(onComponentChange);
73
75
  }, [onComponentChange]);
74
76
 
77
+ useEffect(() => {
78
+ if (onButtonClick) engineRef.current.setButtonClickCallback(onButtonClick);
79
+ }, [onButtonClick]);
80
+
81
+ useEffect(() => {
82
+ if (onGraphPointClick) engineRef.current.setGraphPointClickCallback(onGraphPointClick);
83
+ }, [onGraphPointClick]);
84
+
75
85
  // Load Artboard
76
86
  useEffect(() => {
77
87
  const loadArtboard = async () => {
package/src/engine.ts CHANGED
@@ -35,6 +35,8 @@ export class ExodeUIEngine {
35
35
  private onInputChange?: (name: string, text: string) => void;
36
36
  private onInputFocus?: (name: string) => void;
37
37
  private onInputBlur?: (name: string) => void;
38
+ private onButtonClick?: (name: string, objectId: string) => void;
39
+ private onGraphPointClick?: (event: { objectId: string; componentName: string; datasetIndex: number; pointIndex: number; value: number; label: string }) => void;
38
40
 
39
41
  // Track triggers that were just fired in the current frame
40
42
  private justFiredTriggers: Set<string> = new Set();
@@ -78,6 +80,12 @@ export class ExodeUIEngine {
78
80
  setInputBlurCallback(cb: (name: string) => void) {
79
81
  this.onInputBlur = cb;
80
82
  }
83
+ setButtonClickCallback(cb: (name: string, objectId: string) => void) {
84
+ this.onButtonClick = cb;
85
+ }
86
+ setGraphPointClickCallback(cb: (event: { objectId: string; componentName: string; datasetIndex: number; pointIndex: number; value: number; label: string }) => void) {
87
+ this.onGraphPointClick = cb;
88
+ }
81
89
 
82
90
  constructor() {}
83
91
 
@@ -964,6 +972,15 @@ export class ExodeUIEngine {
964
972
  }
965
973
  }
966
974
 
975
+ if (targetObj.type === 'Component' && (targetObj as any).variant === 'button') {
976
+ if (type === 'click' || type === 'PointerDown') {
977
+ if (this.onButtonClick) this.onButtonClick(targetObj.name, targetObj.id);
978
+ if (this.onComponentChange) {
979
+ this.onComponentChange({ objectId: targetObj.id, componentName: targetObj.name, variant: 'button', property: 'click', value: true });
980
+ }
981
+ }
982
+ }
983
+
967
984
  if (targetObj.type === 'Component' && (targetObj as any).variant === 'slider') {
968
985
  if (type === 'PointerDown') {
969
986
  this.draggingSliderId = targetObj.id;
@@ -1021,6 +1038,43 @@ export class ExodeUIEngine {
1021
1038
  }
1022
1039
  }
1023
1040
  }
1041
+
1042
+ if (targetObj.type === 'LineGraph') {
1043
+ if (type === 'click' || type === 'PointerDown') {
1044
+ const geom = targetObj.geometry as any;
1045
+ const datasets = geom?.datasets || [];
1046
+ const state = this.objectStates.get(targetObj.id);
1047
+ const objW = state?.width || geom?.width || 200;
1048
+ const objH = state?.height || geom?.height || 150;
1049
+ const world = this.getWorldTransform(targetObj.id);
1050
+ const axisMarginL = 30;
1051
+ const axisMarginB = 20;
1052
+ const plotW = objW - axisMarginL;
1053
+ const plotH = objH - axisMarginB;
1054
+ let globalMax = 1;
1055
+ datasets.forEach((ds: any) => { const m = Math.max(...(ds.data || [0])); if (m > globalMax) globalMax = m; });
1056
+
1057
+ let closestDist = Infinity;
1058
+ let closestResult: any = null;
1059
+ datasets.forEach((ds: any, dIdx: number) => {
1060
+ const data = ds.data || [];
1061
+ const stepX = plotW / Math.max(data.length - 1, 1);
1062
+ data.forEach((val: number, pIdx: number) => {
1063
+ const px = world.x - objW/2 + axisMarginL + pIdx * stepX;
1064
+ const py = world.y + objH/2 - axisMarginB - (val / globalMax) * plotH;
1065
+ const dist = Math.sqrt((x - px) ** 2 + (y - py) ** 2);
1066
+ if (dist < closestDist) { closestDist = dist; closestResult = { dIdx, pIdx, val, label: ds.label || `Dataset ${dIdx + 1}` }; }
1067
+ });
1068
+ });
1069
+
1070
+ if (closestResult && closestDist < 20 && this.onGraphPointClick) {
1071
+ this.onGraphPointClick({ objectId: targetObj.id, componentName: targetObj.name, datasetIndex: closestResult.dIdx, pointIndex: closestResult.pIdx, value: closestResult.val, label: closestResult.label });
1072
+ }
1073
+ if (this.onComponentChange) {
1074
+ this.onComponentChange({ objectId: targetObj.id, componentName: targetObj.name, variant: 'linegraph', property: 'click', value: closestResult?.val ?? null });
1075
+ }
1076
+ }
1077
+ }
1024
1078
  }
1025
1079
  }
1026
1080
 
@@ -1630,13 +1684,13 @@ export class ExodeUIEngine {
1630
1684
  const paint = Skia.Paint();
1631
1685
 
1632
1686
  const isPressed = this.draggingSliderId === obj.id;
1633
- // Parse color safely
1634
- const bgNormal = opts.backgroundColor || opts.background || '#3b82f6';
1687
+ // The .exode file uses 'buttonBgColor', fall back to 'backgroundColor'
1688
+ const bgNormal = opts.buttonBgColor || opts.backgroundColor || opts.background || '#3b82f6';
1635
1689
  const bgActive = opts.activeBackgroundColor || opts.activeBackground || '#2563eb';
1636
- const bgStr = typeof bgNormal === 'string' ? bgNormal : '#3b82f6';
1637
- const bgActiveStr = typeof bgActive === 'string' ? bgActive : '#2563eb';
1690
+ const bgStr = (typeof bgNormal === 'string' ? bgNormal : '#3b82f6').replace(/\s+/g, '');
1691
+ const bgActiveStr = (typeof bgActive === 'string' ? bgActive : '#2563eb').replace(/\s+/g, '');
1638
1692
  try {
1639
- paint.setColor(Skia.Color((isPressed ? bgActiveStr : bgStr).replace(/\s+/g, '')));
1693
+ paint.setColor(Skia.Color(isPressed ? bgActiveStr : bgStr));
1640
1694
  } catch {
1641
1695
  paint.setColor(Skia.Color('#3b82f6'));
1642
1696
  }
@@ -1644,7 +1698,8 @@ export class ExodeUIEngine {
1644
1698
  const r = opts.cornerRadius ?? 8;
1645
1699
  canvas.drawRRect(Skia.RRectXY({ x: -w/2, y: -h/2, width: w, height: h }, r, r), paint);
1646
1700
 
1647
- const labelObj = opts.label || {};
1701
+ // label is an object {text, fontSize, color, ...}
1702
+ const labelObj = typeof opts.label === 'object' && opts.label !== null ? opts.label : {};
1648
1703
  const labelText = labelObj.text || opts.text || opts.title || 'Button';
1649
1704
  const fontSize = labelObj.fontSize || opts.fontSize || 14;
1650
1705
  const labelColor = labelObj.color || opts.color || '#ffffff';
@@ -1658,8 +1713,7 @@ export class ExodeUIEngine {
1658
1713
  }
1659
1714
  if (isPressed) textPaint.setAlphaf(0.85);
1660
1715
 
1661
- // Center text horizontally using getTextWidth
1662
- const textWidth = font.getTextWidth ? font.getTextWidth(labelText) : 0;
1716
+ const textWidth = font.getTextWidth ? font.getTextWidth(String(labelText)) : 0;
1663
1717
  canvas.save();
1664
1718
  canvas.translate(-textWidth / 2, fontSize / 3);
1665
1719
  canvas.drawText(String(labelText), 0, 0, textPaint, font);
@@ -1675,26 +1729,30 @@ export class ExodeUIEngine {
1675
1729
  const trackColor = checked ? (opts.activeColor || '#3b82f6') : (opts.inactiveColor || '#374151');
1676
1730
  try { paint.setColor(Skia.Color(trackColor.replace(/\s+/g, ''))); } catch { paint.setColor(Skia.Color('#374151')); }
1677
1731
 
1678
- const r = h / 2;
1732
+ const r = opts.cornerRadius ?? h / 2;
1679
1733
  canvas.drawRRect(Skia.RRectXY({ x: -w/2, y: -h/2, width: w, height: h }, r, r), paint);
1680
1734
 
1681
1735
  const thumbPaint = Skia.Paint();
1682
- thumbPaint.setColor(Skia.Color('#ffffff'));
1736
+ const thumbCol = opts.knobColor || opts.thumbColor || '#ffffff';
1737
+ try { thumbPaint.setColor(Skia.Color(thumbCol.replace(/\s+/g, ''))); } catch { thumbPaint.setColor(Skia.Color('#ffffff')); }
1683
1738
  const thumbRadius = (h - 8) / 2;
1684
1739
  const thumbX = checked ? (w / 2 - thumbRadius - 4) : (-w / 2 + thumbRadius + 4);
1685
1740
  canvas.drawCircle(thumbX, 0, thumbRadius, thumbPaint);
1686
1741
 
1687
- // Label text
1688
- const labelText = opts.label || opts.text || '';
1689
- if (labelText) {
1690
- const fontSize = opts.fontSize || 12;
1742
+ // label is an object: {text, position, fontSize, color, gap}
1743
+ const labelObj = typeof opts.label === 'object' && opts.label !== null ? opts.label : null;
1744
+ if (labelObj && labelObj.text) {
1745
+ const fontSize = labelObj.fontSize || 12;
1691
1746
  const font = this.getFont(fontSize, 'Helvetica Neue');
1692
1747
  const labelPaint = Skia.Paint();
1693
- const labelColor = opts.labelColor || opts.color || '#ffffff';
1748
+ const labelColor = labelObj.color || '#ffffff';
1694
1749
  try { labelPaint.setColor(Skia.Color((typeof labelColor === 'string' ? labelColor : '#ffffff').replace(/\s+/g, ''))); } catch { labelPaint.setColor(Skia.Color('#ffffff')); }
1750
+ const gap = labelObj.gap || 8;
1751
+ const position = labelObj.position || 'right';
1752
+ const offsetX = position === 'left' ? -(w/2 + gap + font.getTextWidth(String(labelObj.text))) : w/2 + gap;
1695
1753
  canvas.save();
1696
- canvas.translate(w/2 + 8, fontSize / 3);
1697
- canvas.drawText(String(labelText), 0, 0, labelPaint, font);
1754
+ canvas.translate(offsetX, fontSize / 3);
1755
+ canvas.drawText(String(labelObj.text), 0, 0, labelPaint, font);
1698
1756
  canvas.restore();
1699
1757
  }
1700
1758
  }
@@ -1714,7 +1772,7 @@ export class ExodeUIEngine {
1714
1772
  canvas.drawRRect(Skia.RRectXY({ x: -w/2, y: -trackHeight/2, width: w, height: trackHeight }, trackHeight/2, trackHeight/2), trackPaint);
1715
1773
 
1716
1774
  const activePaint = Skia.Paint();
1717
- const activeColor = opts.activeColor || opts.thumbColor || '#3b82f6';
1775
+ const activeColor = opts.activeColor || '#3b82f6';
1718
1776
  try { activePaint.setColor(Skia.Color((typeof activeColor === 'string' ? activeColor : '#3b82f6').replace(/\s+/g, ''))); } catch { activePaint.setColor(Skia.Color('#3b82f6')); }
1719
1777
  const thumbWidth = opts.thumbWidth ?? 16;
1720
1778
  const travelW = w - thumbWidth;
@@ -1727,16 +1785,39 @@ export class ExodeUIEngine {
1727
1785
  try { thumbPaint.setColor(Skia.Color((typeof thumbCol === 'string' ? thumbCol : '#ffffff').replace(/\s+/g, ''))); } catch { thumbPaint.setColor(Skia.Color('#ffffff')); }
1728
1786
  canvas.drawCircle(thumbX, 0, thumbWidth / 2, thumbPaint);
1729
1787
 
1730
- // Value label above thumb
1731
- if (opts.showValue !== false) {
1732
- const fontSize = opts.fontSize || 11;
1733
- const font = this.getFont(fontSize, 'Helvetica Neue');
1734
- const valPaint = Skia.Paint();
1735
- valPaint.setColor(Skia.Color('#ffffff'));
1736
- const valText = String(Math.round(value));
1788
+ // Label: opts.label is an object {text, visible, fontSize, color}
1789
+ const labelObj = typeof opts.label === 'object' && opts.label !== null ? opts.label : null;
1790
+ if (labelObj && labelObj.text && labelObj.visible !== false) {
1791
+ const lFontSize = labelObj.fontSize || 12;
1792
+ const lFont = this.getFont(lFontSize, 'Helvetica Neue');
1793
+ const lPaint = Skia.Paint();
1794
+ const lColor = labelObj.color || '#ffffff';
1795
+ try { lPaint.setColor(Skia.Color((typeof lColor === 'string' ? lColor : '#9ca3af').replace(/\s+/g, ''))); } catch { lPaint.setColor(Skia.Color('#9ca3af')); }
1737
1796
  canvas.save();
1738
- canvas.translate(thumbX, -thumbWidth / 2 - fontSize - 2);
1739
- canvas.drawText(valText, 0, 0, valPaint, font);
1797
+ canvas.translate(-w/2, -h/2 - lFontSize - 2);
1798
+ canvas.drawText(String(labelObj.text), 0, 0, lPaint, lFont);
1799
+ canvas.restore();
1800
+ }
1801
+
1802
+ // Value tooltip: opts.showValueTooltip + tooltipOffsetY
1803
+ if (opts.showValueTooltip) {
1804
+ const tFontSize = 11;
1805
+ const tFont = this.getFont(tFontSize, 'Helvetica Neue');
1806
+ const tooltipBg = opts.tooltipBgColor || '#333333';
1807
+ const tooltipText = opts.tooltipColor || '#ffffff';
1808
+ const offsetY = opts.tooltipOffsetY ?? -25;
1809
+ const valStr = String(Math.round(value));
1810
+ const tWidth = tFont.getTextWidth ? tFont.getTextWidth(valStr) + 8 : 24;
1811
+
1812
+ const bgP = Skia.Paint();
1813
+ try { bgP.setColor(Skia.Color((typeof tooltipBg === 'string' ? tooltipBg : '#333333').replace(/\s+/g, ''))); } catch { bgP.setColor(Skia.Color('#333333')); }
1814
+ canvas.drawRRect(Skia.RRectXY({ x: thumbX - tWidth/2, y: offsetY - tFontSize - 2, width: tWidth, height: tFontSize + 6 }, 3, 3), bgP);
1815
+
1816
+ const tPaint = Skia.Paint();
1817
+ try { tPaint.setColor(Skia.Color((typeof tooltipText === 'string' ? tooltipText : '#ffffff').replace(/\s+/g, ''))); } catch { tPaint.setColor(Skia.Color('#ffffff')); }
1818
+ canvas.save();
1819
+ canvas.translate(thumbX - tWidth/2 + 4, offsetY - 4);
1820
+ canvas.drawText(valStr, 0, 0, tPaint, tFont);
1740
1821
  canvas.restore();
1741
1822
  }
1742
1823
  }
@@ -2007,41 +2088,42 @@ export class ExodeUIEngine {
2007
2088
  const data = ds.data || [];
2008
2089
  if (data.length < 2) return;
2009
2090
 
2010
- const lineColorStr = (typeof ds.lineColor === 'string' ? ds.lineColor : '#3b82f6').replace(/\s+/g, '');
2091
+ const lineColorStr = typeof ds.lineColor === 'string' ? ds.lineColor : '#3b82f6';
2092
+ // hsl/hsla colors need to be converted; Skia may not accept them directly
2093
+ // We'll try Skia.Color, and if it throws, fall back to a safe default
2011
2094
  let lineColor;
2012
- try { lineColor = Skia.Color(lineColorStr); } catch { lineColor = Skia.Color('#3b82f6'); }
2095
+ try { lineColor = Skia.Color(lineColorStr.replace(/\s+/g, '')); } catch { lineColor = Skia.Color('#3b82f6'); }
2013
2096
 
2014
- const stepX = plotW / (data.length - 1);
2097
+ const stepX = plotW / (Math.max(data.length - 1, 1));
2015
2098
  const getX = (i: number) => -w/2 + axisMarginL + i * stepX;
2016
2099
  const getY = (val: number) => h/2 - axisMarginB - (val / globalMax) * plotH;
2017
2100
 
2018
- const smooth = opts.smooth !== false && (ds.smooth !== false);
2101
+ // Per-dataset smoothing from the .exode file
2102
+ const smooth = ds.smoothing === true;
2019
2103
 
2020
- // Gradient fill
2021
- if (ds.fill !== false && opts.fill !== false) {
2022
- const fillPath = Skia.Path.Make();
2023
- fillPath.moveTo(getX(0), h/2 - axisMarginB);
2024
- data.forEach((val: number, i: number) => {
2025
- const x = getX(i);
2026
- const y = getY(val);
2027
- if (i === 0) fillPath.lineTo(x, y);
2028
- else if (smooth && i < data.length) {
2029
- const prevX = getX(i - 1);
2030
- const prevY = getY(data[i - 1]);
2031
- const cpX = (prevX + x) / 2;
2032
- fillPath.cubicTo(cpX, prevY, cpX, y, x, y);
2033
- } else {
2034
- fillPath.lineTo(x, y);
2035
- }
2036
- });
2037
- fillPath.lineTo(getX(data.length - 1), h/2 - axisMarginB);
2038
- fillPath.close();
2039
-
2040
- const fillPaint = Skia.Paint();
2041
- fillPaint.setStyle(PaintStyle.Fill);
2042
- fillPaint.setAlphaf(0.15);
2043
- try { fillPaint.setColor(lineColor); } catch { fillPaint.setColor(Skia.Color('#3b82f6')); }
2044
- canvas.drawPath(fillPath, fillPaint);
2104
+ // Per-dataset area fill from .exode field 'showArea'
2105
+ if (ds.showArea === true && ds.areaColor) {
2106
+ const areaColorStr = typeof ds.areaColor === 'string' ? ds.areaColor : null;
2107
+ let areaColor;
2108
+ if (areaColorStr) {
2109
+ try { areaColor = Skia.Color(areaColorStr.replace(/\s+/g, '')); } catch { areaColor = null; }
2110
+ }
2111
+ if (areaColor) {
2112
+ const fillPath = Skia.Path.Make();
2113
+ fillPath.moveTo(getX(0), h/2 - axisMarginB);
2114
+ data.forEach((val: number, i: number) => {
2115
+ const x = getX(i); const y = getY(val);
2116
+ if (i === 0) fillPath.lineTo(x, y);
2117
+ else if (smooth) { const px = getX(i-1); const py = getY(data[i-1]); const cpX = (px+x)/2; fillPath.cubicTo(cpX, py, cpX, y, x, y); }
2118
+ else fillPath.lineTo(x, y);
2119
+ });
2120
+ fillPath.lineTo(getX(data.length - 1), h/2 - axisMarginB);
2121
+ fillPath.close();
2122
+ const fillPaint = Skia.Paint();
2123
+ fillPaint.setStyle(PaintStyle.Fill);
2124
+ fillPaint.setColor(areaColor);
2125
+ canvas.drawPath(fillPath, fillPaint);
2126
+ }
2045
2127
  }
2046
2128
 
2047
2129
  // Line
@@ -2052,26 +2134,29 @@ export class ExodeUIEngine {
2052
2134
 
2053
2135
  const linePath = Skia.Path.Make();
2054
2136
  data.forEach((val: number, i: number) => {
2055
- const x = getX(i);
2056
- const y = getY(val);
2137
+ const x = getX(i); const y = getY(val);
2057
2138
  if (i === 0) linePath.moveTo(x, y);
2058
- else if (smooth && i < data.length) {
2059
- const prevX = getX(i - 1);
2060
- const prevY = getY(data[i - 1]);
2061
- const cpX = (prevX + x) / 2;
2062
- linePath.cubicTo(cpX, prevY, cpX, y, x, y);
2063
- } else {
2064
- linePath.lineTo(x, y);
2065
- }
2139
+ else if (smooth) { const px = getX(i-1); const py = getY(data[i-1]); const cpX = (px+x)/2; linePath.cubicTo(cpX, py, cpX, y, x, y); }
2140
+ else linePath.lineTo(x, y);
2066
2141
  });
2067
2142
  canvas.drawPath(linePath, linePaint);
2068
2143
 
2069
- // Data points
2070
- if (ds.showPoints !== false && data.length <= 20) {
2144
+ // Data points with custom point styling
2145
+ if (ds.showPoints !== false && data.length <= 30 && ds.pointSize) {
2146
+ const pr = ds.pointSize / 2;
2071
2147
  data.forEach((val: number, i: number) => {
2148
+ // Outer stroke
2149
+ if (ds.pointStrokeColor) {
2150
+ const strokeP = Skia.Paint();
2151
+ strokeP.setStyle(PaintStyle.Stroke);
2152
+ strokeP.setStrokeWidth(ds.pointStrokeWidth || 2);
2153
+ try { strokeP.setColor(Skia.Color((ds.pointStrokeColor || '#3b82f6').replace(/\s+/g, ''))); } catch { strokeP.setColor(lineColor); }
2154
+ canvas.drawCircle(getX(i), getY(val), pr, strokeP);
2155
+ }
2156
+ // Fill
2072
2157
  const dotPaint = Skia.Paint();
2073
- try { dotPaint.setColor(lineColor); } catch { dotPaint.setColor(Skia.Color('#3b82f6')); }
2074
- canvas.drawCircle(getX(i), getY(val), 3, dotPaint);
2158
+ try { dotPaint.setColor(Skia.Color((ds.pointFill || '#ffffff').replace(/\s+/g, ''))); } catch { dotPaint.setColor(Skia.Color('#ffffff')); }
2159
+ canvas.drawCircle(getX(i), getY(val), pr - (ds.pointStrokeWidth || 2)/2, dotPaint);
2075
2160
  });
2076
2161
  }
2077
2162
  });
@@ -2107,15 +2192,15 @@ export class ExodeUIEngine {
2107
2192
  });
2108
2193
  }
2109
2194
 
2110
- // Legend
2111
- if (datasets.length > 1 || datasets[0]?.label) {
2112
- datasets.forEach((ds: any, i: number) => {
2113
- if (!ds.label) return;
2195
+ // Legend — only if datasets have labels
2196
+ const datasetsWithLabels = datasets.filter((ds: any) => ds.label);
2197
+ if (datasetsWithLabels.length > 0) {
2198
+ datasetsWithLabels.forEach((ds: any, i: number) => {
2114
2199
  const legX = -w/2 + axisMarginL + i * 80;
2115
2200
  const legY = -h/2 + 10;
2116
2201
  const legPaint = Skia.Paint();
2117
- const lcStr = (typeof ds.lineColor === 'string' ? ds.lineColor : '#3b82f6').replace(/\s+/g, '');
2118
- try { legPaint.setColor(Skia.Color(lcStr)); } catch { legPaint.setColor(Skia.Color('#3b82f6')); }
2202
+ const lcStr = typeof ds.lineColor === 'string' ? ds.lineColor : '#3b82f6';
2203
+ try { legPaint.setColor(Skia.Color(lcStr.replace(/\s+/g, ''))); } catch { legPaint.setColor(Skia.Color('#3b82f6')); }
2119
2204
  canvas.drawRect({ x: legX, y: legY - 5, width: 16, height: 3 }, legPaint);
2120
2205
  canvas.save();
2121
2206
  canvas.translate(legX + 20, legY);
package/src/useExodeUI.ts CHANGED
@@ -40,6 +40,14 @@ export function useExodeUI() {
40
40
  engine?.updateObjectOptions(id, newOptions);
41
41
  }, [engine]);
42
42
 
43
+ const onButtonClick = useCallback((cb: (name: string, objectId: string) => void) => {
44
+ engine?.setButtonClickCallback(cb);
45
+ }, [engine]);
46
+
47
+ const onGraphPointClick = useCallback((cb: (event: { objectId: string; componentName: string; datasetIndex: number; pointIndex: number; value: number; label: string }) => void) => {
48
+ engine?.setGraphPointClickCallback(cb);
49
+ }, [engine]);
50
+
43
51
  return {
44
52
  setEngine,
45
53
  engine,
@@ -51,6 +59,8 @@ export function useExodeUI() {
51
59
  fireTrigger,
52
60
  updateConstraint,
53
61
  updateGraphData,
54
- updateObjectOptions
62
+ updateObjectOptions,
63
+ onButtonClick,
64
+ onGraphPointClick,
55
65
  };
56
66
  }