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);
+ });
+ });
});