@internetstiftelsen/charts 0.18.2 → 0.19.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.
package/README.md CHANGED
@@ -255,7 +255,9 @@ axis, and responsive behavior. `ChartGroup.height` behaves like chart height:
255
255
  set it for a fixed total group height, or omit it to size from the render
256
256
  container. Set `syncY: true` when you want vertical `XYChart` children to share
257
257
  the same Y domain so grid lines stay aligned while only one child renders a
258
- visible Y axis.
258
+ visible Y axis. Legend rows wrap automatically, with `align` positioning the
259
+ legend block and `textAlign` aligning rows inside it:
260
+ `new Legend({ align: 'center', textAlign: 'left' })`.
259
261
 
260
262
  `ChartGroup` also supports declarative responsive layout overrides. Group
261
263
  breakpoints can change `cols` and `gap`, while `addChart(..., options)` can
package/dist/legend.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { type Selection } from 'd3';
2
- import type { LegendConfig, ChartTheme, LegendSeries, ExportHooks, LegendConfigBase, LegendMode } from './types.js';
2
+ import type { LegendConfig, ChartTheme, HorizontalAlignment, LegendSeries, ExportHooks, LegendConfigBase, LegendMode } from './types.js';
3
3
  import type { LayoutAwareComponent, ComponentSpace } from './chart-interface.js';
4
4
  import { LegendStateController } from './legend-state.js';
5
5
  export declare class Legend implements LayoutAwareComponent<LegendConfigBase> {
@@ -7,6 +7,8 @@ export declare class Legend implements LayoutAwareComponent<LegendConfigBase> {
7
7
  mode: LegendMode;
8
8
  readonly position: LegendConfig['position'];
9
9
  readonly disconnectedTarget?: string | HTMLElement;
10
+ readonly align: HorizontalAlignment;
11
+ readonly textAlign: HorizontalAlignment;
10
12
  readonly exportHooks?: ExportHooks<LegendConfigBase>;
11
13
  private readonly marginTop;
12
14
  private readonly marginBottom;
@@ -53,5 +55,6 @@ export declare class Legend implements LayoutAwareComponent<LegendConfigBase> {
53
55
  private positionRows;
54
56
  private getLayoutSignature;
55
57
  private getFallbackRowHeight;
58
+ private getAlignmentOffset;
56
59
  private bindStateController;
57
60
  }
package/dist/legend.js CHANGED
@@ -28,6 +28,18 @@ export class Legend {
28
28
  writable: true,
29
29
  value: void 0
30
30
  });
31
+ Object.defineProperty(this, "align", {
32
+ enumerable: true,
33
+ configurable: true,
34
+ writable: true,
35
+ value: void 0
36
+ });
37
+ Object.defineProperty(this, "textAlign", {
38
+ enumerable: true,
39
+ configurable: true,
40
+ writable: true,
41
+ value: void 0
42
+ });
31
43
  Object.defineProperty(this, "exportHooks", {
32
44
  enumerable: true,
33
45
  configurable: true,
@@ -106,10 +118,12 @@ export class Legend {
106
118
  writable: true,
107
119
  value: null
108
120
  });
109
- const { mode = 'inline', position = 'bottom', disconnectedTarget, marginTop = 20, marginBottom = 10, paddingX, itemSpacingX, itemSpacingY, exportHooks, } = config ?? {};
121
+ const { mode = 'inline', position = 'bottom', disconnectedTarget, align = 'center', textAlign = 'center', marginTop = 20, marginBottom = 10, paddingX, itemSpacingX, itemSpacingY, exportHooks, } = config ?? {};
110
122
  this.mode = mode;
111
123
  this.position = position;
112
124
  this.disconnectedTarget = disconnectedTarget;
125
+ this.align = align;
126
+ this.textAlign = textAlign;
113
127
  this.marginTop = marginTop;
114
128
  this.marginBottom = marginBottom;
115
129
  this.paddingX = paddingX;
@@ -124,6 +138,8 @@ export class Legend {
124
138
  mode: this.mode,
125
139
  position: this.position,
126
140
  disconnectedTarget: this.disconnectedTarget,
141
+ align: this.align,
142
+ textAlign: this.textAlign,
127
143
  marginTop: this.marginTop,
128
144
  marginBottom: this.marginBottom,
129
145
  paddingX: this.paddingX,
@@ -335,6 +351,8 @@ export class Legend {
335
351
  paddingX: this.paddingX ?? theme.legend.paddingX,
336
352
  itemSpacingX: this.itemSpacingX ?? theme.legend.itemSpacingX,
337
353
  itemSpacingY: this.itemSpacingY ?? theme.legend.itemSpacingY,
354
+ align: this.align,
355
+ textAlign: this.textAlign,
338
356
  };
339
357
  }
340
358
  buildLegendItems(series) {
@@ -402,10 +420,15 @@ export class Legend {
402
420
  positionRows(rows, width, settings) {
403
421
  const positionedItems = [];
404
422
  const availableWidth = Math.max(0, width - settings.paddingX * 2);
423
+ const legendBlockWidth = rows.reduce((maxWidth, row) => {
424
+ return Math.max(maxWidth, row.width);
425
+ }, 0);
426
+ const legendBlockX = settings.paddingX +
427
+ this.getAlignmentOffset(availableWidth, legendBlockWidth, settings.align);
405
428
  let accumulatedRowHeight = 0;
406
429
  rows.forEach((row, rowIndex) => {
407
- const rowX = settings.paddingX +
408
- Math.max(0, (availableWidth - row.width) / 2);
430
+ const rowX = legendBlockX +
431
+ this.getAlignmentOffset(legendBlockWidth, row.width, settings.textAlign);
409
432
  const rowY = accumulatedRowHeight + rowIndex * settings.itemSpacingY;
410
433
  let currentX = rowX;
411
434
  row.items.forEach((item, itemIndex) => {
@@ -433,6 +456,8 @@ export class Legend {
433
456
  theme.legend.paddingX,
434
457
  theme.legend.itemSpacingX,
435
458
  theme.legend.itemSpacingY,
459
+ this.align,
460
+ this.textAlign,
436
461
  this.paddingX ?? '',
437
462
  this.itemSpacingX ?? '',
438
463
  this.itemSpacingY ?? '',
@@ -442,6 +467,16 @@ export class Legend {
442
467
  getFallbackRowHeight(theme) {
443
468
  return Math.max(theme.legend.boxSize, theme.legend.fontSize);
444
469
  }
470
+ getAlignmentOffset(availableWidth, rowWidth, align) {
471
+ const remainingWidth = Math.max(0, availableWidth - rowWidth);
472
+ if (align === 'left') {
473
+ return 0;
474
+ }
475
+ if (align === 'right') {
476
+ return remainingWidth;
477
+ }
478
+ return remainingWidth / 2;
479
+ }
445
480
  bindStateController(controller) {
446
481
  this.stateControllerCleanup?.();
447
482
  this.stateController = controller;
@@ -39,6 +39,10 @@ export declare abstract class RadialChartBase extends BaseChart {
39
39
  protected showTooltipAtElement(target: Element, content: string): void;
40
40
  protected hideTooltip(): void;
41
41
  private applyTooltipPosition;
42
+ private getTooltipViewportLeft;
43
+ private getTooltipViewportRight;
44
+ private getTooltipViewportTop;
45
+ private getTooltipViewportBottom;
42
46
  private resolveRadialLabelLayout;
43
47
  private resolveRadialLabelOverflowContext;
44
48
  private createVisibleRadialLabelLayout;
@@ -159,7 +159,7 @@ export class RadialChartBase extends BaseChart {
159
159
  }
160
160
  let x = event.pageX + TOOLTIP_OFFSET_PX;
161
161
  const y = event.pageY - rect.height / 2;
162
- if (x + rect.width > window.innerWidth - EDGE_MARGIN_PX) {
162
+ if (x + rect.width > this.getTooltipViewportRight()) {
163
163
  x = event.pageX - rect.width - TOOLTIP_OFFSET_PX;
164
164
  }
165
165
  this.applyTooltipPosition(x, y, rect.height, rect.width);
@@ -182,7 +182,7 @@ export class RadialChartBase extends BaseChart {
182
182
  window.scrollY +
183
183
  targetRect.height / 2 -
184
184
  tooltipRect.height / 2;
185
- if (x + tooltipRect.width > window.innerWidth - EDGE_MARGIN_PX) {
185
+ if (x + tooltipRect.width > this.getTooltipViewportRight()) {
186
186
  x =
187
187
  targetRect.left +
188
188
  window.scrollX -
@@ -195,10 +195,22 @@ export class RadialChartBase extends BaseChart {
195
195
  this.tooltip?.hide();
196
196
  }
197
197
  applyTooltipPosition(rawX, rawY, height, width) {
198
- const x = Math.max(EDGE_MARGIN_PX, Math.min(rawX, window.innerWidth + window.scrollX - width - EDGE_MARGIN_PX));
199
- const y = Math.max(EDGE_MARGIN_PX, Math.min(rawY, window.innerHeight + window.scrollY - height - EDGE_MARGIN_PX));
198
+ const x = Math.max(this.getTooltipViewportLeft(), Math.min(rawX, this.getTooltipViewportRight() - width));
199
+ const y = Math.max(this.getTooltipViewportTop(), Math.min(rawY, this.getTooltipViewportBottom() - height));
200
200
  this.tooltip?.showAt(x, y);
201
201
  }
202
+ getTooltipViewportLeft() {
203
+ return window.scrollX + EDGE_MARGIN_PX;
204
+ }
205
+ getTooltipViewportRight() {
206
+ return window.scrollX + window.innerWidth - EDGE_MARGIN_PX;
207
+ }
208
+ getTooltipViewportTop() {
209
+ return window.scrollY + EDGE_MARGIN_PX;
210
+ }
211
+ getTooltipViewportBottom() {
212
+ return window.scrollY + window.innerHeight - EDGE_MARGIN_PX;
213
+ }
202
214
  resolveRadialLabelLayout(text, options) {
203
215
  const overflowContext = this.resolveRadialLabelOverflowContext(options.maxLabelWidth);
204
216
  if (!overflowContext) {
@@ -17,6 +17,7 @@ export declare class TooltipDom {
17
17
  private readonly transition;
18
18
  private readonly tooltipStyleKeys;
19
19
  private readonly tooltipTransitionFrameIds;
20
+ private readonly tooltipPositionResetTimeoutIds;
20
21
  private readonly tooltipStyles;
21
22
  private tooltipDiv;
22
23
  constructor(config: TooltipDomConfig);
@@ -45,6 +46,9 @@ export declare class TooltipDom {
45
46
  private showTooltipAt;
46
47
  private showTooltipSelection;
47
48
  private hideTooltipElement;
49
+ private resetTooltipPosition;
50
+ private scheduleTooltipPositionReset;
51
+ private cancelTooltipPositionReset;
48
52
  private getTooltipTransitionStyle;
49
53
  private isTooltipVisible;
50
54
  private getTooltipPosition;
@@ -1,6 +1,7 @@
1
1
  import { select } from 'd3';
2
2
  import { resolveTooltipArrowPosition, resolveTooltipConnectorLayout, } from './geometry.js';
3
3
  import { TOOLTIP_ARROW_BORDER_Z_INDEX, TOOLTIP_ARROW_FILL_Z_INDEX, TOOLTIP_BODY_Z_INDEX, TOOLTIP_BORDER_WIDTH_PX, TOOLTIP_BOX_ARROW_HALF_HEIGHT_PX, TOOLTIP_BOX_ARROW_LENGTH_PX, TOOLTIP_CONNECTOR_Z_INDEX, TOOLTIP_HIDDEN_TRANSFORM, TOOLTIP_ROOT_Z_INDEX, TOOLTIP_VISIBLE_TRANSFORM, } from './types.js';
4
+ const HIDDEN_TOOLTIP_POSITION_PX = '0px';
4
5
  export class TooltipDom {
5
6
  constructor(config) {
6
7
  Object.defineProperty(this, "id", {
@@ -39,6 +40,12 @@ export class TooltipDom {
39
40
  writable: true,
40
41
  value: new WeakMap()
41
42
  });
43
+ Object.defineProperty(this, "tooltipPositionResetTimeoutIds", {
44
+ enumerable: true,
45
+ configurable: true,
46
+ writable: true,
47
+ value: new WeakMap()
48
+ });
42
49
  Object.defineProperty(this, "tooltipStyles", {
43
50
  enumerable: true,
44
51
  configurable: true,
@@ -284,15 +291,16 @@ export class TooltipDom {
284
291
  : null);
285
292
  }
286
293
  showTooltipSelection(tooltip, slideOffset = null) {
294
+ const node = tooltip.node();
295
+ if (!node) {
296
+ return;
297
+ }
298
+ this.cancelTooltipPositionReset(node);
287
299
  tooltip.style('visibility', 'visible');
288
300
  if (!this.transition.show) {
289
301
  return;
290
302
  }
291
303
  tooltip.style('opacity', '1');
292
- const node = tooltip.node();
293
- if (!node) {
294
- return;
295
- }
296
304
  if (!slideOffset || !this.hasVisibleSlideOffset(slideOffset)) {
297
305
  this.cancelTooltipTransitionFrame(node);
298
306
  tooltip.style('transform', TOOLTIP_VISIBLE_TRANSFORM);
@@ -301,14 +309,43 @@ export class TooltipDom {
301
309
  this.slideTooltipFromOffset(node, slideOffset);
302
310
  }
303
311
  hideTooltipElement(node) {
312
+ const wasVisible = this.isTooltipVisible(node);
313
+ this.cancelTooltipPositionReset(node);
304
314
  this.cancelTooltipTransitionFrame(node);
305
315
  if (!this.transition.show) {
306
316
  node.style.visibility = 'hidden';
317
+ this.resetTooltipPosition(node);
307
318
  return;
308
319
  }
309
320
  node.style.visibility = 'visible';
310
321
  node.style.opacity = '0';
311
322
  node.style.transform = TOOLTIP_HIDDEN_TRANSFORM;
323
+ if (!wasVisible) {
324
+ this.resetTooltipPosition(node);
325
+ return;
326
+ }
327
+ this.scheduleTooltipPositionReset(node);
328
+ }
329
+ resetTooltipPosition(node) {
330
+ node.style.left = HIDDEN_TOOLTIP_POSITION_PX;
331
+ node.style.top = HIDDEN_TOOLTIP_POSITION_PX;
332
+ }
333
+ scheduleTooltipPositionReset(node) {
334
+ const timeoutId = window.setTimeout(() => {
335
+ this.tooltipPositionResetTimeoutIds.delete(node);
336
+ if (!this.isTooltipVisible(node)) {
337
+ this.resetTooltipPosition(node);
338
+ }
339
+ }, this.transition.duration);
340
+ this.tooltipPositionResetTimeoutIds.set(node, timeoutId);
341
+ }
342
+ cancelTooltipPositionReset(node) {
343
+ const timeoutId = this.tooltipPositionResetTimeoutIds.get(node);
344
+ if (timeoutId === undefined) {
345
+ return;
346
+ }
347
+ window.clearTimeout(timeoutId);
348
+ this.tooltipPositionResetTimeoutIds.delete(node);
312
349
  }
313
350
  getTooltipTransitionStyle() {
314
351
  return `opacity ${this.transition.duration}ms ${this.transition.easing}, transform ${this.transition.duration}ms ${this.transition.easing}`;
@@ -517,12 +517,36 @@ function getClosestSingleTooltipCandidate(candidates, request) {
517
517
  !Number.isFinite(request.pointerY)) {
518
518
  return candidates[0];
519
519
  }
520
+ const touchedBarCandidate = getTouchedBarTooltipCandidate(candidates, request);
521
+ if (touchedBarCandidate) {
522
+ return touchedBarCandidate;
523
+ }
520
524
  return candidates.reduce((closest, candidate) => {
521
525
  const closestDistance = getSingleTooltipDistance(closest, request);
522
526
  const candidateDistance = getSingleTooltipDistance(candidate, request);
523
527
  return candidateDistance < closestDistance ? candidate : closest;
524
528
  });
525
529
  }
530
+ function getTouchedBarTooltipCandidate(candidates, request) {
531
+ const touchedBarCandidates = candidates.filter((candidate) => {
532
+ return (candidate.series.type === 'bar' &&
533
+ isPointerInsideAnchor(request, candidate.anchor));
534
+ });
535
+ return touchedBarCandidates[touchedBarCandidates.length - 1] ?? null;
536
+ }
537
+ function isPointerInsideAnchor(request, anchor) {
538
+ const { pointerX, pointerY } = request;
539
+ if (pointerX === undefined ||
540
+ pointerY === undefined ||
541
+ !Number.isFinite(pointerX) ||
542
+ !Number.isFinite(pointerY)) {
543
+ return false;
544
+ }
545
+ return (pointerX >= anchor.left &&
546
+ pointerX <= anchor.right &&
547
+ pointerY >= anchor.top &&
548
+ pointerY <= anchor.bottom);
549
+ }
526
550
  function getSingleTooltipDistance(candidate, request) {
527
551
  const deltaX = (request.pointerX ?? 0) - candidate.target.x;
528
552
  const deltaY = (request.pointerY ?? 0) - candidate.target.y;
@@ -785,7 +809,7 @@ function attachTooltipScrollListeners(svgNode, onScroll) {
785
809
  };
786
810
  }
787
811
  function getTooltipScrollTargets(svgNode) {
788
- const scrollTargets = new Set([window]);
812
+ const scrollTargets = new Set();
789
813
  let currentElement = svgNode.parentElement;
790
814
  while (currentElement) {
791
815
  if (isScrollableElement(currentElement)) {
package/dist/types.d.ts CHANGED
@@ -330,10 +330,13 @@ export type TooltipConfigBase = {
330
330
  export type TooltipConfig = TooltipConfigBase & {
331
331
  exportHooks?: ExportHooks<TooltipConfigBase>;
332
332
  };
333
+ export type HorizontalAlignment = 'left' | 'center' | 'right';
333
334
  export type LegendConfigBase = {
334
335
  mode?: LegendMode;
335
336
  position?: 'bottom';
336
337
  disconnectedTarget?: string | HTMLElement;
338
+ align?: HorizontalAlignment;
339
+ textAlign?: HorizontalAlignment;
337
340
  marginTop?: number;
338
341
  marginBottom?: number;
339
342
  paddingX?: number;
@@ -355,7 +358,7 @@ export type LegendItem = {
355
358
  visible: boolean;
356
359
  };
357
360
  export type TextPosition = 'top' | 'bottom';
358
- export type TextAlign = 'left' | 'center' | 'right';
361
+ export type TextAlign = HorizontalAlignment;
359
362
  export type TextVariantStyle = {
360
363
  fontSize?: number;
361
364
  fontWeight?: string;
@@ -211,6 +211,8 @@ new Legend({
211
211
  mode?: 'inline' | 'disconnected' | 'hidden', // Rendering mode (default: 'inline')
212
212
  position?: 'bottom', // Position (currently only 'bottom')
213
213
  disconnectedTarget?: string | HTMLElement, // External mount target when mode is 'disconnected'
214
+ align?: 'left' | 'center' | 'right', // Legend block alignment (default: 'center')
215
+ textAlign?: 'left' | 'center' | 'right', // Wrapped row alignment within the legend block (default: 'center')
214
216
  marginTop?: number, // Space above legend (default: 20)
215
217
  marginBottom?: number, // Space below legend (default: 10)
216
218
  paddingX?: number, // Horizontal inset for legend layout (default: theme.legend.paddingX)
@@ -220,7 +222,10 @@ new Legend({
220
222
  ```
221
223
 
222
224
  Legend items automatically wrap to new rows when they do not fit available
223
- width. Each wrapped row is centered independently.
225
+ width. The widest wrapped row defines the legend block. Use `align` to position
226
+ that block and `textAlign` to align rows inside it, for example
227
+ `{ align: 'center', textAlign: 'left' }` for a centered legend with
228
+ left-aligned wrapped rows.
224
229
 
225
230
  - `inline`: current default behavior (legend rendered inside the chart SVG)
226
231
  - `disconnected`: legend rendered in a separate SVG outside chart layout
package/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.18.2",
2
+ "version": "0.19.1",
3
3
  "name": "@internetstiftelsen/charts",
4
4
  "type": "module",
5
5
  "sideEffects": false,