layerchart 2.0.0-next.62 → 2.0.0-next.63

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.
Files changed (53) hide show
  1. package/dist/canvas.d.ts +4 -0
  2. package/dist/canvas.js +4 -0
  3. package/dist/components/Arc/Arc.shared.svelte.d.ts +2 -0
  4. package/dist/components/ArcLabel/ArcLabel.shared.svelte.d.ts +1 -0
  5. package/dist/components/Circle/Circle.shared.svelte.js +24 -5
  6. package/dist/components/Circle/Circle.svelte.test.js +70 -0
  7. package/dist/components/Dodge/Dodge.shared.svelte.d.ts +132 -0
  8. package/dist/components/Dodge/Dodge.shared.svelte.js +240 -0
  9. package/dist/components/Dodge/Dodge.svelte +88 -0
  10. package/dist/components/Dodge/Dodge.svelte.d.ts +27 -0
  11. package/dist/components/Dodge/Dodge.test.d.ts +1 -0
  12. package/dist/components/Dodge/Dodge.test.js +128 -0
  13. package/dist/components/Image/Image.html.svelte +0 -8
  14. package/dist/components/Image/Image.svg.svelte +1 -9
  15. package/dist/components/Pattern/Pattern.canvas.svelte +4 -1
  16. package/dist/components/Pattern/Pattern.shared.svelte.d.ts +31 -2
  17. package/dist/components/Pattern/Pattern.shared.svelte.js +20 -1
  18. package/dist/components/Pattern/Pattern.svg.svelte +17 -1
  19. package/dist/components/Raster/Raster.base.svelte +2 -8
  20. package/dist/components/Rect/Rect.canvas.svelte +2 -4
  21. package/dist/components/Rect/Rect.canvas.svelte.d.ts +1 -1
  22. package/dist/components/Rect/Rect.html.svelte +3 -9
  23. package/dist/components/Rect/Rect.html.svelte.d.ts +1 -1
  24. package/dist/components/Rect/Rect.shared.svelte.d.ts +5 -2
  25. package/dist/components/Rect/Rect.shared.svelte.js +26 -13
  26. package/dist/components/Rect/Rect.svelte.test.js +45 -0
  27. package/dist/components/Rect/Rect.svg.svelte +36 -21
  28. package/dist/components/Rect/Rect.svg.svelte.d.ts +1 -1
  29. package/dist/components/Spline/Spline.base.svelte +3 -2
  30. package/dist/components/Text/Text.canvas.svelte +9 -0
  31. package/dist/components/Text/Text.html.svelte +6 -0
  32. package/dist/components/Text/Text.shared.svelte.d.ts +25 -2
  33. package/dist/components/Text/Text.shared.svelte.js +36 -5
  34. package/dist/components/Text/Text.svelte.test.js +40 -0
  35. package/dist/components/Text/Text.svg.svelte +7 -1
  36. package/dist/components/Waffle/Waffle.shared.svelte.d.ts +182 -0
  37. package/dist/components/Waffle/Waffle.shared.svelte.js +300 -0
  38. package/dist/components/Waffle/Waffle.svelte +148 -0
  39. package/dist/components/Waffle/Waffle.svelte.d.ts +5 -0
  40. package/dist/components/index.d.ts +4 -0
  41. package/dist/components/index.js +4 -0
  42. package/dist/html.d.ts +4 -0
  43. package/dist/html.js +4 -0
  44. package/dist/states/chart.svelte.js +8 -4
  45. package/dist/states/chart.svelte.test.js +53 -0
  46. package/dist/svg.d.ts +4 -0
  47. package/dist/svg.js +4 -0
  48. package/dist/utils/canvas.js +54 -13
  49. package/dist/utils/canvas.svelte.test.js +44 -0
  50. package/dist/utils/download.d.ts +5 -3
  51. package/dist/utils/download.js +36 -16
  52. package/dist/utils/stack.js +10 -2
  53. package/package.json +1 -1
@@ -13,6 +13,16 @@ function isTransparentFill(fill) {
13
13
  // Match rgba(..., 0) - alpha channel is 0 (fully transparent)
14
14
  return /rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*0\s*\)/.test(fill);
15
15
  }
16
+ /**
17
+ * Returns true if a style value cannot be assigned directly to a canvas
18
+ * context and must first be resolved through the hidden `<svg>` helper —
19
+ * specifically `var(...)` references and the `currentColor` keyword.
20
+ */
21
+ function needsCSSResolution(value) {
22
+ if (typeof value !== 'string')
23
+ return false;
24
+ return value.includes('var(') || value.toLowerCase() === 'currentcolor';
25
+ }
16
26
  const CANVAS_STYLES_ELEMENT_ID = '__layerchart_canvas_styles_id';
17
27
  /**
18
28
  * Parse an inline CSS style string into a StyleOptions object.
@@ -122,9 +132,8 @@ function render(ctx, render, styleOptions = {}, { applyText, } = {}) {
122
132
  // TODO: Consider memoizing? How about reactiving to CSS variable changes (light/dark mode toggle)
123
133
  let resolvedStyles;
124
134
  if (typeof document === 'undefined' ||
125
- (styleOptions.classes == null &&
126
- !Object.values(mergedStyles).some((v) => typeof v === 'string' && v.includes('var(')))) {
127
- // Skip resolving styles if running on server (no DOM), or no classes are provided and no styles are using CSS variables
135
+ (styleOptions.classes == null && !Object.values(mergedStyles).some(needsCSSResolution))) {
136
+ // Skip resolving styles if running on server (no DOM), or no classes are provided and no styles need CSS resolution (`var(...)` / `currentColor`)
128
137
  resolvedStyles = mergedStyles;
129
138
  // On server, provide sensible defaults for styles that would normally come from CSS
130
139
  if (typeof document === 'undefined') {
@@ -136,12 +145,12 @@ function render(ctx, render, styleOptions = {}, { applyText, } = {}) {
136
145
  else {
137
146
  // Remove constant non-css variable properties (ex. `strokeWidth: 0.5`, `fill: #123456`) as not needed and improves memoization cache hit
138
147
  const { constantStyles, variableStyles } = Object.entries(mergedStyles).reduce((acc, [key, value]) => {
139
- if (typeof value === 'number' || (typeof value === 'string' && !value.includes('var('))) {
140
- acc.constantStyles[key] = value;
141
- }
142
- else if (typeof value === 'string' && value.includes('var(')) {
148
+ if (needsCSSResolution(value)) {
143
149
  acc.variableStyles[key] = value;
144
150
  }
151
+ else if (typeof value === 'number' || typeof value === 'string') {
152
+ acc.constantStyles[key] = value;
153
+ }
145
154
  return acc;
146
155
  }, { constantStyles: {}, variableStyles: {} });
147
156
  const computedStyles = getComputedStyles(ctx.canvas, {
@@ -200,7 +209,7 @@ function render(ctx, render, styleOptions = {}, { applyText, } = {}) {
200
209
  styleOptions.styles?.fill instanceof CanvasGradient) ||
201
210
  (typeof CanvasPattern !== 'undefined' &&
202
211
  styleOptions.styles?.fill instanceof CanvasPattern) ||
203
- !styleOptions.styles?.fill?.includes('var'))
212
+ !needsCSSResolution(styleOptions.styles?.fill))
204
213
  ? styleOptions.styles.fill
205
214
  : resolvedStyles?.fill;
206
215
  if (fill && !isTransparentFill(fill)) {
@@ -217,7 +226,7 @@ function render(ctx, render, styleOptions = {}, { applyText, } = {}) {
217
226
  const stroke = styleOptions.styles?.stroke &&
218
227
  ((typeof CanvasGradient !== 'undefined' &&
219
228
  styleOptions.styles?.stroke instanceof CanvasGradient) ||
220
- !styleOptions.styles?.stroke?.includes('var'))
229
+ !needsCSSResolution(styleOptions.styles?.stroke))
221
230
  ? styleOptions.styles?.stroke
222
231
  : resolvedStyles?.stroke;
223
232
  if (stroke && !['none'].includes(stroke)) {
@@ -371,10 +380,18 @@ export function _createPattern(ctx, width, height, shapes, background) {
371
380
  const patternCtx = patternCanvas.getContext('2d');
372
381
  // Add pattern canvas to DOM to allow computed styles to be read (`getComputedStyles()`)
373
382
  ctx.canvas.after(patternCanvas);
374
- // TODO: Fix blurry pattern
375
- // const newScale = scaleCanvas(patternCtx, width, height);
376
- patternCanvas.width = width;
377
- patternCanvas.height = height;
383
+ // Render the pattern at the device pixel ratio so the bitmap tile is
384
+ // sharp on high-DPI screens. Chrome samples patterns in the canvas's
385
+ // user (post-transform) coordinate space, so 1 source pixel = 1 user px
386
+ // by default — without DPR-scaling the bitmap, a 12 logical-px tile is
387
+ // sampled from 12 source pixels, leaving each device pixel of fill to
388
+ // be interpolated from a 1/dpr-th of a source pixel (blurry). Drawing
389
+ // at DPR resolution and scaling the pattern back down to user space at
390
+ // fill time keeps tiles sharp.
391
+ const dpr = (typeof window !== 'undefined' ? window.devicePixelRatio : 1) || 1;
392
+ patternCanvas.width = Math.max(1, Math.round(width * dpr));
393
+ patternCanvas.height = Math.max(1, Math.round(height * dpr));
394
+ patternCtx.scale(dpr, dpr);
378
395
  if (background) {
379
396
  patternCtx.fillStyle = background;
380
397
  patternCtx.fillRect(0, 0, width, height);
@@ -389,9 +406,23 @@ export function _createPattern(ctx, width, height, shapes, background) {
389
406
  styles: { stroke: shape.stroke, strokeWidth: shape.strokeWidth, opacity: shape.opacity },
390
407
  });
391
408
  }
409
+ else if (shape.type === 'rect') {
410
+ const rx = typeof shape.rx === 'string' ? toRectCornerPx(shape.rx, shape.width) : shape.rx;
411
+ const ry = typeof shape.ry === 'string' ? toRectCornerPx(shape.ry, shape.height) : (shape.ry ?? rx);
412
+ renderRect(patternCtx, { x: shape.x, y: shape.y, width: shape.width, height: shape.height, rx, ry }, { styles: { fill: shape.fill, opacity: shape.opacity } });
413
+ }
392
414
  patternCtx.restore();
393
415
  }
394
416
  const pattern = ctx.createPattern(patternCanvas, 'repeat');
417
+ // Scale-only matrix; no translate so the pattern anchors to the path's
418
+ // local origin at fill time (matches SVG `patternUnits="userSpaceOnUse"`).
419
+ // Use the *actual* bitmap pixel dimensions for the scale so rounding
420
+ // `width * dpr` to an integer doesn't accumulate drift across tiles.
421
+ if (pattern) {
422
+ const sx = width / patternCanvas.width;
423
+ const sy = height / patternCanvas.height;
424
+ pattern.setTransform(new DOMMatrix([sx, 0, 0, sy, 0, 0]));
425
+ }
395
426
  // Cleanup
396
427
  ctx.canvas.parentElement?.removeChild(patternCanvas);
397
428
  return pattern;
@@ -400,3 +431,13 @@ export function _createPattern(ctx, width, height, shapes, background) {
400
431
  export const createPattern = memoize(_createPattern, {
401
432
  cacheKey: (args) => JSON.stringify(args.slice(1)), // Ignore `ctx` argument
402
433
  });
434
+ function toRectCornerPx(value, max) {
435
+ if (value.endsWith('%')) {
436
+ const pct = parseFloat(value);
437
+ if (!Number.isFinite(pct))
438
+ return 0;
439
+ return (max / 2) * (pct / 100);
440
+ }
441
+ const n = parseFloat(value);
442
+ return Number.isFinite(n) ? n : 0;
443
+ }
@@ -403,6 +403,33 @@ describe('renderPathData', () => {
403
403
  expect(strokeSpy).toHaveBeenCalled();
404
404
  expect(ctx.strokeStyle).toBe('#008000');
405
405
  });
406
+ it('resolves currentColor stroke through the SVG helper', () => {
407
+ const parent = canvas.parentElement;
408
+ const previousColor = parent.style.color;
409
+ parent.style.color = 'rgb(255, 165, 0)';
410
+ renderPathData(ctx, 'M0,0 L100,0', {
411
+ styles: {
412
+ fill: 'none',
413
+ stroke: 'currentColor',
414
+ strokeOpacity: '1',
415
+ opacity: '1',
416
+ strokeWidth: '2',
417
+ },
418
+ });
419
+ // Canvas normalizes rgb(255, 165, 0) → '#ffa500'
420
+ expect(ctx.strokeStyle).toBe('#ffa500');
421
+ parent.style.color = previousColor;
422
+ });
423
+ it('resolves currentColor fill through the SVG helper', () => {
424
+ const parent = canvas.parentElement;
425
+ const previousColor = parent.style.color;
426
+ parent.style.color = 'rgb(128, 0, 128)';
427
+ renderPathData(ctx, 'M0,0 L100,0 L100,100 Z', {
428
+ styles: { fill: 'currentColor', fillOpacity: '1', opacity: '1', stroke: 'none' },
429
+ });
430
+ expect(ctx.fillStyle).toBe('#800080');
431
+ parent.style.color = previousColor;
432
+ });
406
433
  });
407
434
  // ---------------------------------------------------------------------------
408
435
  // renderText
@@ -704,6 +731,23 @@ describe('_getComputedStyles', () => {
704
731
  // 'red' resolves to 'rgb(255, 0, 0)' in the browser
705
732
  expect(result.fill).toMatch(/rgb\(255,\s*0,\s*0\)/);
706
733
  });
734
+ it('resolves currentColor for fill via inherited color', () => {
735
+ // Set color on the canvas's parent so the helper SVG (sibling of canvas) inherits it
736
+ const parent = canvas.parentElement;
737
+ const previousColor = parent.style.color;
738
+ parent.style.color = 'rgb(0, 128, 0)';
739
+ const result = _getComputedStyles(canvas, { styles: { fill: 'currentColor' } });
740
+ expect(result.fill).toMatch(/rgb\(0,\s*128,\s*0\)/);
741
+ parent.style.color = previousColor;
742
+ });
743
+ it('resolves currentColor for stroke via inherited color', () => {
744
+ const parent = canvas.parentElement;
745
+ const previousColor = parent.style.color;
746
+ parent.style.color = 'rgb(0, 0, 255)';
747
+ const result = _getComputedStyles(canvas, { styles: { stroke: 'currentColor' } });
748
+ expect(result.stroke).toMatch(/rgb\(0,\s*0,\s*255\)/);
749
+ parent.style.color = previousColor;
750
+ });
707
751
  it('returns empty object when DOM throws (graceful error handling)', () => {
708
752
  // Simulate error by breaking canvas.after
709
753
  const originalAfter = canvas.after.bind(canvas);
@@ -17,9 +17,11 @@ export type ChartImageOptions = {
17
17
  */
18
18
  quality?: number;
19
19
  /**
20
- * Device pixel ratio to use when rasterising SVG layers.
21
- * Higher values produce crisper images on retina displays.
22
- * Defaults to `window.devicePixelRatio` (usually 1 or 2).
20
+ * Device pixel ratio to use when rasterising the image. Defaults to `1`
21
+ * so the output matches the chart's CSS dimensions (looks the same as
22
+ * what's on the page when viewed 1:1). Set to `window.devicePixelRatio`
23
+ * (or higher) to produce crisper images on retina displays at the cost
24
+ * of larger files.
23
25
  */
24
26
  pixelRatio?: number;
25
27
  };
@@ -27,6 +27,12 @@ const SVG_STYLE_PROPERTIES = [
27
27
  'alignment-baseline',
28
28
  'visibility',
29
29
  'display',
30
+ // `<Text>` wraps each label in a nested `<svg class="lc-text-svg">` that
31
+ // relies on `overflow: visible` so labels can render outside the wrapper
32
+ // (axis ticks positioned at negative x). Without this inlined, the
33
+ // rasteriser falls back to the spec default `overflow: hidden` and clips
34
+ // the labels.
35
+ 'overflow',
30
36
  'paint-order',
31
37
  'shape-rendering',
32
38
  'text-rendering',
@@ -58,9 +64,16 @@ function inlineSvgStyles(svg) {
58
64
  }
59
65
  /**
60
66
  * Draw an SVG element onto a canvas context at the given pixel dimensions.
67
+ * Sets `viewBox` (if not authored) so the SVG content scales to fill the
68
+ * destination size — without it, increasing the `width`/`height` attributes
69
+ * leaves content at its original pixel coordinates and the result lands in
70
+ * the top-left.
61
71
  */
62
- function drawSvgToCanvas(svg, ctx, pixelWidth, pixelHeight) {
72
+ function drawSvgToCanvas(svg, ctx, pixelWidth, pixelHeight, cssWidth, cssHeight) {
63
73
  const inlined = inlineSvgStyles(svg);
74
+ if (!inlined.getAttribute('viewBox')) {
75
+ inlined.setAttribute('viewBox', `0 0 ${cssWidth} ${cssHeight}`);
76
+ }
64
77
  inlined.setAttribute('width', String(pixelWidth));
65
78
  inlined.setAttribute('height', String(pixelHeight));
66
79
  const svgStr = new XMLSerializer().serializeToString(inlined);
@@ -69,7 +82,7 @@ function drawSvgToCanvas(svg, ctx, pixelWidth, pixelHeight) {
69
82
  return new Promise((resolve, reject) => {
70
83
  const img = new Image();
71
84
  img.onload = () => {
72
- ctx.drawImage(img, 0, 0);
85
+ ctx.drawImage(img, 0, 0, pixelWidth, pixelHeight);
73
86
  URL.revokeObjectURL(url);
74
87
  resolve();
75
88
  };
@@ -90,9 +103,22 @@ function drawSvgToCanvas(svg, ctx, pixelWidth, pixelHeight) {
90
103
  */
91
104
  export async function getChartImageBlob(container, options = {}) {
92
105
  const { background, format = 'png', quality = 0.92 } = options;
93
- const dpr = options.pixelRatio ?? window.devicePixelRatio ?? 1;
94
- const cssWidth = container.clientWidth;
95
- const cssHeight = container.clientHeight;
106
+ // Default to 1 so PNGs match the chart's on-page size; pass
107
+ // `pixelRatio: window.devicePixelRatio` (or higher) for retina-sharp output.
108
+ const dpr = options.pixelRatio ?? 1;
109
+ // Find all SVG and Canvas layers within the container, sorted by z-index.
110
+ // The class-name selector implicitly excludes `.lc-hit-canvas`.
111
+ const layers = Array.from(container.querySelectorAll('.lc-layout-svg, .lc-layout-canvas')).sort((a, b) => {
112
+ const aZ = parseFloat(window.getComputedStyle(a).zIndex) || 0;
113
+ const bZ = parseFloat(window.getComputedStyle(b).zIndex) || 0;
114
+ return aZ - bZ;
115
+ });
116
+ // Size the output to the chart layers (all share the same bounds) rather
117
+ // than the wrapping container, so padding/margin doesn't leave blank
118
+ // space on the right/bottom of the image.
119
+ const layerRect = layers[0]?.getBoundingClientRect();
120
+ const cssWidth = layerRect?.width || container.clientWidth;
121
+ const cssHeight = layerRect?.height || container.clientHeight;
96
122
  const pixelWidth = Math.round(cssWidth * dpr);
97
123
  const pixelHeight = Math.round(cssHeight * dpr);
98
124
  const offscreen = document.createElement('canvas');
@@ -105,21 +131,15 @@ export async function getChartImageBlob(container, options = {}) {
105
131
  ctx.fillStyle = bg;
106
132
  ctx.fillRect(0, 0, pixelWidth, pixelHeight);
107
133
  }
108
- // Find all SVG and Canvas layers within the container, sorted by z-index.
109
- // `.lc-hit-canvas` is excluded via the class selector (it uses `.lc-layout-canvas`).
110
- const layers = Array.from(container.querySelectorAll('.lc-layout-svg, .lc-layout-canvas')).sort((a, b) => {
111
- const aZ = parseFloat(window.getComputedStyle(a).zIndex) || 0;
112
- const bZ = parseFloat(window.getComputedStyle(b).zIndex) || 0;
113
- return aZ - bZ;
114
- });
115
134
  for (const layer of layers) {
116
135
  if (layer instanceof SVGElement) {
117
- await drawSvgToCanvas(layer, ctx, pixelWidth, pixelHeight);
136
+ await drawSvgToCanvas(layer, ctx, pixelWidth, pixelHeight, cssWidth, cssHeight);
118
137
  }
119
138
  else if (layer instanceof HTMLCanvasElement) {
120
- // Canvas layers are already rendered at physical pixel resolution via scaleCanvas().
121
- // Draw them at full natural size to preserve sharpness.
122
- ctx.drawImage(layer, 0, 0);
139
+ // Canvas bitmaps are sized to `cssSize × window.devicePixelRatio`
140
+ // (set by `scaleCanvas`). Map them to the requested output size so
141
+ // the result matches `pixelRatio`.
142
+ ctx.drawImage(layer, 0, 0, pixelWidth, pixelHeight);
123
143
  }
124
144
  }
125
145
  return new Promise((resolve, reject) => {
@@ -14,7 +14,11 @@ export function groupStackData(data, options) {
14
14
  ...new Set(groupData.map((d) => d[options.stackBy ?? ''])),
15
15
  ];
16
16
  // @ts-expect-error
17
- const stackData = stack().keys(stackKeys).order(options.order).offset(options.offset)(pivotData);
17
+ const stackData = stack()
18
+ .keys(stackKeys)
19
+ .value((d, key) => d[key] ?? 0)
20
+ .order(options.order)
21
+ .offset(options.offset)(pivotData);
18
22
  return stackData.flatMap((series) => {
19
23
  return series.flatMap((s) => {
20
24
  const keys = {
@@ -43,7 +47,11 @@ export function groupStackData(data, options) {
43
47
  // @ts-expect-error
44
48
  const stackKeys = [...new Set(data.map((d) => d[options.stackBy ?? '']))];
45
49
  // @ts-expect-error
46
- const stackData = stack().keys(stackKeys).order(options.order).offset(options.offset)(pivotData);
50
+ const stackData = stack()
51
+ .keys(stackKeys)
52
+ .value((d, key) => d[key] ?? 0)
53
+ .order(options.order)
54
+ .offset(options.offset)(pivotData);
47
55
  const result = stackData.flatMap((series) => {
48
56
  return series.flatMap((s) => {
49
57
  const keys = {
package/package.json CHANGED
@@ -5,7 +5,7 @@
5
5
  "license": "MIT",
6
6
  "repository": "techniq/layerchart",
7
7
  "homepage": "https://layerchart.com",
8
- "version": "2.0.0-next.62",
8
+ "version": "2.0.0-next.63",
9
9
  "devDependencies": {
10
10
  "@changesets/cli": "^2.30.0",
11
11
  "@napi-rs/canvas": "^0.1.97",