@windborne/grapher 1.0.32 → 1.0.33

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": "@windborne/grapher",
3
- "version": "1.0.32",
3
+ "version": "1.0.33",
4
4
  "description": "Graphing library",
5
5
  "main": "src/index.js",
6
6
  "module": "dist/bundle.esm.js",
package/readme.md CHANGED
@@ -149,8 +149,10 @@ Grapher supports multiple data formats within a series:
149
149
  | background | `object` | ✗ | Background configuration. |
150
150
  | hideFromKey | `boolean` | ✗ | Whether to hide this series from the legend. |
151
151
  | showIndividualPoints | `boolean` | ✗ | Whether to show individual data points. |
152
- | negativeColor | `string` | ✗ | Color for negative values. |
153
- | gradient | `string[] \| [number, string][]` | ✗ | Gradient configuration, only applies to area rendering. |
152
+ | minPointSpacing | `number` | ✗ | Minimum pixel spacing between individual points to prevent overlap. |
153
+ | negativeColor | `string` | ✗ | Color for lines and points with negative values (below zero). |
154
+ | gradient | `string[] \| [number, string][]` | ✗ | Gradient configuration for area and shadow fills. Array of colors or [position, color] pairs. |
155
+ | negativeGradient | `string[] \| [number, string][]` | ✗ | Separate gradient for negative values (below zero) in area and shadow charts. Same format as gradient. |
154
156
  | zeroLineWidth | `number` | ✗ | Width of the zero line, only applies to bar and area rendering. |
155
157
  | zeroLineColor | `string` | ✗ | Color of the zero line, only applies to bar and area rendering. |
156
158
  | zeroLineY | `number \| 'bottom'` | ✗ | Y-coordinate of the zero line, only applies to bar and area rendering. Defaults to zero; may also be the string "bottom". |
@@ -67,8 +67,9 @@ const SingleSeries = PropTypes.shape({
67
67
  showIndividualPoints: PropTypes.bool,
68
68
  minPointSpacing: PropTypes.number,
69
69
  rendering: PropTypes.oneOf(['line', 'bar', 'area', 'shadow']), // defaults to line
70
- negativeColor: PropTypes.string, // only applies to bar
71
- gradient: PropTypes.array, // only applies to area
70
+ negativeColor: PropTypes.string, // colors lines and points below zero for all renderings
71
+ gradient: PropTypes.array, // only applies to area and shadow
72
+ negativeGradient: PropTypes.array, // gradient for values below zero (area and shadow)
72
73
  zeroLineWidth: PropTypes.number, // only applies to bar and area
73
74
  zeroLineColor: PropTypes.string, // only applies to bar and area
74
75
  zeroLineY: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), // only applies to bar and area
package/src/index.d.ts CHANGED
@@ -27,6 +27,7 @@ export interface SeriesData {
27
27
  minPointSpacing?: number;
28
28
  rendering?: 'line' | 'bar' | 'area' | 'shadow';
29
29
  negativeColor?: string;
30
+ negativeGradient?: string[] | [number, string][];
30
31
  zeroLineWidth?: number;
31
32
  zeroLineColor?: string;
32
33
  zeroLineY?: number | string;
@@ -34,6 +34,7 @@ export default function drawArea(
34
34
  zero,
35
35
  hasNegatives,
36
36
  gradient,
37
+ negativeGradient,
37
38
  zeroColor,
38
39
  zeroWidth,
39
40
  showIndividualPoints,
@@ -95,9 +96,10 @@ export default function drawArea(
95
96
  // we want to draw a polygon with a flat line at areaBottom, and then follows the shape of the data
96
97
  const areaBottom = hasNegatives ? zero : sizing.renderHeight;
97
98
 
98
- const areaPaths = pathsFrom(dataInRenderSpace);
99
+ const shouldSplitAreaPaths = hasNegatives && negativeGradient;
100
+ const areaPaths = pathsFrom(dataInRenderSpace, shouldSplitAreaPaths ? { splitAtY: zero } : undefined);
99
101
  const areaBottomPaths =
100
- inRenderSpaceAreaBottom && pathsFrom(inRenderSpaceAreaBottom);
102
+ inRenderSpaceAreaBottom && pathsFrom(inRenderSpaceAreaBottom, shouldSplitAreaPaths ? { splitAtY: zero } : undefined);
101
103
 
102
104
  const linePaths = pathsFrom(dataInRenderSpace, {
103
105
  splitAtY: zero,
@@ -139,6 +141,56 @@ export default function drawArea(
139
141
  for (let pathI = 0; pathI < areaPaths.length; pathI++) {
140
142
  const path = areaPaths[pathI];
141
143
  const areaBottomPath = areaBottomPaths && areaBottomPaths[pathI];
144
+
145
+ // Determine if this path segment is positive or negative for gradient application
146
+ let isPositive = true;
147
+ if (hasNegatives && negativeGradient && path.length > 0) {
148
+ // Check the average Y position of the path to determine if it's above or below zero
149
+ let sumY = 0;
150
+ for (let i = 0; i < path.length; i++) {
151
+ sumY += path[i][1];
152
+ }
153
+ const avgY = sumY / path.length;
154
+ isPositive = avgY <= zero; // In screen coordinates, smaller Y is higher up (positive)
155
+ }
156
+
157
+ // Set the fill style based on whether this is a positive or negative segment
158
+ if (hasNegatives && negativeGradient) {
159
+ if (isPositive) {
160
+ // Use the original gradient or color for positive areas
161
+ if (gradient && gradient.length >= 2) {
162
+ const globalGradient = context.createLinearGradient(0, 0, 0, sizing.renderHeight);
163
+ for (let i = 0; i < gradient.length; i++) {
164
+ const value = gradient[i];
165
+ if (Array.isArray(value)) {
166
+ globalGradient.addColorStop(value[0], value[1]);
167
+ } else {
168
+ globalGradient.addColorStop(i / (gradient.length - 1), value);
169
+ }
170
+ }
171
+ context.fillStyle = globalGradient;
172
+ } else {
173
+ context.fillStyle = color;
174
+ }
175
+ } else {
176
+ // Use negativeGradient for negative areas
177
+ if (negativeGradient.length >= 2) {
178
+ const negGradient = context.createLinearGradient(0, 0, 0, sizing.renderHeight);
179
+ for (let i = 0; i < negativeGradient.length; i++) {
180
+ const value = negativeGradient[i];
181
+ if (Array.isArray(value)) {
182
+ negGradient.addColorStop(value[0], value[1]);
183
+ } else {
184
+ negGradient.addColorStop(i / (negativeGradient.length - 1), value);
185
+ }
186
+ }
187
+ context.fillStyle = negGradient;
188
+ } else {
189
+ context.fillStyle = negativeColor || color;
190
+ }
191
+ }
192
+ }
193
+
142
194
  context.beginPath();
143
195
 
144
196
  const [firstX, _startY] = path[0];
@@ -180,7 +232,7 @@ export default function drawArea(
180
232
  continue;
181
233
  }
182
234
 
183
- if (hasNegatives) {
235
+ if (hasNegatives && negativeColor) {
184
236
  let positive = true;
185
237
  if (path.length >= 2) {
186
238
  positive = path[1][1] <= zero;
@@ -193,6 +245,8 @@ export default function drawArea(
193
245
  } else {
194
246
  context.strokeStyle = negativeColor;
195
247
  }
248
+ } else {
249
+ context.strokeStyle = color;
196
250
  }
197
251
 
198
252
  context.beginPath();
@@ -222,8 +276,6 @@ export default function drawArea(
222
276
  }
223
277
 
224
278
  if (showIndividualPoints && !renderCutoffGradient) {
225
- context.fillStyle = color;
226
-
227
279
  // Apply point spacing for individual point circles only
228
280
  function applyPointSpacing(points, minSpacing) {
229
281
  if (!minSpacing || points.length <= 1) {
@@ -246,16 +298,19 @@ export default function drawArea(
246
298
 
247
299
  const pointsToRender = applyPointSpacing(individualPoints, minPointSpacing);
248
300
  for (let [x, y] of pointsToRender) {
301
+ // Determine the color for this point
302
+ let pointColor = color;
249
303
  if (negativeColor && hasNegatives) {
250
304
  if (y === zero && zeroColor) {
251
- context.fillStyle = zeroColor;
305
+ pointColor = zeroColor;
252
306
  } else if (y < zero) {
253
- context.fillStyle = color;
307
+ pointColor = color;
254
308
  } else {
255
- context.fillStyle = negativeColor;
309
+ pointColor = negativeColor;
256
310
  }
257
311
  }
258
-
312
+
313
+ context.fillStyle = pointColor;
259
314
  context.beginPath();
260
315
  context.arc(x, y, pointRadius || 8, 0, 2 * Math.PI, false);
261
316
  context.fill();
@@ -22,7 +22,7 @@ function applyPointSpacing(points, minSpacing) {
22
22
  }
23
23
 
24
24
  export default function drawLine(dataInRenderSpace, {
25
- color, width=1, context, shadowColor='black', shadowBlur=5, dashed=false, dashPattern=null, highlighted=false, showIndividualPoints=false, pointRadius, minPointSpacing, getIndividualPoints, getRanges, cutoffIndex, cutoffOpacity, originalData, renderCutoffGradient, currentBounds, selectionBounds, rendering, isPreview
25
+ color, width=1, context, shadowColor='black', shadowBlur=5, dashed=false, dashPattern=null, highlighted=false, showIndividualPoints=false, pointRadius, minPointSpacing, getIndividualPoints, getRanges, cutoffIndex, cutoffOpacity, originalData, renderCutoffGradient, currentBounds, selectionBounds, rendering, isPreview, negativeColor, hasNegatives, zero, zeroColor
26
26
  }) {
27
27
  if (!context) {
28
28
  console.error("Canvas context is null in drawLine");
@@ -45,7 +45,8 @@ export default function drawLine(dataInRenderSpace, {
45
45
  context.setLineDash([]);
46
46
  }
47
47
 
48
- const paths = pathsFrom(dataInRenderSpace);
48
+ // Split paths at zero line if negativeColor is specified
49
+ const paths = pathsFrom(dataInRenderSpace, hasNegatives && negativeColor ? { splitAtY: zero } : undefined);
49
50
 
50
51
  for (let path of paths) {
51
52
  if (renderCutoffGradient && cutoffIndex !== undefined && originalData) {
@@ -279,6 +280,18 @@ export default function drawLine(dataInRenderSpace, {
279
280
  }
280
281
  }
281
282
  } else {
283
+ if (hasNegatives && negativeColor) {
284
+ let positive = true;
285
+ if (path.length >= 2) {
286
+ positive = path[1][1] <= zero;
287
+ } else if (path.length > 0) {
288
+ positive = path[0][1] <= zero;
289
+ }
290
+ context.strokeStyle = positive ? color : negativeColor;
291
+ } else {
292
+ context.strokeStyle = color;
293
+ }
294
+
282
295
  context.beginPath();
283
296
 
284
297
  for (let i = 0; i < path.length; i++) {
@@ -377,11 +390,22 @@ export default function drawLine(dataInRenderSpace, {
377
390
  isBeforeCutoff = x < cutoffPixelX;
378
391
  }
379
392
 
393
+ let pointColor = color;
394
+ if (negativeColor && hasNegatives) {
395
+ if (y === zero && zeroColor) {
396
+ pointColor = zeroColor;
397
+ } else if (y < zero) {
398
+ pointColor = color;
399
+ } else {
400
+ pointColor = negativeColor;
401
+ }
402
+ }
403
+
380
404
  if (isBeforeCutoff) {
381
- const reducedOpacityColor = applyReducedOpacity(color, cutoffOpacity);
405
+ const reducedOpacityColor = applyReducedOpacity(pointColor, cutoffOpacity);
382
406
  context.fillStyle = reducedOpacityColor;
383
407
  } else {
384
- context.fillStyle = color;
408
+ context.fillStyle = pointColor;
385
409
  }
386
410
 
387
411
  context.beginPath();
@@ -392,7 +416,20 @@ export default function drawLine(dataInRenderSpace, {
392
416
  const spacedPoints = applyPointSpacing(individualPoints, minPointSpacing);
393
417
  for (let i = 0; i < spacedPoints.length; i++) {
394
418
  const [x, y] = spacedPoints[i];
395
- context.fillStyle = color;
419
+
420
+ // Determine point color based on position relative to zero
421
+ let pointColor = color;
422
+ if (negativeColor && hasNegatives) {
423
+ if (y === zero && zeroColor) {
424
+ pointColor = zeroColor;
425
+ } else if (y < zero) {
426
+ pointColor = color;
427
+ } else {
428
+ pointColor = negativeColor;
429
+ }
430
+ }
431
+
432
+ context.fillStyle = pointColor;
396
433
  context.beginPath();
397
434
  context.arc(x, y, pointRadius || 8, 0, 2 * Math.PI, false);
398
435
  context.fill();
@@ -416,11 +453,22 @@ export default function drawLine(dataInRenderSpace, {
416
453
  isBeforeCutoff = x < cutoffPixelX;
417
454
  }
418
455
 
456
+ let pointColor = color;
457
+ if (negativeColor && hasNegatives) {
458
+ if (y === zero && zeroColor) {
459
+ pointColor = zeroColor;
460
+ } else if (y < zero) {
461
+ pointColor = color;
462
+ } else {
463
+ pointColor = negativeColor;
464
+ }
465
+ }
466
+
419
467
  if (isBeforeCutoff) {
420
- const reducedOpacityColor = applyReducedOpacity(color, cutoffOpacity);
468
+ const reducedOpacityColor = applyReducedOpacity(pointColor, cutoffOpacity);
421
469
  context.fillStyle = reducedOpacityColor;
422
470
  } else {
423
- context.fillStyle = color;
471
+ context.fillStyle = pointColor;
424
472
  }
425
473
 
426
474
  context.beginPath();
@@ -432,7 +480,20 @@ export default function drawLine(dataInRenderSpace, {
432
480
  const spacedPoints = applyPointSpacing(individualPoints, minPointSpacing);
433
481
  for (let i = 0; i < spacedPoints.length; i++) {
434
482
  const [x, y] = spacedPoints[i];
435
- context.fillStyle = color;
483
+
484
+ // Determine point color based on position relative to zero
485
+ let pointColor = color;
486
+ if (negativeColor && hasNegatives) {
487
+ if (y === zero && zeroColor) {
488
+ pointColor = zeroColor;
489
+ } else if (y < zero) {
490
+ pointColor = color;
491
+ } else {
492
+ pointColor = negativeColor;
493
+ }
494
+ }
495
+
496
+ context.fillStyle = pointColor;
436
497
  context.beginPath();
437
498
  context.arc(x, y, pointRadius || 8, 0, 2 * Math.PI, false);
438
499
  context.fill();
@@ -370,6 +370,7 @@ export default class GraphBodyRenderer extends Eventable {
370
370
  ...commonCPUParams,
371
371
  showIndividualPoints: typeof singleSeries.showIndividualPoints === 'boolean' ? singleSeries.showIndividualPoints : showIndividualPoints,
372
372
  gradient: singleSeries.gradient,
373
+ negativeGradient: singleSeries.negativeGradient,
373
374
  pointRadius: singleSeries.pointRadius,
374
375
  minPointSpacing: singleSeries.minPointSpacing,
375
376
  highlighted,
@@ -451,9 +452,12 @@ export default class GraphBodyRenderer extends Eventable {
451
452
  }
452
453
 
453
454
  const shadowColor = getColor(singleSeries.color, singleSeries.index, singleSeries.multigrapherSeriesIndex);
455
+ const hasNegatives = !!singleSeries.inDataSpace.find((tuple) => tuple[1] < 0);
454
456
  let shadowParams = {
455
457
  color: shadowColor,
456
458
  gradient: singleSeries.gradient || createDefaultGradient(shadowColor),
459
+ negativeGradient: singleSeries.negativeGradient,
460
+ hasNegatives,
457
461
  zero,
458
462
  sizing: this._sizing,
459
463
  inRenderSpaceAreaBottom
@@ -523,9 +527,54 @@ export default class GraphBodyRenderer extends Eventable {
523
527
 
524
528
  const shouldShowIndividualPoints = typeof singleSeries.showIndividualPoints === 'boolean' ? singleSeries.showIndividualPoints : showIndividualPoints;
525
529
 
530
+ let zero;
531
+ if (singleSeries.zeroLineY === 'bottom') {
532
+ zero = this._sizing.renderHeight;
533
+ } else if (singleSeries.zeroLineY !== undefined) {
534
+ zero = (1.0 - ((singleSeries.zeroLineY) - bounds.minY) / (bounds.maxY - bounds.minY)) * this._sizing.renderHeight;
535
+ } else {
536
+ if (bounds.minY <= 0 && bounds.maxY >= 0) {
537
+ zero = (1.0 - (0 - bounds.minY) / (bounds.maxY - bounds.minY)) * this._sizing.renderHeight;
538
+ } else {
539
+ zero = this._sizing.renderHeight;
540
+ }
541
+ }
542
+ const hasNegatives = !!singleSeries.inDataSpace.find((tuple) => tuple[1] < 0);
543
+
544
+ // For WebGL shadow rendering, we need a separate 2D canvas overlay for lines/points
545
+ // since WebGL and 2D contexts can't coexist on the same canvas
546
+ let drawContext = this._context;
547
+
548
+ if (this._webgl && singleSeries.rendering === 'shadow' && (width > 0 || shouldShowIndividualPoints)) {
549
+ // Only create overlay if we're actually drawing lines or points
550
+ if (!this._overlayCanvas) {
551
+ this._overlayCanvas = document.createElement('canvas');
552
+ this._overlayCanvas.style.position = 'absolute';
553
+ this._overlayCanvas.style.top = '0';
554
+ this._overlayCanvas.style.left = '0';
555
+ this._overlayCanvas.style.pointerEvents = 'none';
556
+ this._overlayContext = this._overlayCanvas.getContext('2d');
557
+ this._canvas.parentNode.insertBefore(this._overlayCanvas, this._canvas.nextSibling);
558
+ }
559
+
560
+ // Size the overlay canvas to match the main canvas
561
+ this._overlayCanvas.width = this._canvas.width;
562
+ this._overlayCanvas.height = this._canvas.height;
563
+ this._overlayCanvas.style.width = this._canvas.style.width;
564
+ this._overlayCanvas.style.height = this._canvas.style.height;
565
+
566
+ // Clear the overlay before drawing
567
+ this._overlayContext.clearRect(0, 0, this._overlayCanvas.width, this._overlayCanvas.height);
568
+
569
+ drawContext = this._overlayContext;
570
+ } else if (this._context2d) {
571
+ // For non-WebGL or non-shadow charts with 2D context
572
+ drawContext = this._context2d;
573
+ }
574
+
526
575
  const drawParams = {
527
576
  color: getColor(singleSeries.color, singleSeries.index, singleSeries.multigrapherSeriesIndex),
528
- context: this._context,
577
+ context: drawContext,
529
578
  width: width || singleSeries.width || defaultLineWidth,
530
579
  shadowColor,
531
580
  shadowBlur,
@@ -537,7 +586,11 @@ export default class GraphBodyRenderer extends Eventable {
537
586
  minPointSpacing: singleSeries.minPointSpacing,
538
587
  getIndividualPoints,
539
588
  getRanges: singleSeries.rangeKey ? getRanges : null,
540
- rendering: singleSeries.rendering // Pass rendering type for all charts
589
+ rendering: singleSeries.rendering, // Pass rendering type for all charts
590
+ negativeColor: singleSeries.negativeColor,
591
+ hasNegatives,
592
+ zero,
593
+ zeroColor: singleSeries.zeroLineColor
541
594
  };
542
595
 
543
596
 
@@ -560,7 +613,10 @@ export default class GraphBodyRenderer extends Eventable {
560
613
  drawParams.selectionBounds = selection || bounds;
561
614
  }
562
615
 
563
- if (this._webgl) {
616
+ // For shadow rendering, always use 2D canvas for lines/points even with WebGL
617
+ // This is because shadow uses WebGL for the fill but needs 2D canvas for lines/points
618
+ // to support negativeColor and other features
619
+ if (this._webgl && singleSeries.rendering !== 'shadow') {
564
620
  this._lineProgram.draw(inRenderSpace, drawParams);
565
621
  } else {
566
622
  drawLine(inRenderSpace, drawParams);
@@ -210,7 +210,9 @@ export default class ShadowProgram {
210
210
  }
211
211
 
212
212
  const trapezoids = [];
213
- const { zero, inRenderSpaceAreaBottom } = params;
213
+ const positiveTrapezoids = [];
214
+ const negativeTrapezoids = [];
215
+ const { zero, inRenderSpaceAreaBottom, negativeGradient, hasNegatives } = params;
214
216
 
215
217
 
216
218
  for (let i = 0; i < individualPoints.length - 1; i++) {
@@ -232,25 +234,45 @@ export default class ShadowProgram {
232
234
  const yCross = zero;
233
235
 
234
236
  if (Math.abs(y1 - yCross) > 0.1) {
235
- trapezoids.push({
237
+ const trap = {
236
238
  x1,
237
239
  y1,
238
240
  x2: xCross,
239
241
  y2: yCross,
240
242
  bottomY1,
241
243
  bottomY2: zero,
242
- });
244
+ };
245
+ trapezoids.push(trap);
246
+
247
+ // Determine if positive or negative (in screen coords, smaller Y is higher/positive)
248
+ if (hasNegatives && negativeGradient) {
249
+ if (y1 <= zero) {
250
+ positiveTrapezoids.push(trap);
251
+ } else {
252
+ negativeTrapezoids.push(trap);
253
+ }
254
+ }
243
255
  }
244
256
 
245
257
  if (Math.abs(y2 - yCross) > 0.1) {
246
- trapezoids.push({
258
+ const trap = {
247
259
  x1: xCross,
248
260
  y1: yCross,
249
261
  x2,
250
262
  y2,
251
263
  bottomY1: zero,
252
264
  bottomY2,
253
- });
265
+ };
266
+ trapezoids.push(trap);
267
+
268
+ // Determine if positive or negative
269
+ if (hasNegatives && negativeGradient) {
270
+ if (y2 <= zero) {
271
+ positiveTrapezoids.push(trap);
272
+ } else {
273
+ negativeTrapezoids.push(trap);
274
+ }
275
+ }
254
276
  }
255
277
  } else {
256
278
  // Skip trapezoids completely outside canvas
@@ -272,16 +294,24 @@ export default class ShadowProgram {
272
294
 
273
295
  const trapezoid = { x1, y1, x2: finalX2, y2: finalY2, bottomY1, bottomY2: finalBottomY2 };
274
296
  trapezoids.push(trapezoid);
297
+
298
+ // Determine if positive or negative
299
+ if (hasNegatives && negativeGradient) {
300
+ // Check average Y position
301
+ const avgY = (y1 + finalY2) / 2;
302
+ if (avgY <= zero) {
303
+ positiveTrapezoids.push(trapezoid);
304
+ } else {
305
+ negativeTrapezoids.push(trapezoid);
306
+ }
307
+ }
275
308
  }
276
309
  }
277
-
278
-
279
310
 
280
311
  if (trapezoids.length === 0) {
281
312
  return;
282
313
  }
283
314
 
284
- const geometry = this.generateTrapezoidGeometry(trapezoids);
285
315
  const positionLoc = gl.getAttribLocation(this._program, "position");
286
316
  const trapezoidBoundsLoc = gl.getAttribLocation(
287
317
  this._program,
@@ -292,63 +322,82 @@ export default class ShadowProgram {
292
322
  "trapezoidBottom"
293
323
  );
294
324
 
295
- gl.enableVertexAttribArray(positionLoc);
296
- gl.bindBuffer(gl.ARRAY_BUFFER, this._positionBuffer);
297
- gl.bufferData(gl.ARRAY_BUFFER, geometry.positions, gl.STATIC_DRAW);
298
- gl.vertexAttribPointer(positionLoc, 2, gl.FLOAT, false, 0, 0);
299
-
300
- gl.enableVertexAttribArray(trapezoidBoundsLoc);
301
- gl.bindBuffer(gl.ARRAY_BUFFER, this._trapezoidBoundsBuffer);
302
- gl.bufferData(gl.ARRAY_BUFFER, geometry.trapezoidBounds, gl.STATIC_DRAW);
303
- gl.vertexAttribPointer(trapezoidBoundsLoc, 4, gl.FLOAT, false, 0, 0);
304
-
305
- gl.enableVertexAttribArray(trapezoidBottomLoc);
306
- gl.bindBuffer(gl.ARRAY_BUFFER, this._trapezoidBottomBuffer);
307
- gl.bufferData(gl.ARRAY_BUFFER, geometry.trapezoidBottom, gl.STATIC_DRAW);
308
- gl.vertexAttribPointer(trapezoidBottomLoc, 4, gl.FLOAT, false, 0, 0);
309
-
310
325
  gl.uniform1f(gl.getUniformLocation(this._program, "width"), width);
311
326
  gl.uniform1f(gl.getUniformLocation(this._program, "height"), height);
312
327
 
313
- const gradientData = this.parseGradient(params.gradient, params.color);
314
-
315
- gl.activeTexture(gl.TEXTURE0);
316
- gl.bindTexture(gl.TEXTURE_2D, this._gradientTexture);
317
-
318
- gl.texImage2D(
319
- gl.TEXTURE_2D,
320
- 0,
321
- gl.RGBA,
322
- gradientData.textureWidth,
323
- 1,
324
- 0,
325
- gl.RGBA,
326
- gl.UNSIGNED_BYTE,
327
- gradientData.textureData
328
- );
328
+ gl.enable(gl.BLEND);
329
+ gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
329
330
 
330
- gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
331
- gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
332
- gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
333
- gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
331
+ // Helper function to render a set of trapezoids with a given gradient
332
+ const renderTrapezoidSet = (trapSet, gradient, color) => {
333
+ if (trapSet.length === 0) return;
334
+
335
+ const geometry = this.generateTrapezoidGeometry(trapSet);
336
+
337
+ gl.enableVertexAttribArray(positionLoc);
338
+ gl.bindBuffer(gl.ARRAY_BUFFER, this._positionBuffer);
339
+ gl.bufferData(gl.ARRAY_BUFFER, geometry.positions, gl.STATIC_DRAW);
340
+ gl.vertexAttribPointer(positionLoc, 2, gl.FLOAT, false, 0, 0);
341
+
342
+ gl.enableVertexAttribArray(trapezoidBoundsLoc);
343
+ gl.bindBuffer(gl.ARRAY_BUFFER, this._trapezoidBoundsBuffer);
344
+ gl.bufferData(gl.ARRAY_BUFFER, geometry.trapezoidBounds, gl.STATIC_DRAW);
345
+ gl.vertexAttribPointer(trapezoidBoundsLoc, 4, gl.FLOAT, false, 0, 0);
346
+
347
+ gl.enableVertexAttribArray(trapezoidBottomLoc);
348
+ gl.bindBuffer(gl.ARRAY_BUFFER, this._trapezoidBottomBuffer);
349
+ gl.bufferData(gl.ARRAY_BUFFER, geometry.trapezoidBottom, gl.STATIC_DRAW);
350
+ gl.vertexAttribPointer(trapezoidBottomLoc, 4, gl.FLOAT, false, 0, 0);
351
+
352
+ const gradientData = this.parseGradient(gradient, color);
353
+
354
+ gl.activeTexture(gl.TEXTURE0);
355
+ gl.bindTexture(gl.TEXTURE_2D, this._gradientTexture);
356
+
357
+ gl.texImage2D(
358
+ gl.TEXTURE_2D,
359
+ 0,
360
+ gl.RGBA,
361
+ gradientData.textureWidth,
362
+ 1,
363
+ 0,
364
+ gl.RGBA,
365
+ gl.UNSIGNED_BYTE,
366
+ gradientData.textureData
367
+ );
334
368
 
335
- gl.uniform1i(gl.getUniformLocation(this._program, "gradientTexture"), 0);
336
- gl.uniform1i(
337
- gl.getUniformLocation(this._program, "gradientCount"),
338
- gradientData.gradientCount
339
- );
340
- gl.uniform4fv(
341
- gl.getUniformLocation(this._program, "fallbackColor"),
342
- gradientData.fallbackColor
343
- );
369
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
370
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
371
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
372
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
344
373
 
345
- gl.enable(gl.BLEND);
346
- gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
374
+ gl.uniform1i(gl.getUniformLocation(this._program, "gradientTexture"), 0);
375
+ gl.uniform1i(
376
+ gl.getUniformLocation(this._program, "gradientCount"),
377
+ gradientData.gradientCount
378
+ );
379
+ gl.uniform4fv(
380
+ gl.getUniformLocation(this._program, "fallbackColor"),
381
+ gradientData.fallbackColor
382
+ );
383
+
384
+ gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this._indexBuffer);
385
+ gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, geometry.indices, gl.STATIC_DRAW);
347
386
 
348
- gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this._indexBuffer);
349
- gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, geometry.indices, gl.STATIC_DRAW);
387
+ gl.drawElements(gl.TRIANGLES, geometry.indices.length, gl.UNSIGNED_INT, 0);
388
+ };
350
389
 
351
- gl.drawElements(gl.TRIANGLES, geometry.indices.length, gl.UNSIGNED_INT, 0);
390
+ // If we have negativeGradient and separate trapezoid sets, render them separately
391
+ if (hasNegatives && negativeGradient && (positiveTrapezoids.length > 0 || negativeTrapezoids.length > 0)) {
392
+ // Render positive trapezoids with the normal gradient
393
+ renderTrapezoidSet(positiveTrapezoids, params.gradient, params.color);
394
+
395
+ // Render negative trapezoids with negativeGradient
396
+ renderTrapezoidSet(negativeTrapezoids, negativeGradient, params.color);
397
+ } else {
398
+ // Fallback to rendering all trapezoids with the same gradient (original behavior)
399
+ renderTrapezoidSet(trapezoids, params.gradient, params.color);
400
+ }
352
401
 
353
402
  const error = gl.getError();
354
403
  if (error !== gl.NO_ERROR) {