@opendata-ai/openchart-vanilla 2.1.0 → 2.2.1

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.
@@ -1130,7 +1130,15 @@
1130
1130
  }));
1131
1131
  nodeMap = new Map(internalNodes.map((n) => [n.id, n]));
1132
1132
  const { config } = msg;
1133
- simulation = simulation_default(internalNodes).force("link", link_default(msg.edges.map((e) => ({ ...e }))).id((d) => d.id).distance(config.linkDistance)).force("charge", manyBody_default().strength(config.chargeStrength)).force("center", center_default(0, 0)).force("collide", collide_default().radius((d) => d.radius + 1)).force("gravityX", x_default2(0).strength(0.05)).force("gravityY", y_default2(0).strength(0.05)).alphaDecay(config.alphaDecay).velocityDecay(config.velocityDecay);
1133
+ const linkForce = link_default(msg.edges.map((e) => ({ ...e }))).id((d) => d.id).distance(config.linkDistance);
1134
+ if (config.linkStrength != null) {
1135
+ linkForce.strength(config.linkStrength);
1136
+ }
1137
+ const padding = config.collisionPadding ?? 2;
1138
+ simulation = simulation_default(internalNodes).force("link", linkForce).force("charge", manyBody_default().strength(config.chargeStrength)).force("collide", collide_default().radius((d) => d.radius + padding)).force("gravityX", x_default2(0).strength(0.05)).force("gravityY", y_default2(0).strength(0.05)).alphaDecay(config.alphaDecay).velocityDecay(config.velocityDecay);
1139
+ if (config.centerForce !== false) {
1140
+ simulation.force("center", center_default(0, 0));
1141
+ }
1134
1142
  if (config.clustering) {
1135
1143
  const clusterFn = forceCluster(internalNodes, config.clustering.strength);
1136
1144
  simulation.force("cluster", clusterFn);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opendata-ai/openchart-vanilla",
3
- "version": "2.1.0",
3
+ "version": "2.2.1",
4
4
  "description": "Vanilla JS renderer for openchart: SVG charts, HTML tables, force-directed graphs",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Riley Hilliard",
@@ -46,8 +46,8 @@
46
46
  "typecheck": "tsc --noEmit"
47
47
  },
48
48
  "dependencies": {
49
- "@opendata-ai/openchart-core": "2.1.0",
50
- "@opendata-ai/openchart-engine": "2.1.0",
49
+ "@opendata-ai/openchart-core": "2.2.1",
50
+ "@opendata-ai/openchart-engine": "2.2.1",
51
51
  "d3-force": "^3.0.0",
52
52
  "d3-quadtree": "^3.0.1"
53
53
  },
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Export utility tests.
3
3
  *
4
- * Tests exportSVG and exportCSV functions directly, verifying SVG string
5
- * validity and CSV formatting with headers and proper escaping.
4
+ * Tests exportSVG, exportCSV, and exportJPG functions directly, verifying SVG string
5
+ * validity, CSV formatting with headers and proper escaping, and JPG export interface.
6
6
  */
7
7
 
8
8
  import type { CompileOptions } from '@opendata-ai/openchart-engine';
@@ -10,7 +10,7 @@ import { compileChart } from '@opendata-ai/openchart-engine';
10
10
  import { afterEach, describe, expect, it } from 'vitest';
11
11
  import { createContainer } from '../__test-fixtures__/dom';
12
12
  import { barSpec, lineSpec } from '../__test-fixtures__/specs';
13
- import { exportCSV, exportSVG } from '../export';
13
+ import { exportCSV, exportJPG, exportSVG } from '../export';
14
14
  import { renderChartSVG } from '../svg-renderer';
15
15
 
16
16
  // ---------------------------------------------------------------------------
@@ -148,3 +148,29 @@ describe('exportCSV', () => {
148
148
  expect(result.split('\n')[0]).toBe('x,y,z');
149
149
  });
150
150
  });
151
+
152
+ // ---------------------------------------------------------------------------
153
+ // exportJPG
154
+ // ---------------------------------------------------------------------------
155
+
156
+ describe('exportJPG', () => {
157
+ it('is a function that accepts an SVG element and options', () => {
158
+ expect(typeof exportJPG).toBe('function');
159
+ expect(exportJPG.length).toBeGreaterThanOrEqual(1);
160
+ });
161
+
162
+ it('returns a Promise when called with a rendered SVG element', () => {
163
+ const svg = renderToSVG();
164
+ const result = exportJPG(svg);
165
+ expect(result).toBeInstanceOf(Promise);
166
+ // Clean up: catch any rejection from happy-dom canvas limitations
167
+ result.catch(() => {});
168
+ });
169
+
170
+ it('accepts quality option between 0 and 1', () => {
171
+ const svg = renderToSVG();
172
+ const result = exportJPG(svg, { quality: 0.5, dpi: 1 });
173
+ expect(result).toBeInstanceOf(Promise);
174
+ result.catch(() => {});
175
+ });
176
+ });
package/src/export.ts CHANGED
@@ -74,6 +74,76 @@ export async function exportPNG(svgElement: SVGElement, options?: PNGExportOptio
74
74
  });
75
75
  }
76
76
 
77
+ export interface JPGExportOptions extends PNGExportOptions {
78
+ /** JPEG quality from 0 to 1. Defaults to 0.92. */
79
+ quality?: number;
80
+ }
81
+
82
+ /**
83
+ * Render an SVG element to a JPEG Blob via a canvas.
84
+ *
85
+ * Same pipeline as exportPNG but outputs JPEG with configurable quality.
86
+ * The canvas is filled with white before drawing to avoid transparent
87
+ * backgrounds rendering as black in JPEG format.
88
+ *
89
+ * @param svgElement - The rendered SVG element.
90
+ * @param options - Optional DPI scaling and JPEG quality.
91
+ * @returns A Promise resolving to the JPEG Blob.
92
+ */
93
+ export async function exportJPG(svgElement: SVGElement, options?: JPGExportOptions): Promise<Blob> {
94
+ const dpi = options?.dpi ?? 2;
95
+ const quality = options?.quality ?? 0.92;
96
+ const svgString = exportSVG(svgElement);
97
+
98
+ const width = parseFloat(svgElement.getAttribute('width') || '600');
99
+ const height = parseFloat(svgElement.getAttribute('height') || '400');
100
+
101
+ const canvas = document.createElement('canvas');
102
+ canvas.width = width * dpi;
103
+ canvas.height = height * dpi;
104
+
105
+ const ctx = canvas.getContext('2d');
106
+ if (!ctx) {
107
+ throw new Error('Canvas 2D context not available');
108
+ }
109
+
110
+ // Fill white background since JPEG doesn't support transparency
111
+ ctx.fillStyle = '#ffffff';
112
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
113
+
114
+ ctx.scale(dpi, dpi);
115
+
116
+ const img = new Image();
117
+ const blob = new Blob([svgString], { type: 'image/svg+xml;charset=utf-8' });
118
+ const url = URL.createObjectURL(blob);
119
+
120
+ return new Promise<Blob>((resolve, reject) => {
121
+ img.onload = () => {
122
+ ctx.drawImage(img, 0, 0);
123
+ URL.revokeObjectURL(url);
124
+
125
+ canvas.toBlob(
126
+ (result) => {
127
+ if (result) {
128
+ resolve(result);
129
+ } else {
130
+ reject(new Error('Canvas toBlob returned null'));
131
+ }
132
+ },
133
+ 'image/jpeg',
134
+ quality,
135
+ );
136
+ };
137
+
138
+ img.onerror = () => {
139
+ URL.revokeObjectURL(url);
140
+ reject(new Error('Failed to load SVG as image'));
141
+ };
142
+
143
+ img.src = url;
144
+ });
145
+ }
146
+
77
147
  /**
78
148
  * Convert an array of data objects to a CSV string.
79
149
  *
@@ -18,15 +18,15 @@ import type { GraphRenderState, PositionedEdge, PositionedNode } from './types';
18
18
  // Constants
19
19
  // ---------------------------------------------------------------------------
20
20
 
21
- const LABEL_FONT_MIN = 10;
22
- const LABEL_FONT_MAX = 14;
21
+ const LABEL_FONT_MIN = 8;
22
+ const LABEL_FONT_MAX = 12;
23
23
  const EDGE_ALPHA_DEFAULT = 0.35;
24
24
  const EDGE_ALPHA_CONNECTED = 1.0;
25
25
  const EDGE_ALPHA_DIMMED = 0.05;
26
26
  const SEARCH_NON_MATCH_ALPHA = 0.15;
27
27
  const GLOW_NODE_THRESHOLD = 2000;
28
- const GLOW_RADIUS_MULTIPLIER = 1.5;
29
- const GLOW_ALPHA = 0.2;
28
+ const GLOW_RADIUS_MULTIPLIER = 1.3;
29
+ const GLOW_ALPHA = 0.15;
30
30
  const CULL_MARGIN = 50;
31
31
  const TWO_PI = Math.PI * 2;
32
32
 
@@ -141,6 +141,7 @@ export class GraphCanvasRenderer {
141
141
  edges,
142
142
  transform,
143
143
  hoveredNodeId,
144
+ hoveredEdgeId,
144
145
  selectedNodeIds,
145
146
  adjacencyMap,
146
147
  theme,
@@ -179,9 +180,11 @@ export class GraphCanvasRenderer {
179
180
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
180
181
  ctx.clearRect(0, 0, cssWidth, cssHeight);
181
182
 
182
- // Fill background
183
- ctx.fillStyle = theme.colors.background;
184
- ctx.fillRect(0, 0, cssWidth, cssHeight);
183
+ // Fill background (skip if transparent to let page background show through)
184
+ if (theme.colors.background !== 'transparent') {
185
+ ctx.fillStyle = theme.colors.background;
186
+ ctx.fillRect(0, 0, cssWidth, cssHeight);
187
+ }
185
188
 
186
189
  ctx.translate(transform.x, transform.y);
187
190
  ctx.scale(transform.k, transform.k);
@@ -193,6 +196,7 @@ export class GraphCanvasRenderer {
193
196
  hasActiveNode,
194
197
  connectedNodeIds,
195
198
  isGesturing ? null : searchMatches,
199
+ hoveredEdgeId,
196
200
  );
197
201
 
198
202
  // -- Draw nodes (batched by fill color) --
@@ -263,13 +267,21 @@ export class GraphCanvasRenderer {
263
267
  hasActiveNode: boolean,
264
268
  connectedNodeIds: Set<string>,
265
269
  searchMatches: Set<string> | null,
270
+ hoveredEdgeId: string | null,
266
271
  ): void {
267
272
  // Classify edges by alpha level, then batch by visual style within each level
268
273
  const dimmedEdges: PositionedEdge[] = [];
269
274
  const defaultEdges: PositionedEdge[] = [];
270
275
  const connectedEdges: PositionedEdge[] = [];
276
+ let hoveredEdge: PositionedEdge | null = null;
271
277
 
272
278
  for (const edge of edges) {
279
+ const edgeId = `${edge.source}->${edge.target}`;
280
+ if (edgeId === hoveredEdgeId) {
281
+ hoveredEdge = edge;
282
+ continue; // Draw hovered edge last, on top
283
+ }
284
+
273
285
  const isConnected =
274
286
  hasActiveNode && connectedNodeIds.has(edge.source) && connectedNodeIds.has(edge.target);
275
287
  const isDimmed = hasActiveNode && !isConnected;
@@ -287,6 +299,21 @@ export class GraphCanvasRenderer {
287
299
  this.drawEdgeGroupBatched(ctx, dimmedEdges, EDGE_ALPHA_DIMMED, searchMatches);
288
300
  this.drawEdgeGroupBatched(ctx, defaultEdges, EDGE_ALPHA_DEFAULT, searchMatches);
289
301
  this.drawEdgeGroupBatched(ctx, connectedEdges, EDGE_ALPHA_CONNECTED, searchMatches);
302
+
303
+ // Draw hovered edge on top with highlight
304
+ if (hoveredEdge) {
305
+ const dash = DASH_PATTERNS[hoveredEdge.style] ?? DASH_PATTERNS.solid;
306
+ ctx.setLineDash(dash);
307
+ ctx.strokeStyle = hoveredEdge.stroke;
308
+ ctx.lineWidth = hoveredEdge.strokeWidth * 2;
309
+ ctx.globalAlpha = EDGE_ALPHA_CONNECTED;
310
+ ctx.beginPath();
311
+ ctx.moveTo(hoveredEdge.sourceX, hoveredEdge.sourceY);
312
+ ctx.lineTo(hoveredEdge.targetX, hoveredEdge.targetY);
313
+ ctx.stroke();
314
+ ctx.setLineDash([]);
315
+ ctx.globalAlpha = 1;
316
+ }
290
317
  }
291
318
 
292
319
  /**
@@ -613,7 +640,7 @@ export class GraphCanvasRenderer {
613
640
  theme: GraphRenderState['theme'],
614
641
  ): void {
615
642
  // Font size inversely scaled by zoom, clamped to readable range
616
- const rawSize = 12 / zoom;
643
+ const rawSize = 10 / zoom;
617
644
  const fontSize = Math.max(LABEL_FONT_MIN, Math.min(LABEL_FONT_MAX, rawSize));
618
645
 
619
646
  ctx.font = `${fontSize}px ${theme.fonts.family}`;
@@ -635,8 +662,9 @@ export class GraphCanvasRenderer {
635
662
 
636
663
  const labelY = node.y + node.radius + 3;
637
664
 
638
- // Dark halo for readability
639
- ctx.strokeStyle = theme.colors.background;
665
+ // Dark halo for readability (fall back to semi-transparent black when bg is transparent)
666
+ ctx.strokeStyle =
667
+ theme.colors.background === 'transparent' ? 'rgba(0, 0, 0, 0.7)' : theme.colors.background;
640
668
  ctx.lineWidth = 3;
641
669
  ctx.lineJoin = 'round';
642
670
  ctx.strokeText(node.label, node.x, labelY);
@@ -26,6 +26,8 @@ const HIT_DISTANCE = 5;
26
26
  export interface InteractionCallbacks {
27
27
  onTransformChange(transform: ZoomTransform): void;
28
28
  onHoverChange(nodeId: string | null): void;
29
+ /** Called during mouse move when no node is hit, with graph-space coordinates for edge hit testing. */
30
+ onBackgroundHover?(graphX: number, graphY: number, screenX: number, screenY: number): void;
29
31
  onSelectionChange(nodeIds: string[]): void;
30
32
  onNodeDragStart(nodeId: string): void;
31
33
  onNodeDrag(nodeId: string, x: number, y: number): void;
@@ -198,6 +200,12 @@ export class GraphInteractionManager {
198
200
  const hitId = this.hitTest(x, y);
199
201
  this.callbacks.onHoverChange(hitId);
200
202
 
203
+ // If no node hit, check edges via callback
204
+ if (!hitId) {
205
+ const graph = this.transform.screenToGraph(x, y);
206
+ this.callbacks.onBackgroundHover?.(graph.x, graph.y, x, y);
207
+ }
208
+
201
209
  // Update cursor
202
210
  this.canvas.style.cursor = hitId ? 'pointer' : 'default';
203
211
  }
@@ -60,6 +60,9 @@ interface SimConfig {
60
60
  alphaDecay: number;
61
61
  velocityDecay: number;
62
62
  collisionRadius: number;
63
+ collisionPadding?: number;
64
+ linkStrength?: number;
65
+ centerForce?: boolean;
63
66
  }
64
67
 
65
68
  type InMessage =
@@ -173,18 +176,21 @@ ctx.addEventListener('message', ((event: MessageEvent<InMessage>) => {
173
176
 
174
177
  const { config } = msg;
175
178
 
179
+ const linkForce = forceLink(msg.edges.map((e) => ({ ...e })))
180
+ .id((d) => (d as InternalNode).id)
181
+ .distance(config.linkDistance);
182
+ if (config.linkStrength != null) {
183
+ linkForce.strength(config.linkStrength);
184
+ }
185
+
186
+ const padding = config.collisionPadding ?? 2;
187
+
176
188
  simulation = forceSimulation<InternalNode>(internalNodes)
177
- .force(
178
- 'link',
179
- forceLink(msg.edges.map((e) => ({ ...e })))
180
- .id((d) => (d as InternalNode).id)
181
- .distance(config.linkDistance),
182
- )
189
+ .force('link', linkForce)
183
190
  .force('charge', forceManyBody().strength(config.chargeStrength))
184
- .force('center', forceCenter(0, 0))
185
191
  .force(
186
192
  'collide',
187
- forceCollide<InternalNode>().radius((d) => d.radius + 1),
193
+ forceCollide<InternalNode>().radius((d) => d.radius + padding),
188
194
  )
189
195
  // Weak gravity keeps disconnected nodes from drifting far from center
190
196
  .force('gravityX', forceX<InternalNode>(0).strength(0.05))
@@ -192,6 +198,11 @@ ctx.addEventListener('message', ((event: MessageEvent<InMessage>) => {
192
198
  .alphaDecay(config.alphaDecay)
193
199
  .velocityDecay(config.velocityDecay);
194
200
 
201
+ // Center force (default true)
202
+ if (config.centerForce !== false) {
203
+ simulation.force('center', forceCenter(0, 0));
204
+ }
205
+
195
206
  // Add clustering force if configured
196
207
  if (config.clustering) {
197
208
  const clusterFn = forceCluster(internalNodes, config.clustering.strength);
@@ -278,18 +278,21 @@ export class SimulationManager {
278
278
 
279
279
  this.syncNodeMap = new Map(this.syncNodes.map((n) => [n.id, n]));
280
280
 
281
+ const linkForce = forceLink(edges.map((e) => ({ ...e })))
282
+ .id((d) => (d as SyncNode).id)
283
+ .distance(config.linkDistance);
284
+ if (config.linkStrength != null) {
285
+ linkForce.strength(config.linkStrength);
286
+ }
287
+
288
+ const padding = config.collisionPadding ?? 2;
289
+
281
290
  this.syncSim = forceSimulation<SyncNode>(this.syncNodes)
282
- .force(
283
- 'link',
284
- forceLink(edges.map((e) => ({ ...e })))
285
- .id((d) => (d as SyncNode).id)
286
- .distance(config.linkDistance),
287
- )
291
+ .force('link', linkForce)
288
292
  .force('charge', forceManyBody().strength(config.chargeStrength))
289
- .force('center', forceCenter(0, 0))
290
293
  .force(
291
294
  'collide',
292
- forceCollide<SyncNode>().radius((d) => d.radius + 1),
295
+ forceCollide<SyncNode>().radius((d) => d.radius + padding),
293
296
  )
294
297
  // Weak gravity keeps disconnected nodes from drifting far from center
295
298
  .force('gravityX', forceX<SyncNode>(0).strength(0.05))
@@ -298,6 +301,11 @@ export class SimulationManager {
298
301
  .velocityDecay(config.velocityDecay)
299
302
  .stop(); // Don't auto-run; we tick manually
300
303
 
304
+ // Center force (default true)
305
+ if (config.centerForce !== false) {
306
+ this.syncSim.force('center', forceCenter(0, 0));
307
+ }
308
+
301
309
  // Add clustering force if configured
302
310
  if (config.clustering) {
303
311
  const clusterFn = forceCluster(this.syncNodes, config.clustering.strength);
@@ -35,6 +35,7 @@ export interface GraphRenderState {
35
35
  edges: PositionedEdge[];
36
36
  transform: { x: number; y: number; k: number };
37
37
  hoveredNodeId: string | null;
38
+ hoveredEdgeId: string | null;
38
39
  selectedNodeIds: Set<string>;
39
40
  adjacencyMap: Map<string, Set<string>>;
40
41
  theme: ResolvedTheme;
@@ -34,6 +34,9 @@ export interface WorkerSimulationConfig {
34
34
  alphaDecay: number;
35
35
  velocityDecay: number;
36
36
  collisionRadius: number;
37
+ collisionPadding?: number;
38
+ linkStrength?: number;
39
+ centerForce?: boolean;
37
40
  }
38
41
 
39
42
  // ---------------------------------------------------------------------------