diff --git a/CUSTOM_MARKER_FUNCTIONS.md b/CUSTOM_MARKER_FUNCTIONS.md new file mode 100644 index 00000000000..8e10ead1209 --- /dev/null +++ b/CUSTOM_MARKER_FUNCTIONS.md @@ -0,0 +1,163 @@ +# 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. + +## Function Signature + +Custom marker functions receive: + +```javascript +function customMarker(r, customdata) { + // r: radius/size of the marker (half of marker.size) + // customdata: the value from trace.customdata[i] for this point (optional) + + // Return an SVG path string centered at (0,0) + return 'M...Z'; +} +``` + +**Simple markers** can use just `(r)`: +```javascript +function diamond(r) { + return 'M' + r + ',0L0,' + r + 'L-' + r + ',0L0,-' + r + 'Z'; +} +``` + +**Data-aware markers** use `(r, customdata)`: +```javascript +function categoryMarker(r, customdata) { + if (customdata === 'high') { + return 'M0,-' + r + 'L' + r + ',' + r + 'L-' + r + ',' + r + 'Z'; // up triangle + } + return 'M0,' + r + 'L' + r + ',-' + r + 'L-' + r + ',-' + r + 'Z'; // down triangle +} +``` + +Note: Rotation is handled automatically via `marker.angle` - your function just returns an unrotated path. + +## Usage Examples + +### Basic Example + +```javascript +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'; +} + +Plotly.newPlot('myDiv', [{ + type: 'scatter', + x: [1, 2, 3, 4, 5], + y: [2, 3, 4, 3, 2], + mode: 'markers', + marker: { + symbol: heartMarker, + size: 15, + color: 'red' + } +}]); +``` + +### Multiple Custom Markers + +```javascript +function star(r) { + var path = 'M'; + for (var i = 0; i < 10; i++) { + var radius = i % 2 === 0 ? r : r * 0.4; + var ang = (i * Math.PI) / 5 - Math.PI / 2; + path += (i === 0 ? '' : 'L') + (radius * Math.cos(ang)).toFixed(2) + ',' + (radius * Math.sin(ang)).toFixed(2); + } + return path + 'Z'; +} + +Plotly.newPlot('myDiv', [{ + x: [1, 2, 3, 4, 5], + y: [2, 3, 4, 3, 2], + mode: 'markers', + marker: { + symbol: [heartMarker, star, 'circle', star, heartMarker], + size: 18, + color: ['red', 'gold', 'blue', 'orange', 'crimson'] + } +}]); +``` + +### Data-Driven Markers with customdata + +```javascript +function weatherMarker(r, customdata) { + var weather = customdata; + + if (weather.type === 'sunny') { + // Sun: circle with rays + var cr = r * 0.5; + var path = 'M' + cr + ',0A' + cr + ',' + cr + ' 0 1,1 0,-' + cr + + 'A' + cr + ',' + cr + ' 0 0,1 ' + cr + ',0Z'; + for (var i = 0; i < 8; i++) { + var ang = i * Math.PI / 4; + var x1 = (cr + 2) * Math.cos(ang), y1 = (cr + 2) * Math.sin(ang); + var x2 = (cr + r*0.4) * Math.cos(ang), y2 = (cr + r*0.4) * Math.sin(ang); + path += 'M' + x1.toFixed(2) + ',' + y1.toFixed(2) + 'L' + x2.toFixed(2) + ',' + y2.toFixed(2); + } + return path; + } + + if (weather.type === 'cloudy') { + var cy = r * 0.2; + return 'M' + (-r*0.6) + ',' + cy + + 'A' + (r*0.35) + ',' + (r*0.35) + ' 0 1,1 ' + (-r*0.1) + ',' + (-cy) + + 'A' + (r*0.4) + ',' + (r*0.4) + ' 0 1,1 ' + (r*0.5) + ',' + (-cy*0.5) + + 'A' + (r*0.3) + ',' + (r*0.3) + ' 0 1,1 ' + (r*0.7) + ',' + cy + + 'L' + (-r*0.6) + ',' + cy + 'Z'; + } + + // Default: circle + return 'M' + r + ',0A' + r + ',' + r + ' 0 1,1 0,-' + r + 'A' + r + ',' + r + ' 0 0,1 ' + r + ',0Z'; +} + +Plotly.newPlot('myDiv', [{ + type: 'scatter', + x: [-122.4, -118.2, -87.6], + y: [37.8, 34.1, 41.9], + customdata: [ + { type: 'sunny' }, + { type: 'cloudy' }, + { type: 'sunny' } + ], + mode: 'markers', + marker: { + symbol: weatherMarker, + size: 30, + color: ['#FFD700', '#708090', '#FFD700'] + } +}]); +``` + +## SVG Path Commands + +Common SVG path commands: + +- `M x,y`: Move to (x, y) +- `L x,y`: Line to (x, y) +- `H x`: Horizontal line to x +- `V y`: Vertical line to y +- `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 + +## Notes + +- Custom marker functions work with all marker styling options (color, size, line, etc.) +- The function is called for each point that uses it +- Rotation is handled via `marker.angle` - your function returns an unrotated path +- For best performance, define functions once outside the plot call 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/devtools/demos/backward_compatibility_test.html b/devtools/demos/backward_compatibility_test.html new file mode 100644 index 00000000000..c92d37a7dd8 --- /dev/null +++ b/devtools/demos/backward_compatibility_test.html @@ -0,0 +1,96 @@ + + + + + Custom Markers - With and Without Customdata + + + + +

Custom Markers - With and Without Customdata

+
+ Simple markers use function(r)
+ Data-aware markers use function(r, customdata) +
+
+ + + + diff --git a/devtools/demos/weather_map_demo.html b/devtools/demos/weather_map_demo.html new file mode 100644 index 00000000000..5a152378871 --- /dev/null +++ b/devtools/demos/weather_map_demo.html @@ -0,0 +1,143 @@ + + + + + Weather Map Demo - Custom Marker Functions + + + + +

Weather Map Demo

+

Custom marker functions with data-driven symbols: sun, cloud, and wind arrows.

+
+
+ Legend: ☀️ Sunny | ☁️ Cloudy | 🌬️ Wind (more barbs = stronger) +
+ + + + 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..3622450246c 100644 --- a/src/components/drawing/index.js +++ b/src/components/drawing/index.js @@ -392,7 +392,16 @@ 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, d) { + // Check if a custom function was passed directly + if (typeof symbolNumberOrFunc === 'function') { + // Custom functions receive (r, customdata) and return an unrotated path. + // Rotation and standoff are applied automatically via align(). + var path = symbolNumberOrFunc(r, d.data); + return SYMBOLDEFS.align(t, s, path); + } + + var symbolNumber = symbolNumberOrFunc; var base = symbolNumber % 100; return drawing.symbolFuncs[base](r, t, s) + (symbolNumber >= 200 ? DOTPATH : ''); } @@ -914,17 +923,18 @@ 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); - sel.attr('d', makePointPath(x, r, angle, standoff)); + sel.attr('d', makePointPath(x, r, angle, standoff, d)); } var perPointGradient = false; @@ -1202,9 +1212,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), d) ); // save for Drawing.selectedTextStyle @@ -1496,7 +1509,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/src/components/drawing/symbol_defs.js b/src/components/drawing/symbol_defs.js index 3aab9be891c..d450ccf81ac 100644 --- a/src/components/drawing/symbol_defs.js +++ b/src/components/drawing/symbol_defs.js @@ -811,3 +811,6 @@ function align(angle, standoff, path) { return str; } + +// Export align for use with custom marker functions +module.exports.align = align; diff --git a/src/lib/coerce.js b/src/lib/coerce.js index 9fa59255bc1..162344de7dc 100644 --- a/src/lib/coerce.js +++ b/src/lib/coerce.js @@ -64,6 +64,11 @@ exports.valObjectMeta = { requiredOpts: ['values'], otherOpts: ['dflt', 'coerceNumber', 'arrayOk'], coerceFunction: function(v, propOut, dflt, opts) { + // Allow functions to pass through (for custom marker symbols, etc.) + if(typeof v === 'function') { + propOut.set(v); + return; + } if(opts.coerceNumber) v = +v; if(opts.values.indexOf(v) === -1) propOut.set(dflt); else propOut.set(v); diff --git a/test/jasmine/tests/drawing_test.js b/test/jasmine/tests/drawing_test.js index def7497704b..8b48b121863 100644 --- a/test/jasmine/tests/drawing_test.js +++ b/test/jasmine/tests/drawing_test.js @@ -573,4 +573,143 @@ 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); + }); + + it('should pass customdata to custom marker function', function(done) { + var receivedArgs = []; + var customFunc = function(r, customdata) { + receivedArgs.push({ r: r, customdata: customdata }); + 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', + customdata: ['first', 'second', 'third'], + marker: { + symbol: customFunc, + size: 12 + } + }]) + .then(function() { + expect(receivedArgs.length).toBe(3); + + // Verify r is passed + expect(typeof receivedArgs[0].r).toBe('number'); + expect(receivedArgs[0].r).toBe(6); // size/2 + + // Verify customdata values + expect(receivedArgs[0].customdata).toBe('first'); + expect(receivedArgs[1].customdata).toBe('second'); + expect(receivedArgs[2].customdata).toBe('third'); + }) + .then(done, done.fail); + }); + + it('should work with object customdata', function(done) { + var receivedData = []; + var customFunc = function(r, customdata) { + receivedData.push(customdata); + if(customdata && customdata.type === 'big') { + return 'M' + (r*1.5) + ',0L0,' + (r*1.5) + 'L-' + (r*1.5) + ',0L0,-' + (r*1.5) + 'Z'; + } + return 'M' + r + ',0L0,' + r + 'L-' + r + ',0L0,-' + r + 'Z'; + }; + + Plotly.newPlot(gd, [{ + type: 'scatter', + x: [1, 2, 3], + y: [1, 2, 3], + mode: 'markers', + customdata: [{ type: 'small' }, { type: 'big' }, { type: 'small' }], + marker: { + symbol: customFunc, + size: 12 + } + }]) + .then(function() { + expect(receivedData.length).toBe(3); + expect(receivedData[0].type).toBe('small'); + expect(receivedData[1].type).toBe('big'); + expect(receivedData[2].type).toBe('small'); + }) + .then(done, done.fail); + }); + }); });