@internetstiftelsen/charts 0.20.0 → 0.20.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.
@@ -27,6 +27,8 @@ export declare class TooltipDom {
27
27
  setContent(content: string): void;
28
28
  getBounds(): DOMRect | null;
29
29
  showAt(left: number, top: number): void;
30
+ hasVisibleTooltips(): boolean;
31
+ moveVisibleTooltips(deltaX: number, deltaY: number): void;
30
32
  hide(): void;
31
33
  cleanup(): void;
32
34
  measureTooltip(tooltip: TooltipDivSelection, content: string): {
@@ -50,6 +52,7 @@ export declare class TooltipDom {
50
52
  private scheduleTooltipPositionReset;
51
53
  private cancelTooltipPositionReset;
52
54
  private getTooltipTransitionStyle;
55
+ private getTooltipNodes;
53
56
  private isTooltipVisible;
54
57
  private getTooltipPosition;
55
58
  private hasVisibleSlideOffset;
@@ -108,6 +108,22 @@ export class TooltipDom {
108
108
  }
109
109
  this.showTooltipAt(this.tooltipDiv, left, top);
110
110
  }
111
+ hasVisibleTooltips() {
112
+ return this.getTooltipNodes().some((node) => this.isTooltipVisible(node));
113
+ }
114
+ moveVisibleTooltips(deltaX, deltaY) {
115
+ this.getTooltipNodes().forEach((node) => {
116
+ if (!this.isTooltipVisible(node)) {
117
+ return;
118
+ }
119
+ const position = this.getTooltipPosition(node);
120
+ if (!position) {
121
+ return;
122
+ }
123
+ node.style.left = `${position.left + deltaX}px`;
124
+ node.style.top = `${position.top + deltaY}px`;
125
+ });
126
+ }
111
127
  hide() {
112
128
  const tooltip = this.tooltipDiv ?? select(`#${this.id}`);
113
129
  if (!tooltip.empty()) {
@@ -350,6 +366,9 @@ export class TooltipDom {
350
366
  getTooltipTransitionStyle() {
351
367
  return `opacity ${this.transition.duration}ms ${this.transition.easing}, transform ${this.transition.duration}ms ${this.transition.easing}`;
352
368
  }
369
+ getTooltipNodes() {
370
+ return Array.from(document.querySelectorAll(`#${this.id}, [data-chart-tooltip-owner="${this.splitTooltipOwner}"]`));
371
+ }
353
372
  isTooltipVisible(node) {
354
373
  return (node.style.visibility === 'visible' && node.style.opacity !== '0');
355
374
  }
@@ -301,11 +301,11 @@ export function attachXYTooltipArea(config) {
301
301
  const svgNode = svg.node();
302
302
  if (!svgNode) {
303
303
  dom.hideTooltipSelection(tooltip);
304
- return null;
304
+ return;
305
305
  }
306
306
  const placementBounds = getTooltipPlacementBounds(svgNode);
307
307
  const candidates = [];
308
- series.forEach((currentSeries, seriesIndex) => {
308
+ series.forEach((currentSeries) => {
309
309
  const rawValue = dataPoint[currentSeries.dataKey];
310
310
  if (rawValue === null || rawValue === undefined) {
311
311
  return;
@@ -319,57 +319,90 @@ export function attachXYTooltipArea(config) {
319
319
  const target = resolveSplitTooltipTarget(currentSeries, visibleAnchor, resolvedBarAnchorPosition);
320
320
  candidates.push({
321
321
  series: currentSeries,
322
- seriesIndex,
323
322
  anchor: visibleAnchor,
324
323
  target,
325
324
  });
326
325
  });
327
- const selectedCandidate = candidates.find((candidate) => candidate.seriesIndex === request.seriesIndex) ?? getClosestSingleTooltipCandidate(candidates, request);
326
+ const selectedCandidate = getClosestSingleTooltipCandidate(candidates, request);
328
327
  updateVisualStateAtIndex(request.index);
329
328
  dom.hideSplitTooltips();
330
329
  if (!selectedCandidate) {
331
330
  dom.hideTooltipSelection(tooltip);
332
- return null;
331
+ return;
333
332
  }
334
333
  dom.applyRootTooltipStyles(theme, getSeriesTooltipStyle(selectedCandidate.series, dataPoint, request.index));
335
334
  const content = buildSplitTooltipContent(dataPoint, selectedCandidate.series);
336
335
  const measuredTooltip = dom.measureTooltip(tooltip, content);
337
336
  if (!measuredTooltip) {
338
337
  dom.hideTooltipSelection(tooltip);
339
- return null;
338
+ return;
340
339
  }
341
340
  const arrowEdge = resolveTooltipArrowEdge(resolvedPosition, selectedCandidate.anchor, selectedCandidate.target, measuredTooltip.width, measuredTooltip.height, placementBounds);
342
341
  const tooltipPosition = getAnchoredTooltipPosition(selectedCandidate.anchor, selectedCandidate.target, measuredTooltip.width, measuredTooltip.height, arrowEdge, placementBounds);
343
342
  if (!tooltipPosition) {
344
343
  dom.hideTooltipSelection(tooltip);
345
- return null;
344
+ return;
346
345
  }
347
346
  dom.renderTooltipWithConnector(tooltip, arrowEdge, tooltipPosition.left, tooltipPosition.top, measuredTooltip.width, measuredTooltip.height, selectedCandidate.target.x, selectedCandidate.target.y, selectedCandidate.anchor);
348
- return {
349
- index: request.index,
350
- seriesIndex: selectedCandidate.seriesIndex,
351
- };
352
347
  };
353
- let activeTooltipRequest = null;
348
+ const tooltipSvgNode = svg.node();
349
+ let activeSvgBounds = null;
350
+ let positionTrackingFrame = null;
351
+ const stopPositionTracking = () => {
352
+ activeSvgBounds = null;
353
+ if (positionTrackingFrame !== null) {
354
+ cancelFrame(positionTrackingFrame);
355
+ positionTrackingFrame = null;
356
+ }
357
+ };
358
+ const updateTooltipPosition = () => {
359
+ if (!activeSvgBounds || !tooltipSvgNode) {
360
+ return;
361
+ }
362
+ const nextSvgBounds = getElementTooltipBounds(tooltipSvgNode);
363
+ const deltaX = nextSvgBounds.minLeft - activeSvgBounds.minLeft;
364
+ const deltaY = nextSvgBounds.minTop - activeSvgBounds.minTop;
365
+ if (deltaX === 0 && deltaY === 0) {
366
+ return;
367
+ }
368
+ dom.moveVisibleTooltips(deltaX, deltaY);
369
+ activeSvgBounds = nextSvgBounds;
370
+ };
371
+ const trackTooltipPosition = () => {
372
+ positionTrackingFrame = null;
373
+ if (!tooltipSvgNode?.isConnected || !dom.hasVisibleTooltips()) {
374
+ stopPositionTracking();
375
+ return;
376
+ }
377
+ // Scroll events can arrive late during touch or momentum scrolling.
378
+ updateTooltipPosition();
379
+ positionTrackingFrame = requestFrame(trackTooltipPosition);
380
+ };
354
381
  const hideTooltip = () => {
355
- activeTooltipRequest = null;
382
+ stopPositionTracking();
356
383
  dom.hideTooltipSelection(tooltip);
357
384
  dom.hideSplitTooltips();
358
385
  clearVisualState();
359
386
  };
360
- const tooltipSvgNode = svg.node();
361
- const queuedRender = createQueuedTooltipRender(tooltipSvgNode, (request) => {
387
+ const renderTooltip = (request) => {
388
+ if (!tooltipSvgNode) {
389
+ return;
390
+ }
391
+ activeSvgBounds = getElementTooltipBounds(tooltipSvgNode);
392
+ if (positionTrackingFrame === null) {
393
+ positionTrackingFrame = requestFrame(trackTooltipPosition);
394
+ }
362
395
  if (mode === 'single') {
363
- activeTooltipRequest = showSingleTooltip(request);
396
+ showSingleTooltip(request);
364
397
  return;
365
398
  }
366
- activeTooltipRequest = { index: request.index };
367
399
  if (mode === 'split') {
368
400
  showSplitTooltipAtIndex(request.index);
369
401
  return;
370
402
  }
371
403
  showSharedTooltipAtIndex(request.index);
372
- });
404
+ };
405
+ const queuedRender = createQueuedTooltipRender(tooltipSvgNode, renderTooltip);
373
406
  overlay
374
407
  .on('mousemove', (event) => {
375
408
  const [mouseX, mouseY] = pointer(event, svg.node());
@@ -415,18 +448,7 @@ export function attachXYTooltipArea(config) {
415
448
  }
416
449
  queuedRender.cancel();
417
450
  select(this).attr('stroke', '#111827').attr('stroke-width', 2);
418
- if (mode === 'single') {
419
- activeTooltipRequest = showSingleTooltip({
420
- index: currentIndex,
421
- });
422
- return;
423
- }
424
- activeTooltipRequest = { index: currentIndex };
425
- if (mode === 'split') {
426
- showSplitTooltipAtIndex(currentIndex);
427
- return;
428
- }
429
- showSharedTooltipAtIndex(currentIndex);
451
+ renderTooltip({ index: currentIndex });
430
452
  })
431
453
  .on('blur', function (event) {
432
454
  select(this).attr('stroke', 'none').attr('stroke-width', 0);
@@ -448,12 +470,12 @@ export function attachXYTooltipArea(config) {
448
470
  event.preventDefault();
449
471
  focusTargetNodes[nextIndex].focus();
450
472
  });
451
- return attachTooltipScrollListeners(tooltipSvgNode, () => {
452
- if (activeTooltipRequest === null) {
453
- return;
454
- }
455
- queuedRender.request(activeTooltipRequest);
456
- });
473
+ const detachScrollListeners = attachTooltipScrollListeners(tooltipSvgNode, updateTooltipPosition);
474
+ return () => {
475
+ detachScrollListeners?.();
476
+ queuedRender.cancel();
477
+ stopPositionTracking();
478
+ };
457
479
  }
458
480
  function normalizeFormatterValue(value) {
459
481
  if (value === null ||
@@ -167,6 +167,11 @@ paths are omitted when the arrow is already close to its target. For line,
167
167
  area, and scatter points, side split tooltips spread nearby point targets
168
168
  within the connectorless arrow range before drawing connector paths.
169
169
 
170
+ Visible XY tooltips track their targets on every animation frame, including
171
+ during touch and momentum scrolling. Their initial placement is preserved,
172
+ allowing them to pass beyond the container edges instead of sticking there
173
+ until blur. Tracking stops when the tooltips are hidden or the chart is destroyed.
174
+
170
175
  For horizontal bar charts, prefer `position: 'auto'` or `position: 'vertical'`.
171
176
  `position: 'side'` and `barAnchorPosition: 'top' | 'middle'` are kept for
172
177
  legacy configs, but horizontal bars resolve bar anchoring automatically.
package/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.20.0",
2
+ "version": "0.20.1",
3
3
  "name": "@internetstiftelsen/charts",
4
4
  "type": "module",
5
5
  "sideEffects": false,