@opendata-ai/openchart-vanilla 6.28.5 → 7.0.0

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.
@@ -16,8 +16,10 @@ import { renderAnnotations } from './renderers/annotations';
16
16
  import { renderAxes } from './renderers/axes';
17
17
  import { renderBrand } from './renderers/brand';
18
18
  import { renderChrome } from './renderers/chrome';
19
+ import { renderEndpointLabels } from './renderers/endpoint-labels';
19
20
  import { renderLegend } from './renderers/legend';
20
21
  import { renderMarks, resetMarkRenderState, setMarkRenderState } from './renderers/marks';
22
+ import { renderMetrics } from './renderers/metrics';
21
23
  import { createSVGElement, SVG_NS, setAttrs } from './renderers/svg-dom';
22
24
  import { nextSvgId } from './svg-ids';
23
25
 
@@ -104,16 +106,22 @@ export function renderChartSVG(
104
106
  }
105
107
  }
106
108
 
107
- // Background
108
- const bg = createSVGElement('rect');
109
- setAttrs(bg, {
110
- x: 0,
111
- y: 0,
112
- width,
113
- height,
114
- fill: layout.theme.colors.background,
115
- });
116
- svg.appendChild(bg);
109
+ // Background. Sparkline mode skips the background rect entirely so the
110
+ // host page's surface color shows through — sparklines are drop-ins for
111
+ // KPI cards, table cells, and inline contexts where the consumer owns
112
+ // the background. Other display modes paint a fill so the chart is a
113
+ // self-contained visual on any host surface.
114
+ if (layout.display !== 'sparkline') {
115
+ const bg = createSVGElement('rect');
116
+ setAttrs(bg, {
117
+ x: 0,
118
+ y: 0,
119
+ width,
120
+ height,
121
+ fill: layout.theme.colors.background,
122
+ });
123
+ svg.appendChild(bg);
124
+ }
117
125
 
118
126
  // Clip path to prevent marks (especially area fills) from overflowing
119
127
  // into the chrome region (title/subtitle). Extends full width so
@@ -159,13 +167,21 @@ export function renderChartSVG(
159
167
  renderMarks(clippedGroup, layout);
160
168
 
161
169
  // Add transparent overlay rect for line/area charts to enable voronoi tooltip lookup.
162
- // Only added when there are line or area marks with dataPoints, and no explicit
163
- // PointMark objects (which use per-element event handling instead).
170
+ // Always emitted for line/area with dataPoints the overlay-driven snap tooltip
171
+ // with crosshair is the canonical interaction for these chart types. When point
172
+ // marks coexist (e.g. mark.point: true), they still render decoratively but
173
+ // pointer events route to the overlay so the snap behavior wins.
164
174
  const hasLineOrAreaWithDataPoints = layout.marks.some(
165
175
  (m) => (m.type === 'line' || m.type === 'area') && m.dataPoints && m.dataPoints.length > 0,
166
176
  );
167
- const hasPointMarks = layout.marks.some((m) => m.type === 'point');
168
- if (hasLineOrAreaWithDataPoints && !hasPointMarks) {
177
+ if (hasLineOrAreaWithDataPoints) {
178
+ // Decorative point marks on line/area: route pointer events to the
179
+ // overlay so the snap-tooltip wins instead of competing per-point hover.
180
+ const pointEls = clippedGroup.querySelectorAll('circle.oc-mark-point');
181
+ for (const el of pointEls) {
182
+ el.setAttribute('pointer-events', 'none');
183
+ }
184
+
169
185
  const overlay = createSVGElement('rect');
170
186
  setAttrs(overlay, {
171
187
  x: layout.area.x,
@@ -178,7 +194,10 @@ export function renderChartSVG(
178
194
  overlay.setAttribute('data-voronoi-overlay', 'true');
179
195
  clippedGroup.appendChild(overlay);
180
196
 
181
- // Crosshair line: opt-in vertical line that tracks the nearest data point
197
+ // Crosshair line: vertical line that tracks the snapped data point x.
198
+ // Gated on `opts.crosshair` because the dashed line is the optional bit;
199
+ // the snap-dots layer below ships regardless so the multi-series
200
+ // hover-tooltip stays useful even when the user opts out of the line.
182
201
  if (opts?.crosshair) {
183
202
  const crosshairLine = createSVGElement('line');
184
203
  crosshairLine.setAttribute('data-crosshair', 'true');
@@ -188,27 +207,68 @@ export function renderChartSVG(
188
207
  y1: layout.area.y,
189
208
  x2: 0,
190
209
  y2: layout.area.y + layout.area.height,
191
- stroke: layout.theme.colors.gridline,
192
- 'stroke-opacity': '0.5',
193
- 'stroke-dasharray': '4,3',
210
+ stroke: layout.theme.colors.axis,
211
+ 'stroke-opacity': '0.4',
212
+ 'stroke-dasharray': '3,3',
194
213
  'stroke-width': '1',
195
214
  'pointer-events': 'none',
196
215
  });
197
216
  crosshairLine.style.display = 'none';
198
217
  clippedGroup.appendChild(crosshairLine);
199
218
  }
219
+
220
+ // Snap-dot layer: mount.ts populates one circle per series at the
221
+ // snapped x on hover. Always emitted so the merged tooltip has its
222
+ // anchors regardless of `opts.crosshair`.
223
+ const dotsGroup = createSVGElement('g');
224
+ dotsGroup.setAttribute('data-snap-dots', 'true');
225
+ dotsGroup.setAttribute('class', 'oc-snap-dots');
226
+ dotsGroup.setAttribute('pointer-events', 'none');
227
+ clippedGroup.appendChild(dotsGroup);
200
228
  }
201
229
 
202
230
  svg.appendChild(clippedGroup);
203
231
 
204
232
  renderAnnotations(svg, layout);
233
+
234
+ // Endpoint labels render after marks/annotations so they sit on top of any
235
+ // chart-edge content, but before the traditional legend so chrome wins on
236
+ // collision. The engine handles all suppression — when entries is empty,
237
+ // renderEndpointLabels is a no-op.
238
+ renderEndpointLabels(svg, layout);
239
+
240
+ // Suppress decorative point marks that sit underneath an endpoint marker
241
+ // (mark.point: true + endpoint marker on produces a double-circle at the
242
+ // line's right terminus). The endpoint marker is the canonical terminator
243
+ // when present, so the point mark hides via opacity (not removal) so the
244
+ // SVG DOM stays diff-friendly for animated re-renders.
245
+ const epEntries = layout.endpointLabels?.entries ?? [];
246
+ if (epEntries.length > 0) {
247
+ const pointEls = clippedGroup.querySelectorAll<SVGCircleElement>('circle.oc-mark-point');
248
+ for (const entry of epEntries) {
249
+ if (!entry.marker) continue;
250
+ const mx = entry.marker.x;
251
+ const my = entry.marker.y;
252
+ for (const el of pointEls) {
253
+ const cx = Number(el.getAttribute('cx'));
254
+ const cy = Number(el.getAttribute('cy'));
255
+ if (Math.abs(cx - mx) < 0.5 && Math.abs(cy - my) < 0.5) {
256
+ el.setAttribute('opacity', '0');
257
+ }
258
+ }
259
+ }
260
+ }
261
+
205
262
  renderLegend(svg, layout.legend);
206
263
 
207
264
  // Chrome renders on top so titles are never obscured by chart elements
208
265
  renderChrome(svg, layout);
266
+ renderMetrics(svg, layout);
209
267
 
210
- // Brand renders as a footer item, right-aligned on the source/footer row
211
- if (layout.watermark) {
268
+ // Brand renders as a footer item, right-aligned on the source/footer row.
269
+ // Suppressed when the spec supplies a custom chrome.brand so the two
270
+ // brand blocks don't stack.
271
+ if (layout.watermark && !layout.chrome.brand) {
212
272
  renderBrand(svg, layout);
213
273
  }
214
274
  } finally {
@@ -126,10 +126,10 @@ export function createTileMap(
126
126
 
127
127
  function getContainerDimensions(): { width: number; height: number } {
128
128
  const rect = container.getBoundingClientRect();
129
- return {
130
- width: Math.max(rect.width || 600, 100),
131
- height: Math.max(rect.height || 400, 100),
132
- };
129
+ const width = Math.max(rect.width || 600, 100);
130
+ // Height is derived from content by the compiler (tight viewBox),
131
+ // so pass a large value to ensure width is the binding constraint.
132
+ return { width, height: width };
133
133
  }
134
134
 
135
135
  function compile(): TileMapLayout {
@@ -263,8 +263,8 @@ export function createTileMap(
263
263
  }
264
264
  }
265
265
 
266
- // Setup animation cleanup
267
- if (currentLayout.animation?.enabled) {
266
+ // Setup animation cleanup only when actually animating
267
+ if (animate && currentLayout.animation?.enabled) {
268
268
  animationCleanup = setupAnimationCleanup(newSvg, () => {
269
269
  // On animation complete, check if resize was pending
270
270
  if (pendingResize && !destroyed) {
@@ -392,8 +392,6 @@ export function renderTileMapSVG(
392
392
 
393
393
  const svg = createSVGElement('svg') as SVGSVGElement;
394
394
  svg.setAttribute('viewBox', `0 0 ${width} ${height}`);
395
- svg.setAttribute('width', String(width));
396
- svg.setAttribute('height', String(height));
397
395
  svg.setAttribute('role', 'img');
398
396
  if (a11y.altText) {
399
397
  svg.setAttribute('aria-label', a11y.altText);
package/src/tooltip.ts CHANGED
@@ -6,12 +6,23 @@
6
6
  * tap-outside-to-hide.
7
7
  */
8
8
 
9
+ import type { Placement } from '@floating-ui/dom';
9
10
  import { computePosition, flip, offset, shift } from '@floating-ui/dom';
10
11
  import type { TooltipContent } from '@opendata-ai/openchart-core';
11
12
 
13
+ export interface TooltipShowOptions {
14
+ /**
15
+ * Floating-ui placement. Defaults to 'bottom-start' for mark hover
16
+ * (the legacy behavior). Line/area snap-tooltips pass 'right-start'
17
+ * so the card sits beside the snapped point and flips to 'left-start'
18
+ * via the flip middleware when crowding the right edge.
19
+ */
20
+ placement?: Placement;
21
+ }
22
+
12
23
  export interface TooltipManager {
13
24
  /** Show the tooltip with content at a given position. */
14
- show(content: TooltipContent, x: number, y: number): void;
25
+ show(content: TooltipContent, x: number, y: number, opts?: TooltipShowOptions): void;
15
26
  /** Hide the tooltip. */
16
27
  hide(): void;
17
28
  /** Remove the tooltip element and clean up event listeners. */
@@ -51,7 +62,7 @@ export function createTooltipManager(container: HTMLElement): TooltipManager {
51
62
  };
52
63
  document.addEventListener('touchstart', handleDocumentTouch);
53
64
 
54
- function show(content: TooltipContent, x: number, y: number): void {
65
+ function show(content: TooltipContent, x: number, y: number, opts?: TooltipShowOptions): void {
55
66
  // Fast content identity check: title + field count + first/last field values
56
67
  const contentKey = `${content.title}|${content.fields.length}|${content.fields[0]?.value}|${content.fields[content.fields.length - 1]?.value}`;
57
68
 
@@ -60,9 +71,14 @@ export function createTooltipManager(container: HTMLElement): TooltipManager {
60
71
 
61
72
  let html = '';
62
73
 
63
- // Title row: optional color dot + title text
74
+ // Multi-series tooltips put a swatch on each row instead of in the
75
+ // header, so detect that case once.
76
+ const colorRowCount = content.fields.filter((f) => f.color).length;
77
+ const perRowSwatches = colorRowCount > 1;
78
+
79
+ // Title row: header dot only when there's a single color (single-series)
64
80
  if (content.title) {
65
- const titleColor = content.fields.find((f) => f.color)?.color;
81
+ const titleColor = perRowSwatches ? undefined : content.fields.find((f) => f.color)?.color;
66
82
  html += '<div class="oc-tooltip-header">';
67
83
  if (titleColor) {
68
84
  html += `<span class="oc-tooltip-dot" style="background:${esc(titleColor)}"></span>`;
@@ -76,7 +92,11 @@ export function createTooltipManager(container: HTMLElement): TooltipManager {
76
92
  html += '<div class="oc-tooltip-body">';
77
93
  for (const field of content.fields) {
78
94
  html += '<div class="oc-tooltip-row">';
79
- html += `<span class="oc-tooltip-label">${esc(field.label)}</span>`;
95
+ html += '<span class="oc-tooltip-label">';
96
+ if (perRowSwatches && field.color) {
97
+ html += `<span class="oc-tooltip-row-swatch" style="background:${esc(field.color)}"></span>`;
98
+ }
99
+ html += `${esc(field.label)}</span>`;
80
100
  html += `<span class="oc-tooltip-value">${esc(field.value)}</span>`;
81
101
  html += '</div>';
82
102
  }
@@ -109,8 +129,8 @@ export function createTooltipManager(container: HTMLElement): TooltipManager {
109
129
  };
110
130
 
111
131
  computePosition(virtualRef, tooltip, {
112
- placement: 'bottom-start',
113
- middleware: [offset(TOOLTIP_OFFSET), flip(), shift({ padding: 5 })],
132
+ placement: opts?.placement ?? 'bottom-start',
133
+ middleware: [offset(TOOLTIP_OFFSET), flip(), shift({ padding: 8 })],
114
134
  }).then(({ x: fx, y: fy }) => {
115
135
  // Discard stale callbacks from earlier show() calls
116
136
  if (positionId !== currentPositionId) return;