diff --git a/CUSTOM_MARKER_FUNCTIONS.md b/CUSTOM_MARKER_FUNCTIONS.md new file mode 100644 index 00000000000..e9724ee6697 --- /dev/null +++ b/CUSTOM_MARKER_FUNCTIONS.md @@ -0,0 +1,195 @@ +# Custom Marker Functions + +This document describes how to use custom SVG marker functions in plotly.js scatter plots. + +## Overview + +You can now pass a custom function directly as the `marker.symbol` value to create custom marker shapes. This provides a simple, flexible way to extend the built-in marker symbols without any registration required. + +## Usage + +### Basic Example + +```javascript +// Define a custom marker function +function heartMarker(r, angle, standoff) { + var x = r * 0.6; + var y = r * 0.8; + return 'M0,' + (-y/2) + + 'C' + (-x) + ',' + (-y) + ' ' + (-x*2) + ',' + (-y/3) + ' ' + (-x*2) + ',0' + + 'C' + (-x*2) + ',' + (y/2) + ' 0,' + (y) + ' 0,' + (y*1.5) + + 'C0,' + (y) + ' ' + (x*2) + ',' + (y/2) + ' ' + (x*2) + ',0' + + 'C' + (x*2) + ',' + (-y/3) + ' ' + (x) + ',' + (-y) + ' 0,' + (-y/2) + 'Z'; +} + +// Use it directly in a plot +Plotly.newPlot('myDiv', [{ + type: 'scatter', + x: [1, 2, 3, 4, 5], + y: [2, 3, 4, 3, 2], + mode: 'markers', + marker: { + symbol: heartMarker, // Pass the function directly! + size: 15, + color: 'red' + } +}]); +``` + +### Multiple Custom Markers + +You can use different custom markers for different points by passing an array: + +```javascript +function heartMarker(r) { + var x = r * 0.6, y = r * 0.8; + return 'M0,' + (-y/2) + 'C...Z'; +} + +function starMarker(r) { + var points = 5; + var outerRadius = r; + var innerRadius = r * 0.4; + var path = 'M'; + + for (var i = 0; i < points * 2; i++) { + var radius = i % 2 === 0 ? outerRadius : innerRadius; + var ang = (i * Math.PI) / points - Math.PI / 2; + var x = radius * Math.cos(ang); + var y = radius * Math.sin(ang); + path += (i === 0 ? '' : 'L') + x.toFixed(2) + ',' + y.toFixed(2); + } + path += 'Z'; + return path; +} + +Plotly.newPlot('myDiv', [{ + type: 'scatter', + x: [1, 2, 3, 4, 5], + y: [2, 3, 4, 3, 2], + mode: 'markers', + marker: { + symbol: [heartMarker, starMarker, heartMarker, starMarker, heartMarker], + size: 18, + color: ['red', 'gold', 'pink', 'orange', 'crimson'] + } +}]); +``` + +### Mixing with Built-in Symbols + +Custom functions work seamlessly with built-in symbol names: + +```javascript +function customDiamond(r) { + var rd = r * 1.5; + return 'M' + rd + ',0L0,' + rd + 'L-' + rd + ',0L0,-' + rd + 'Z'; +} + +Plotly.newPlot('myDiv', [{ + type: 'scatter', + x: [1, 2, 3, 4], + y: [1, 2, 3, 4], + mode: 'markers', + marker: { + symbol: ['circle', customDiamond, 'square', customDiamond], + size: 15 + } +}]); +``` + +## Function Signature + +Your custom marker function should have the following signature: + +```javascript +function customMarker(r, angle, standoff) { + // r: radius/size of the marker + // angle: rotation angle in degrees (for directional markers) + // standoff: standoff distance from the point (for advanced use) + + // Return an SVG path string + return 'M...Z'; +} +``` + +### Parameters + +- **r** (number): The radius/size of the marker. Your path should scale proportionally with this value. +- **angle** (number, optional): The rotation angle in degrees. Most simple markers can ignore this. +- **standoff** (number, optional): The standoff distance. Most markers can ignore this. + +### Return Value + +The function must return a valid SVG path string. The path should: +- Be centered at (0, 0) +- Scale proportionally with the radius `r` +- Use standard SVG path commands (M, L, C, Q, A, Z, etc.) + +## SVG Path Commands + +Here are the common SVG path commands you can use: + +- `M x,y`: Move to absolute position (x, y) +- `m dx,dy`: Move to relative position (dx, dy) +- `L x,y`: Line to absolute position +- `l dx,dy`: Line to relative position +- `H x`: Horizontal line to x +- `h dx`: Horizontal line by dx +- `V y`: Vertical line to y +- `v dy`: Vertical line by dy +- `C x1,y1 x2,y2 x,y`: Cubic Bézier curve +- `Q x1,y1 x,y`: Quadratic Bézier curve +- `A rx,ry rotation large-arc sweep x,y`: Elliptical arc +- `Z`: Close path + +## Examples + +### Simple Triangle + +```javascript +function triangleMarker(r) { + var h = r * 1.5; + return 'M0,-' + h + 'L' + r + ',' + (h/2) + 'L-' + r + ',' + (h/2) + 'Z'; +} +``` + +### Pentagon + +```javascript +function pentagonMarker(r) { + var points = 5; + var path = 'M'; + for (var i = 0; i < points; i++) { + var angle = (i * 2 * Math.PI / points) - Math.PI / 2; + var x = r * Math.cos(angle); + var y = r * Math.sin(angle); + path += (i === 0 ? '' : 'L') + x.toFixed(2) + ',' + y.toFixed(2); + } + return path + 'Z'; +} +``` + +### Arrow + +```javascript +function arrowMarker(r) { + var headWidth = r; + var headLength = r * 1.5; + return 'M0,-' + headLength + + 'L-' + headWidth + ',0' + + 'L' + headWidth + ',0Z'; +} +``` + +## Notes + +- Custom marker functions work with all marker styling options (color, size, line, etc.) +- The function is called for each point that uses it +- Functions are passed through as-is and not stored in any registry +- This approach is simpler than the registration-based API +- For best performance, define your functions once outside the plot call + +## Browser Compatibility + +Custom marker functions work in all browsers that support plotly.js and SVG path rendering. diff --git a/devtools/custom_marker_demo.html b/devtools/custom_marker_demo.html new file mode 100644 index 00000000000..0d8269701d2 --- /dev/null +++ b/devtools/custom_marker_demo.html @@ -0,0 +1,172 @@ + + + + + Custom Marker Functions Demo + + + + +
+

Custom Marker Functions Demo

+ +
+ New Feature: You can now pass custom functions directly as + marker.symbol values to create custom marker shapes! +
+ +

Example: Custom Marker Functions

+
+ +

Code:

+
+
// Define custom marker functions
+function heartMarker(r) {
+    var x = r * 0.6, y = r * 0.8;
+    return 'M0,' + (-y/2) + 
+           'C' + (-x) + ',' + (-y) + ' ' + (-x*2) + ',' + (-y/3) + ' ' + (-x*2) + ',0' +
+           'C' + (-x*2) + ',' + (y/2) + ' 0,' + (y) + ' 0,' + (y*1.5) +
+           'C0,' + (y) + ' ' + (x*2) + ',' + (y/2) + ' ' + (x*2) + ',0' +
+           'C' + (x*2) + ',' + (-y/3) + ' ' + (x) + ',' + (-y) + ' 0,' + (-y/2) + 'Z';
+}
+
+function star5Marker(r) {
+    var points = 5, path = 'M';
+    for (var i = 0; i < points * 2; i++) {
+        var radius = i % 2 === 0 ? r : r * 0.4;
+        var ang = (i * Math.PI) / points - Math.PI / 2;
+        path += (i === 0 ? '' : 'L') + 
+                (radius * Math.cos(ang)).toFixed(2) + ',' + 
+                (radius * Math.sin(ang)).toFixed(2);
+    }
+    return path + 'Z';
+}
+
+// Use them directly in a plot
+Plotly.newPlot('plot1', [{
+    x: [1, 2, 3, 4, 5],
+    y: [2, 3, 4, 3, 2],
+    mode: 'markers+lines',
+    marker: {
+        symbol: [heartMarker, star5Marker, 'circle', star5Marker, heartMarker],
+        size: 20,
+        color: ['red', 'gold', 'blue', 'orange', 'crimson']
+    }
+}]);
+
+
+ + + + diff --git a/draftlogs/7653_add.md b/draftlogs/7653_add.md new file mode 100644 index 00000000000..d5bf2455325 --- /dev/null +++ b/draftlogs/7653_add.md @@ -0,0 +1 @@ +- Add custom marker symbol support [#7653](https://github.com/plotly/plotly.js/pull/7653) diff --git a/src/components/drawing/index.js b/src/components/drawing/index.js index 38e8686d102..70759d8879c 100644 --- a/src/components/drawing/index.js +++ b/src/components/drawing/index.js @@ -392,7 +392,13 @@ drawing.symbolNumber = function (v) { return v % 100 >= MAXSYMBOL || v >= 400 ? 0 : Math.floor(Math.max(v, 0)); }; -function makePointPath(symbolNumber, r, t, s) { +function makePointPath(symbolNumberOrFunc, r, t, s) { + // Check if a custom function was passed directly + if (typeof symbolNumberOrFunc === 'function') { + return symbolNumberOrFunc(r, t, s); + } + + var symbolNumber = symbolNumberOrFunc; var base = symbolNumber % 100; return drawing.symbolFuncs[base](r, t, s) + (symbolNumber >= 200 ? DOTPATH : ''); } @@ -914,12 +920,13 @@ drawing.singlePointStyle = function (d, sel, trace, fns, gd, pt) { r = d.mrc = fns.selectedSizeFn(d); } - // turn the symbol into a sanitized number - var x = drawing.symbolNumber(d.mx || marker.symbol) || 0; + // turn the symbol into a sanitized number (or keep function if it's a custom function) + var symbolValue = d.mx || marker.symbol; + var x = typeof symbolValue === 'function' ? symbolValue : (drawing.symbolNumber(symbolValue) || 0); // save if this marker is open // because that impacts how to handle colors - d.om = x % 200 >= 100; + d.om = typeof x === 'number' && x % 200 >= 100; var angle = getMarkerAngle(d, trace); var standoff = getMarkerStandoff(d, trace); @@ -1202,9 +1209,12 @@ drawing.selectedPointStyle = function (s, trace) { var mx = d.mx || marker.symbol || 0; var mrc2 = fns.selectedSizeFn(d); + // Handle both function and string/number symbols + var symbolForPath = typeof mx === 'function' ? mx : drawing.symbolNumber(mx); + pt.attr( 'd', - makePointPath(drawing.symbolNumber(mx), mrc2, getMarkerAngle(d, trace), getMarkerStandoff(d, trace)) + makePointPath(symbolForPath, mrc2, getMarkerAngle(d, trace), getMarkerStandoff(d, trace)) ); // save for Drawing.selectedTextStyle @@ -1496,7 +1506,7 @@ function applyBackoff(pt, start) { var endMarkerSize = endMarker.size; if (Lib.isArrayOrTypedArray(endMarkerSize)) endMarkerSize = endMarkerSize[endI]; - b = endMarker ? drawing.symbolBackOffs[drawing.symbolNumber(endMarkerSymbol)] * endMarkerSize : 0; + b = endMarker && typeof endMarkerSymbol !== 'function' ? (drawing.symbolBackOffs[drawing.symbolNumber(endMarkerSymbol)] || 0) * endMarkerSize : 0; b += drawing.getMarkerStandoff(d[endI], trace) || 0; } diff --git a/test/jasmine/tests/drawing_test.js b/test/jasmine/tests/drawing_test.js index def7497704b..480a296b50d 100644 --- a/test/jasmine/tests/drawing_test.js +++ b/test/jasmine/tests/drawing_test.js @@ -573,4 +573,80 @@ describe('gradients', function() { done(); }, done.fail); }); + + describe('custom marker functions', function() { + it('should accept a function as marker.symbol', function(done) { + var customFunc = function(r) { + return 'M' + r + ',0L0,' + r + 'L-' + r + ',0L0,-' + r + 'Z'; + }; + + Plotly.newPlot(gd, [{ + type: 'scatter', + x: [1, 2, 3], + y: [2, 3, 4], + mode: 'markers', + marker: { + symbol: customFunc, + size: 12 + } + }]) + .then(function() { + var points = d3Select(gd).selectAll('.point'); + expect(points.size()).toBe(3); + + var firstPoint = points.node(); + var path = firstPoint.getAttribute('d'); + expect(path).toContain('M'); + expect(path).toContain('L'); + }) + .then(done, done.fail); + }); + + it('should work with array of functions', function(done) { + var customFunc1 = function(r) { + return 'M' + r + ',0L0,' + r + 'L-' + r + ',0L0,-' + r + 'Z'; + }; + var customFunc2 = function(r) { + return 'M' + r + ',' + r + 'H-' + r + 'V-' + r + 'H' + r + 'Z'; + }; + + Plotly.newPlot(gd, [{ + type: 'scatter', + x: [1, 2, 3], + y: [2, 3, 4], + mode: 'markers', + marker: { + symbol: [customFunc1, customFunc2, customFunc1], + size: 12 + } + }]) + .then(function() { + var points = d3Select(gd).selectAll('.point'); + expect(points.size()).toBe(3); + }) + .then(done, done.fail); + }); + + it('should work mixed with built-in symbols', function(done) { + var customFunc = function(r) { + return 'M' + r + ',0L0,' + r + 'L-' + r + ',0L0,-' + r + 'Z'; + }; + + Plotly.newPlot(gd, [{ + type: 'scatter', + x: [1, 2, 3, 4], + y: [2, 3, 4, 3], + mode: 'markers', + marker: { + symbol: ['circle', customFunc, 'square', customFunc], + size: 12 + } + }]) + .then(function() { + var points = d3Select(gd).selectAll('.point'); + expect(points.size()).toBe(4); + }) + .then(done, done.fail); + }); + }); });