@opendata-ai/openchart-vanilla 6.0.0 → 6.1.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.
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export { ChartLayout, ChartSpec, CompileOptions, TableLayout, TableSpec, VizSpec } from '@opendata-ai/openchart-engine';
2
- import { GraphSpec, ThemeConfig, DarkMode, ChartSpec, ChartLayout, ChartEventHandlers, BarTableCell, CategoryTableCell, TableCell, FlagTableCell, HeatmapTableCell, ImageTableCell, SparklineTableCell, TextTableCell, Mark, TableSpec, SortState, TableLayout, TooltipContent } from '@opendata-ai/openchart-core';
2
+ import { GraphSpec, ThemeConfig, DarkMode, ChartSpec, LayerSpec, ChartLayout, ChartEventHandlers, BarTableCell, CategoryTableCell, TableCell, FlagTableCell, HeatmapTableCell, ImageTableCell, SparklineTableCell, TextTableCell, Mark, TableSpec, SortState, TableLayout, TooltipContent } from '@opendata-ai/openchart-core';
3
3
 
4
4
  /**
5
5
  * Export utilities: serialize charts to SVG, PNG, JPG, or CSV.
@@ -166,7 +166,7 @@ interface ExportOptions extends JPGExportOptions {
166
166
  }
167
167
  interface ChartInstance {
168
168
  /** Re-compile and re-render with a new spec. */
169
- update(spec: ChartSpec | GraphSpec): void;
169
+ update(spec: ChartSpec | LayerSpec | GraphSpec): void;
170
170
  /** Re-compile at current container dimensions. */
171
171
  resize(): void;
172
172
  /** Export the chart. */
@@ -189,7 +189,7 @@ interface ChartInstance {
189
189
  * @param options - Mount options (theme, darkMode, responsive, etc.).
190
190
  * @returns A ChartInstance with update/resize/export/destroy methods.
191
191
  */
192
- declare function createChart(container: HTMLElement, spec: ChartSpec | GraphSpec, options?: MountOptions): ChartInstance;
192
+ declare function createChart(container: HTMLElement, spec: ChartSpec | LayerSpec | GraphSpec, options?: MountOptions): ChartInstance;
193
193
 
194
194
  /**
195
195
  * Table cell renderers: produce DOM elements for each cell type.
package/dist/index.js CHANGED
@@ -3210,7 +3210,8 @@ function escapeHtml(str) {
3210
3210
  }
3211
3211
 
3212
3212
  // src/mount.ts
3213
- import { compileChart } from "@opendata-ai/openchart-engine";
3213
+ import { isLayerSpec } from "@opendata-ai/openchart-core";
3214
+ import { compileChart, compileLayer } from "@opendata-ai/openchart-engine";
3214
3215
 
3215
3216
  // src/svg-renderer.ts
3216
3217
  import { estimateTextWidth } from "@opendata-ai/openchart-core";
@@ -3251,11 +3252,7 @@ function applyTextStyle(el, style) {
3251
3252
  el.setAttribute("text-anchor", style.textAnchor);
3252
3253
  }
3253
3254
  if (style.dominantBaseline) {
3254
- if (style.dominantBaseline === "hanging") {
3255
- el.setAttribute("dy", `${style.fontSize * 0.8}px`);
3256
- } else {
3257
- el.setAttribute("dominant-baseline", style.dominantBaseline);
3258
- }
3255
+ el.setAttribute("dominant-baseline", style.dominantBaseline);
3259
3256
  }
3260
3257
  if (style.fontVariant) {
3261
3258
  el.setAttribute("font-variant", style.fontVariant);
@@ -3423,7 +3420,7 @@ function renderAxis(parent, axis, orientation, layout) {
3423
3420
  y2: gridline.position,
3424
3421
  stroke: layout.theme.colors.gridline,
3425
3422
  "stroke-width": 1,
3426
- "stroke-opacity": 0.35
3423
+ "stroke-opacity": 0.6
3427
3424
  });
3428
3425
  } else {
3429
3426
  setAttrs(gl, {
@@ -3433,7 +3430,7 @@ function renderAxis(parent, axis, orientation, layout) {
3433
3430
  y2: area.y + area.height,
3434
3431
  stroke: layout.theme.colors.gridline,
3435
3432
  "stroke-width": 1,
3436
- "stroke-opacity": 0.35
3433
+ "stroke-opacity": 0.6
3437
3434
  });
3438
3435
  }
3439
3436
  g.appendChild(gl);
@@ -3637,11 +3634,86 @@ function renderPointMark(mark, index2) {
3637
3634
  }
3638
3635
  return circle;
3639
3636
  }
3637
+ function renderTextMark(mark, index2) {
3638
+ const text = createSVGElement("text");
3639
+ text.setAttribute("data-mark-id", `textMark-${index2}`);
3640
+ text.setAttribute("class", "viz-mark viz-mark-text");
3641
+ setAttrs(text, {
3642
+ x: mark.x,
3643
+ y: mark.y,
3644
+ "font-size": mark.fontSize,
3645
+ "text-anchor": mark.textAnchor
3646
+ });
3647
+ text.style.setProperty("fill", mark.fill);
3648
+ if (mark.fontWeight) {
3649
+ text.setAttribute("font-weight", String(mark.fontWeight));
3650
+ }
3651
+ if (mark.fontFamily) {
3652
+ text.setAttribute("font-family", mark.fontFamily);
3653
+ }
3654
+ if (mark.angle) {
3655
+ text.setAttribute("transform", `rotate(${mark.angle}, ${mark.x}, ${mark.y})`);
3656
+ }
3657
+ text.textContent = mark.text;
3658
+ return text;
3659
+ }
3660
+ function renderRuleMark(mark, index2) {
3661
+ const line = createSVGElement("line");
3662
+ line.setAttribute("data-mark-id", `rule-${index2}`);
3663
+ line.setAttribute("class", "viz-mark viz-mark-rule");
3664
+ setAttrs(line, {
3665
+ x1: mark.x1,
3666
+ y1: mark.y1,
3667
+ x2: mark.x2,
3668
+ y2: mark.y2,
3669
+ stroke: mark.stroke,
3670
+ "stroke-width": mark.strokeWidth
3671
+ });
3672
+ if (mark.strokeDasharray) {
3673
+ line.setAttribute("stroke-dasharray", mark.strokeDasharray);
3674
+ }
3675
+ if (mark.opacity != null) {
3676
+ line.setAttribute("opacity", String(mark.opacity));
3677
+ }
3678
+ return line;
3679
+ }
3680
+ function renderTickMark(mark, index2) {
3681
+ const line = createSVGElement("line");
3682
+ line.setAttribute("data-mark-id", `tick-${index2}`);
3683
+ line.setAttribute("class", "viz-mark viz-mark-tick");
3684
+ const half = mark.length / 2;
3685
+ if (mark.orient === "vertical") {
3686
+ setAttrs(line, {
3687
+ x1: mark.x,
3688
+ y1: mark.y - half,
3689
+ x2: mark.x,
3690
+ y2: mark.y + half,
3691
+ stroke: mark.stroke,
3692
+ "stroke-width": mark.strokeWidth
3693
+ });
3694
+ } else {
3695
+ setAttrs(line, {
3696
+ x1: mark.x - half,
3697
+ y1: mark.y,
3698
+ x2: mark.x + half,
3699
+ y2: mark.y,
3700
+ stroke: mark.stroke,
3701
+ "stroke-width": mark.strokeWidth
3702
+ });
3703
+ }
3704
+ if (mark.opacity != null) {
3705
+ line.setAttribute("opacity", String(mark.opacity));
3706
+ }
3707
+ return line;
3708
+ }
3640
3709
  registerMarkRenderer("line", renderLineMark);
3641
3710
  registerMarkRenderer("area", renderAreaMark);
3642
3711
  registerMarkRenderer("rect", renderRectMark);
3643
3712
  registerMarkRenderer("arc", renderArcMark);
3644
3713
  registerMarkRenderer("point", renderPointMark);
3714
+ registerMarkRenderer("textMark", renderTextMark);
3715
+ registerMarkRenderer("rule", renderRuleMark);
3716
+ registerMarkRenderer("tick", renderTickMark);
3645
3717
  function getMarkSeries(mark) {
3646
3718
  if (mark.type === "line" || mark.type === "area") {
3647
3719
  return mark.seriesKey;
@@ -3978,7 +4050,7 @@ function renderBrand(parent, layout) {
3978
4050
  setAttrs(text, {
3979
4051
  x: rightEdge,
3980
4052
  y: chromeY,
3981
- dy: BRAND_FONT_SIZE * 0.8,
4053
+ "dominant-baseline": "hanging",
3982
4054
  "font-family": layout.theme.fonts.family,
3983
4055
  "font-size": BRAND_FONT_SIZE,
3984
4056
  "text-anchor": "end",
@@ -4001,7 +4073,13 @@ function renderChartSVG(layout, container) {
4001
4073
  const svg = createSVGElement("svg");
4002
4074
  setAttrs(svg, {
4003
4075
  viewBox: `0 0 ${width} ${height}`,
4004
- xmlns: SVG_NS
4076
+ xmlns: SVG_NS,
4077
+ // WebKit/iOS Safari getBBox() bug: text with dominant-baseline:hanging
4078
+ // reports bounding boxes extending above y=0. The SVG spec default
4079
+ // overflow is "hidden", which clips this phantom extent. Setting
4080
+ // overflow:visible prevents the clipping. Chart marks are already
4081
+ // constrained by a clipPath, so nothing bleeds out.
4082
+ overflow: "visible"
4005
4083
  });
4006
4084
  svg.style.height = `${height}px`;
4007
4085
  svg.setAttribute("role", layout.a11y.role);
@@ -4034,6 +4112,23 @@ function renderChartSVG(layout, container) {
4034
4112
  const clippedGroup = createSVGElement("g");
4035
4113
  clippedGroup.setAttribute("clip-path", `url(#${clipId})`);
4036
4114
  renderMarks(clippedGroup, layout);
4115
+ const hasLineOrAreaWithDataPoints = layout.marks.some(
4116
+ (m2) => (m2.type === "line" || m2.type === "area") && m2.dataPoints && m2.dataPoints.length > 0
4117
+ );
4118
+ const hasPointMarks = layout.marks.some((m2) => m2.type === "point");
4119
+ if (hasLineOrAreaWithDataPoints && !hasPointMarks) {
4120
+ const overlay = createSVGElement("rect");
4121
+ setAttrs(overlay, {
4122
+ x: layout.area.x,
4123
+ y: layout.area.y,
4124
+ width: layout.area.width,
4125
+ height: layout.area.height,
4126
+ fill: "transparent"
4127
+ });
4128
+ overlay.setAttribute("class", "viz-voronoi-overlay");
4129
+ overlay.setAttribute("data-voronoi-overlay", "true");
4130
+ clippedGroup.appendChild(overlay);
4131
+ }
4037
4132
  svg.appendChild(clippedGroup);
4038
4133
  renderAnnotations(svg, layout);
4039
4134
  renderLegend(svg, layout.legend);
@@ -4124,6 +4219,89 @@ function wireTooltipEvents(svg, tooltipDescriptors, tooltipManager) {
4124
4219
  }
4125
4220
  };
4126
4221
  }
4222
+ function collectVoronoiPoints(layout) {
4223
+ const points = [];
4224
+ for (const mark of layout.marks) {
4225
+ if ((mark.type === "line" || mark.type === "area") && mark.dataPoints) {
4226
+ const color = mark.type === "line" ? mark.stroke : mark.fill;
4227
+ for (const dp of mark.dataPoints) {
4228
+ points.push({ ...dp, color });
4229
+ }
4230
+ }
4231
+ }
4232
+ return points;
4233
+ }
4234
+ function findNearestPoint(points, x3, y3) {
4235
+ if (points.length === 0) return null;
4236
+ let nearest = points[0];
4237
+ let minDist = (points[0].x - x3) ** 2 + (points[0].y - y3) ** 2;
4238
+ for (let i = 1; i < points.length; i++) {
4239
+ const dist = (points[i].x - x3) ** 2 + (points[i].y - y3) ** 2;
4240
+ if (dist < minDist) {
4241
+ minDist = dist;
4242
+ nearest = points[i];
4243
+ }
4244
+ }
4245
+ return nearest;
4246
+ }
4247
+ function wireVoronoiTooltipEvents(svg, layout, tooltipManager) {
4248
+ const overlay = svg.querySelector("[data-voronoi-overlay]");
4249
+ if (!overlay) return () => {
4250
+ };
4251
+ const voronoiPoints = collectVoronoiPoints(layout);
4252
+ if (voronoiPoints.length === 0) return () => {
4253
+ };
4254
+ const cleanups = [];
4255
+ const handleMouseMove = (e) => {
4256
+ const mouseEvent = e;
4257
+ const svgEl = svg;
4258
+ const svgRect = svgEl.getBoundingClientRect();
4259
+ const viewBox = svgEl.viewBox?.baseVal;
4260
+ const scaleX = viewBox?.width && svgRect.width ? viewBox.width / svgRect.width : 1;
4261
+ const scaleY = viewBox?.height && svgRect.height ? viewBox.height / svgRect.height : 1;
4262
+ const svgX = (mouseEvent.clientX - svgRect.left) * scaleX;
4263
+ const svgY = (mouseEvent.clientY - svgRect.top) * scaleY;
4264
+ const nearest = findNearestPoint(voronoiPoints, svgX, svgY);
4265
+ if (!nearest?.tooltip) return;
4266
+ const containerX = mouseEvent.clientX - svgRect.left;
4267
+ const containerY = mouseEvent.clientY - svgRect.top;
4268
+ tooltipManager.show(nearest.tooltip, containerX, containerY);
4269
+ };
4270
+ const handleMouseLeave = () => {
4271
+ tooltipManager.hide();
4272
+ };
4273
+ const handleTouchStart = (e) => {
4274
+ const touchEvent = e;
4275
+ if (touchEvent.touches.length > 0) {
4276
+ const touch = touchEvent.touches[0];
4277
+ const svgEl = svg;
4278
+ const svgRect = svgEl.getBoundingClientRect();
4279
+ const viewBox = svgEl.viewBox?.baseVal;
4280
+ const scaleX = viewBox?.width && svgRect.width ? viewBox.width / svgRect.width : 1;
4281
+ const scaleY = viewBox?.height && svgRect.height ? viewBox.height / svgRect.height : 1;
4282
+ const svgX = (touch.clientX - svgRect.left) * scaleX;
4283
+ const svgY = (touch.clientY - svgRect.top) * scaleY;
4284
+ const nearest = findNearestPoint(voronoiPoints, svgX, svgY);
4285
+ if (!nearest?.tooltip) return;
4286
+ const containerX = touch.clientX - svgRect.left;
4287
+ const containerY = touch.clientY - svgRect.top;
4288
+ tooltipManager.show(nearest.tooltip, containerX, containerY);
4289
+ }
4290
+ };
4291
+ overlay.addEventListener("mousemove", handleMouseMove);
4292
+ overlay.addEventListener("mouseleave", handleMouseLeave);
4293
+ overlay.addEventListener("touchstart", handleTouchStart);
4294
+ cleanups.push(() => {
4295
+ overlay.removeEventListener("mousemove", handleMouseMove);
4296
+ overlay.removeEventListener("mouseleave", handleMouseLeave);
4297
+ overlay.removeEventListener("touchstart", handleTouchStart);
4298
+ });
4299
+ return () => {
4300
+ for (const cleanup of cleanups) {
4301
+ cleanup();
4302
+ }
4303
+ };
4304
+ }
4127
4305
  function buildMarkDataMap(layout) {
4128
4306
  const map = /* @__PURE__ */ new Map();
4129
4307
  for (let i = 0; i < layout.marks.length; i++) {
@@ -4452,10 +4630,10 @@ function wireConnectorEndpointDrag(svg, specAnnotations, onEdit, setDragging) {
4452
4630
  if (!connectorLine && !curvedPath) continue;
4453
4631
  let fromX, fromY, toX, toY;
4454
4632
  if (connectorLine) {
4455
- fromX = Number(connectorLine.getAttribute("x1"));
4456
- fromY = Number(connectorLine.getAttribute("y1"));
4457
- toX = Number(connectorLine.getAttribute("x2"));
4458
- toY = Number(connectorLine.getAttribute("y2"));
4633
+ fromX = Number(connectorLine.getAttribute("x1")) || 0;
4634
+ fromY = Number(connectorLine.getAttribute("y1")) || 0;
4635
+ toX = Number(connectorLine.getAttribute("x2")) || 0;
4636
+ toY = Number(connectorLine.getAttribute("y2")) || 0;
4459
4637
  } else {
4460
4638
  const pathD = curvedPath.getAttribute("d") ?? "";
4461
4639
  const mMatch = pathD.match(/M\s*([\d.e+-]+)\s+([\d.e+-]+)/);
@@ -4465,8 +4643,8 @@ function wireConnectorEndpointDrag(svg, specAnnotations, onEdit, setDragging) {
4465
4643
  const points = arrowhead?.getAttribute("points") ?? "";
4466
4644
  const firstPoint = points.split(" ")[0] ?? "0,0";
4467
4645
  const [px, py] = firstPoint.split(",");
4468
- toX = Number(px);
4469
- toY = Number(py);
4646
+ toX = Number(px) || 0;
4647
+ toY = Number(py) || 0;
4470
4648
  }
4471
4649
  const endpoints = [
4472
4650
  { name: "from", cx: fromX, cy: fromY },
@@ -4474,6 +4652,7 @@ function wireConnectorEndpointDrag(svg, specAnnotations, onEdit, setDragging) {
4474
4652
  ];
4475
4653
  const createdHandles = [];
4476
4654
  for (const ep of endpoints) {
4655
+ if (!Number.isFinite(ep.cx) || !Number.isFinite(ep.cy)) continue;
4477
4656
  const handleEl = document.createElementNS(SVG_NS2, "circle");
4478
4657
  handleEl.setAttribute("class", "viz-connector-handle");
4479
4658
  handleEl.setAttribute("data-endpoint", ep.name);
@@ -4910,6 +5089,7 @@ function createChart(container, spec, options) {
4910
5089
  let tooltipManager = null;
4911
5090
  let disconnectResize = null;
4912
5091
  let cleanupTooltipEvents = null;
5092
+ let cleanupVoronoiEvents = null;
4913
5093
  let cleanupKeyboardNav = null;
4914
5094
  let cleanupLegend = null;
4915
5095
  let cleanupChartEvents = null;
@@ -4931,6 +5111,9 @@ function createChart(container, spec, options) {
4931
5111
  darkMode,
4932
5112
  measureText
4933
5113
  };
5114
+ if (isLayerSpec(currentSpec)) {
5115
+ return compileLayer(currentSpec, compileOpts);
5116
+ }
4934
5117
  return compileChart(currentSpec, compileOpts);
4935
5118
  }
4936
5119
  function getContainerDimensions() {
@@ -4949,6 +5132,10 @@ function createChart(container, spec, options) {
4949
5132
  cleanupTooltipEvents();
4950
5133
  cleanupTooltipEvents = null;
4951
5134
  }
5135
+ if (cleanupVoronoiEvents) {
5136
+ cleanupVoronoiEvents();
5137
+ cleanupVoronoiEvents = null;
5138
+ }
4952
5139
  if (cleanupKeyboardNav) {
4953
5140
  cleanupKeyboardNav();
4954
5141
  cleanupKeyboardNav = null;
@@ -4987,6 +5174,7 @@ function createChart(container, spec, options) {
4987
5174
  currentLayout.tooltipDescriptors,
4988
5175
  tooltipManager
4989
5176
  );
5177
+ cleanupVoronoiEvents = wireVoronoiTooltipEvents(svgElement, currentLayout, tooltipManager);
4990
5178
  cleanupKeyboardNav = wireKeyboardNav(
4991
5179
  svgElement,
4992
5180
  container,
@@ -5029,9 +5217,10 @@ function createChart(container, spec, options) {
5029
5217
  editCleanups.push(
5030
5218
  wireAnnotationLabelDrag(svgElement, dragAnnotations, options.onEdit, setDragging)
5031
5219
  );
5032
- editCleanups.push(wireChromeDrag(svgElement, currentSpec, options.onEdit, setDragging));
5033
- editCleanups.push(wireLegendDrag(svgElement, currentSpec, options.onEdit, setDragging));
5034
- editCleanups.push(wireSeriesLabelDrag(svgElement, currentSpec, options.onEdit, setDragging));
5220
+ const editSpec = currentSpec;
5221
+ editCleanups.push(wireChromeDrag(svgElement, editSpec, options.onEdit, setDragging));
5222
+ editCleanups.push(wireLegendDrag(svgElement, editSpec, options.onEdit, setDragging));
5223
+ editCleanups.push(wireSeriesLabelDrag(svgElement, editSpec, options.onEdit, setDragging));
5035
5224
  cleanupEditDrags = () => {
5036
5225
  for (const cleanup of editCleanups) {
5037
5226
  cleanup();
@@ -5088,6 +5277,10 @@ function createChart(container, spec, options) {
5088
5277
  cleanupTooltipEvents();
5089
5278
  cleanupTooltipEvents = null;
5090
5279
  }
5280
+ if (cleanupVoronoiEvents) {
5281
+ cleanupVoronoiEvents();
5282
+ cleanupVoronoiEvents = null;
5283
+ }
5091
5284
  if (cleanupKeyboardNav) {
5092
5285
  cleanupKeyboardNav();
5093
5286
  cleanupKeyboardNav = null;