@opendata-ai/openchart-vanilla 3.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";
@@ -3419,7 +3420,7 @@ function renderAxis(parent, axis, orientation, layout) {
3419
3420
  y2: gridline.position,
3420
3421
  stroke: layout.theme.colors.gridline,
3421
3422
  "stroke-width": 1,
3422
- "stroke-opacity": 0.35
3423
+ "stroke-opacity": 0.6
3423
3424
  });
3424
3425
  } else {
3425
3426
  setAttrs(gl, {
@@ -3429,7 +3430,7 @@ function renderAxis(parent, axis, orientation, layout) {
3429
3430
  y2: area.y + area.height,
3430
3431
  stroke: layout.theme.colors.gridline,
3431
3432
  "stroke-width": 1,
3432
- "stroke-opacity": 0.35
3433
+ "stroke-opacity": 0.6
3433
3434
  });
3434
3435
  }
3435
3436
  g.appendChild(gl);
@@ -3633,11 +3634,86 @@ function renderPointMark(mark, index2) {
3633
3634
  }
3634
3635
  return circle;
3635
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
+ }
3636
3709
  registerMarkRenderer("line", renderLineMark);
3637
3710
  registerMarkRenderer("area", renderAreaMark);
3638
3711
  registerMarkRenderer("rect", renderRectMark);
3639
3712
  registerMarkRenderer("arc", renderArcMark);
3640
3713
  registerMarkRenderer("point", renderPointMark);
3714
+ registerMarkRenderer("textMark", renderTextMark);
3715
+ registerMarkRenderer("rule", renderRuleMark);
3716
+ registerMarkRenderer("tick", renderTickMark);
3641
3717
  function getMarkSeries(mark) {
3642
3718
  if (mark.type === "line" || mark.type === "area") {
3643
3719
  return mark.seriesKey;
@@ -3974,10 +4050,10 @@ function renderBrand(parent, layout) {
3974
4050
  setAttrs(text, {
3975
4051
  x: rightEdge,
3976
4052
  y: chromeY,
4053
+ "dominant-baseline": "hanging",
3977
4054
  "font-family": layout.theme.fonts.family,
3978
4055
  "font-size": BRAND_FONT_SIZE,
3979
4056
  "text-anchor": "end",
3980
- "dominant-baseline": "hanging",
3981
4057
  "fill-opacity": 0.55
3982
4058
  });
3983
4059
  text.style.setProperty("fill", fill);
@@ -3997,7 +4073,13 @@ function renderChartSVG(layout, container) {
3997
4073
  const svg = createSVGElement("svg");
3998
4074
  setAttrs(svg, {
3999
4075
  viewBox: `0 0 ${width} ${height}`,
4000
- 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"
4001
4083
  });
4002
4084
  svg.style.height = `${height}px`;
4003
4085
  svg.setAttribute("role", layout.a11y.role);
@@ -4030,6 +4112,23 @@ function renderChartSVG(layout, container) {
4030
4112
  const clippedGroup = createSVGElement("g");
4031
4113
  clippedGroup.setAttribute("clip-path", `url(#${clipId})`);
4032
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
+ }
4033
4132
  svg.appendChild(clippedGroup);
4034
4133
  renderAnnotations(svg, layout);
4035
4134
  renderLegend(svg, layout.legend);
@@ -4120,6 +4219,89 @@ function wireTooltipEvents(svg, tooltipDescriptors, tooltipManager) {
4120
4219
  }
4121
4220
  };
4122
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
+ }
4123
4305
  function buildMarkDataMap(layout) {
4124
4306
  const map = /* @__PURE__ */ new Map();
4125
4307
  for (let i = 0; i < layout.marks.length; i++) {
@@ -4448,10 +4630,10 @@ function wireConnectorEndpointDrag(svg, specAnnotations, onEdit, setDragging) {
4448
4630
  if (!connectorLine && !curvedPath) continue;
4449
4631
  let fromX, fromY, toX, toY;
4450
4632
  if (connectorLine) {
4451
- fromX = Number(connectorLine.getAttribute("x1"));
4452
- fromY = Number(connectorLine.getAttribute("y1"));
4453
- toX = Number(connectorLine.getAttribute("x2"));
4454
- 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;
4455
4637
  } else {
4456
4638
  const pathD = curvedPath.getAttribute("d") ?? "";
4457
4639
  const mMatch = pathD.match(/M\s*([\d.e+-]+)\s+([\d.e+-]+)/);
@@ -4461,8 +4643,8 @@ function wireConnectorEndpointDrag(svg, specAnnotations, onEdit, setDragging) {
4461
4643
  const points = arrowhead?.getAttribute("points") ?? "";
4462
4644
  const firstPoint = points.split(" ")[0] ?? "0,0";
4463
4645
  const [px, py] = firstPoint.split(",");
4464
- toX = Number(px);
4465
- toY = Number(py);
4646
+ toX = Number(px) || 0;
4647
+ toY = Number(py) || 0;
4466
4648
  }
4467
4649
  const endpoints = [
4468
4650
  { name: "from", cx: fromX, cy: fromY },
@@ -4470,6 +4652,7 @@ function wireConnectorEndpointDrag(svg, specAnnotations, onEdit, setDragging) {
4470
4652
  ];
4471
4653
  const createdHandles = [];
4472
4654
  for (const ep of endpoints) {
4655
+ if (!Number.isFinite(ep.cx) || !Number.isFinite(ep.cy)) continue;
4473
4656
  const handleEl = document.createElementNS(SVG_NS2, "circle");
4474
4657
  handleEl.setAttribute("class", "viz-connector-handle");
4475
4658
  handleEl.setAttribute("data-endpoint", ep.name);
@@ -4906,6 +5089,7 @@ function createChart(container, spec, options) {
4906
5089
  let tooltipManager = null;
4907
5090
  let disconnectResize = null;
4908
5091
  let cleanupTooltipEvents = null;
5092
+ let cleanupVoronoiEvents = null;
4909
5093
  let cleanupKeyboardNav = null;
4910
5094
  let cleanupLegend = null;
4911
5095
  let cleanupChartEvents = null;
@@ -4927,6 +5111,9 @@ function createChart(container, spec, options) {
4927
5111
  darkMode,
4928
5112
  measureText
4929
5113
  };
5114
+ if (isLayerSpec(currentSpec)) {
5115
+ return compileLayer(currentSpec, compileOpts);
5116
+ }
4930
5117
  return compileChart(currentSpec, compileOpts);
4931
5118
  }
4932
5119
  function getContainerDimensions() {
@@ -4945,6 +5132,10 @@ function createChart(container, spec, options) {
4945
5132
  cleanupTooltipEvents();
4946
5133
  cleanupTooltipEvents = null;
4947
5134
  }
5135
+ if (cleanupVoronoiEvents) {
5136
+ cleanupVoronoiEvents();
5137
+ cleanupVoronoiEvents = null;
5138
+ }
4948
5139
  if (cleanupKeyboardNav) {
4949
5140
  cleanupKeyboardNav();
4950
5141
  cleanupKeyboardNav = null;
@@ -4983,6 +5174,7 @@ function createChart(container, spec, options) {
4983
5174
  currentLayout.tooltipDescriptors,
4984
5175
  tooltipManager
4985
5176
  );
5177
+ cleanupVoronoiEvents = wireVoronoiTooltipEvents(svgElement, currentLayout, tooltipManager);
4986
5178
  cleanupKeyboardNav = wireKeyboardNav(
4987
5179
  svgElement,
4988
5180
  container,
@@ -5025,9 +5217,10 @@ function createChart(container, spec, options) {
5025
5217
  editCleanups.push(
5026
5218
  wireAnnotationLabelDrag(svgElement, dragAnnotations, options.onEdit, setDragging)
5027
5219
  );
5028
- editCleanups.push(wireChromeDrag(svgElement, currentSpec, options.onEdit, setDragging));
5029
- editCleanups.push(wireLegendDrag(svgElement, currentSpec, options.onEdit, setDragging));
5030
- 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));
5031
5224
  cleanupEditDrags = () => {
5032
5225
  for (const cleanup of editCleanups) {
5033
5226
  cleanup();
@@ -5084,6 +5277,10 @@ function createChart(container, spec, options) {
5084
5277
  cleanupTooltipEvents();
5085
5278
  cleanupTooltipEvents = null;
5086
5279
  }
5280
+ if (cleanupVoronoiEvents) {
5281
+ cleanupVoronoiEvents();
5282
+ cleanupVoronoiEvents = null;
5283
+ }
5087
5284
  if (cleanupKeyboardNav) {
5088
5285
  cleanupKeyboardNav();
5089
5286
  cleanupKeyboardNav = null;