From 2d4417778ebbb518f835b089af0c69f3dc7764d5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 19 Nov 2025 11:16:52 +0000 Subject: [PATCH 01/13] Initial plan From 831063543b2a2c155b6f52af514860c11f2e2200 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 19 Nov 2025 11:24:24 +0000 Subject: [PATCH 02/13] Add addCustomMarker API for registering custom SVG markers Co-authored-by: gatopeich <7722268+gatopeich@users.noreply.github.com> --- src/components/drawing/index.js | 68 ++++++++++++++++- src/core.js | 6 ++ test/jasmine/tests/drawing_test.js | 111 ++++++++++++++++++++++++++++ test/jasmine/tests/plot_api_test.js | 7 ++ 4 files changed, 190 insertions(+), 2 deletions(-) diff --git a/src/components/drawing/index.js b/src/components/drawing/index.js index 38e8686d102..6d687af1a00 100644 --- a/src/components/drawing/index.js +++ b/src/components/drawing/index.js @@ -366,10 +366,72 @@ Object.keys(SYMBOLDEFS).forEach(function (k) { } }); -var MAXSYMBOL = drawing.symbolNames.length; // add a dot in the middle of the symbol var DOTPATH = 'M0,0.5L0.5,0L0,-0.5L-0.5,0Z'; +/** + * Add a custom marker symbol + * + * @param {string} name: the name of the new marker symbol + * @param {function} drawFunc: a function(r, angle, standoff) that returns an SVG path string + * @param {object} opts: optional configuration object + * - backoff {number}: backoff distance for this symbol (default: 0) + * - needLine {boolean}: whether this symbol needs a line (default: false) + * - noDot {boolean}: whether to skip creating -dot variants (default: false) + * - noFill {boolean}: whether this symbol should not be filled (default: false) + * + * @return {number}: the symbol number assigned to the new marker, or existing number if already registered + */ +drawing.addCustomMarker = function(name, drawFunc, opts) { + opts = opts || {}; + + // Check if marker already exists + var existingIndex = drawing.symbolNames.indexOf(name); + if(existingIndex >= 0) { + return existingIndex; + } + + // Get the next available symbol number + var n = drawing.symbolNames.length; + + // Add to symbolList (base and -open variants) + drawing.symbolList.push( + n, + String(n), + name, + n + 100, + String(n + 100), + name + '-open' + ); + + // Register the symbol + drawing.symbolNames[n] = name; + drawing.symbolFuncs[n] = drawFunc; + drawing.symbolBackOffs[n] = opts.backoff || 0; + + if(opts.needLine) { + drawing.symbolNeedLines[n] = true; + } + if(opts.noDot) { + drawing.symbolNoDot[n] = true; + } else { + // Add -dot and -open-dot variants + drawing.symbolList.push( + n + 200, + String(n + 200), + name + '-dot', + n + 300, + String(n + 300), + name + '-open-dot' + ); + } + if(opts.noFill) { + drawing.symbolNoFill[n] = true; + } + + return n; +}; + drawing.symbolNumber = function (v) { if (isNumeric(v)) { v = +v; @@ -389,7 +451,9 @@ drawing.symbolNumber = function (v) { } } - return v % 100 >= MAXSYMBOL || v >= 400 ? 0 : Math.floor(Math.max(v, 0)); + // Use dynamic length instead of MAXSYMBOL constant + var maxSymbol = drawing.symbolNames.length; + return v % 100 >= maxSymbol || v >= 400 ? 0 : Math.floor(Math.max(v, 0)); }; function makePointPath(symbolNumber, r, t, s) { diff --git a/src/core.js b/src/core.js index 99d86862ef6..716b1211d35 100644 --- a/src/core.js +++ b/src/core.js @@ -81,3 +81,9 @@ exports.Fx = { }; exports.Snapshot = require('./snapshot'); exports.PlotSchema = require('./plot_api/plot_schema'); + +// expose Drawing methods for custom marker registration +var Drawing = require('./components/drawing'); +exports.Drawing = { + addCustomMarker: Drawing.addCustomMarker +}; diff --git a/test/jasmine/tests/drawing_test.js b/test/jasmine/tests/drawing_test.js index def7497704b..4c5b7ed9adc 100644 --- a/test/jasmine/tests/drawing_test.js +++ b/test/jasmine/tests/drawing_test.js @@ -573,4 +573,115 @@ describe('gradients', function() { done(); }, done.fail); }); + + describe('addCustomMarker', function() { + it('should register a new custom marker symbol', function() { + var initialLength = Drawing.symbolNames.length; + + var customFunc = function(r) { + return 'M' + r + ',0L0,' + r + 'L-' + r + ',0L0,-' + r + 'Z'; + }; + + var symbolNumber = Drawing.addCustomMarker('my-custom-marker', customFunc); + + expect(symbolNumber).toBe(initialLength); + expect(Drawing.symbolNames[symbolNumber]).toBe('my-custom-marker'); + expect(Drawing.symbolFuncs[symbolNumber]).toBe(customFunc); + expect(Drawing.symbolNames.length).toBe(initialLength + 1); + }); + + it('should return existing symbol number if marker already registered', function() { + var customFunc = function(r) { + return 'M' + r + ',0L0,' + r + 'L-' + r + ',0L0,-' + r + 'Z'; + }; + + var firstAdd = Drawing.addCustomMarker('my-marker-2', customFunc); + var secondAdd = Drawing.addCustomMarker('my-marker-2', customFunc); + + expect(firstAdd).toBe(secondAdd); + }); + + it('should add marker to symbolList with variants', function() { + var initialListLength = Drawing.symbolList.length; + var customFunc = function(r) { + return 'M0,0L' + r + ',0'; + }; + + var symbolNumber = Drawing.addCustomMarker('my-marker-3', customFunc); + + // Should add 6 entries: n, String(n), name, n+100, String(n+100), name-open + // Plus 6 more for dot variants if noDot is not set + expect(Drawing.symbolList.length).toBeGreaterThan(initialListLength); + expect(Drawing.symbolList).toContain('my-marker-3'); + expect(Drawing.symbolList).toContain('my-marker-3-open'); + expect(Drawing.symbolList).toContain('my-marker-3-dot'); + expect(Drawing.symbolList).toContain('my-marker-3-open-dot'); + }); + + it('should respect noDot option', function() { + var customFunc = function(r) { + return 'M0,0L' + r + ',0'; + }; + + Drawing.addCustomMarker('my-marker-4', customFunc, {noDot: true}); + + expect(Drawing.symbolList).toContain('my-marker-4'); + expect(Drawing.symbolList).toContain('my-marker-4-open'); + expect(Drawing.symbolList).not.toContain('my-marker-4-dot'); + expect(Drawing.symbolList).not.toContain('my-marker-4-open-dot'); + }); + + it('should allow using custom marker in scatter plot', function(done) { + var customFunc = function(r) { + return 'M' + r + ',0L0,' + r + 'L-' + r + ',0L0,-' + r + 'Z'; + }; + + Drawing.addCustomMarker('my-scatter-marker', customFunc); + + Plotly.newPlot(gd, [{ + type: 'scatter', + x: [1, 2, 3], + y: [2, 3, 4], + mode: 'markers', + marker: { + symbol: 'my-scatter-marker', + 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 marker symbol variants', function(done) { + var customFunc = function(r) { + return 'M' + r + ',0L0,' + r + 'L-' + r + ',0L0,-' + r + 'Z'; + }; + + Drawing.addCustomMarker('my-variant-marker', customFunc); + + Plotly.newPlot(gd, [{ + type: 'scatter', + x: [1, 2, 3], + y: [2, 3, 4], + mode: 'markers', + marker: { + symbol: ['my-variant-marker', 'my-variant-marker-open', 'my-variant-marker-dot'], + size: 12 + } + }]) + .then(function() { + var points = d3Select(gd).selectAll('.point'); + expect(points.size()).toBe(3); + }) + .then(done, done.fail); + }); + }); }); diff --git a/test/jasmine/tests/plot_api_test.js b/test/jasmine/tests/plot_api_test.js index d962aab7e1b..5fc8dd0b124 100644 --- a/test/jasmine/tests/plot_api_test.js +++ b/test/jasmine/tests/plot_api_test.js @@ -31,6 +31,13 @@ describe('Test plot api', function () { }); }); + describe('Plotly.Drawing', function () { + it('should expose addCustomMarker method', function () { + expect(typeof Plotly.Drawing).toBe('object'); + expect(typeof Plotly.Drawing.addCustomMarker).toBe('function'); + }); + }); + describe('Plotly.newPlot', function () { var gd; From 343dd6c830aa996b4449f972b066ee0df543d7ed Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 19 Nov 2025 11:25:35 +0000 Subject: [PATCH 03/13] Add demo file for custom marker feature Co-authored-by: gatopeich <7722268+gatopeich@users.noreply.github.com> --- devtools/custom_marker_demo.html | 225 +++++++++++++++++++++++++++++++ 1 file changed, 225 insertions(+) create mode 100644 devtools/custom_marker_demo.html diff --git a/devtools/custom_marker_demo.html b/devtools/custom_marker_demo.html new file mode 100644 index 00000000000..56a30896473 --- /dev/null +++ b/devtools/custom_marker_demo.html @@ -0,0 +1,225 @@ + + +
+ +Plotly.Drawing.addCustomMarker(name, drawFunc, opts)
+ // Define a custom heart-shaped marker
+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';
+}
+
+// Define a custom star marker
+function star5Marker(r, angle, standoff) {
+ 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;
+}
+
+// Register the custom markers
+Plotly.Drawing.addCustomMarker('heart', heartMarker);
+Plotly.Drawing.addCustomMarker('star5', star5Marker);
+
+// Use them in a plot
+Plotly.newPlot('plot1', [{
+ x: [1, 2, 3, 4, 5],
+ y: [2, 3, 4, 3, 2],
+ mode: 'markers',
+ marker: {
+ symbol: ['heart', 'star5', 'heart-open', 'star5-open', 'heart-dot'],
+ size: 20,
+ color: ['red', 'gold', 'pink', 'orange', 'crimson']
+ }
+}]);
+ Plotly.Drawing.addCustomMarker(name, drawFunc, opts) + +Parameters: + - name (string): The name of the new marker symbol + - drawFunc (function): A function(r, angle, standoff) that returns an SVG path string + - opts (object, optional): Configuration options + - backoff (number): Backoff distance for this symbol (default: 0) + - needLine (boolean): Whether this symbol needs a line (default: false) + - noDot (boolean): Whether to skip creating -dot variants (default: false) + - noFill (boolean): Whether this symbol should not be filled (default: false) + +Returns: + - (number): The symbol number assigned to the new marker + +Marker Variants: + Automatically creates these variants (unless noDot is true): + - 'name' : Base marker (filled) + - 'name-open' : Open marker (outline only) + - 'name-dot' : Marker with dot in center + - 'name-open-dot' : Open marker with dot in center+
function(r)function(r, customdata)
+ Custom marker functions with data-driven symbols: sun, cloud, and wind arrows.
+ +marker.symbol to create custom shapes.function(r) — r is marker radiusfunction(r, customdata) — access per-point data
+ Define functions returning SVG path strings. Mix with built-in symbols.
+ +
+// Heart shape
+function heart(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';
+}
+
+// 5-point star
+function star(r) {
+ var path = 'M';
+ for (var i = 0; i < 10; i++) {
+ var rad = i % 2 === 0 ? r : r * 0.4;
+ var ang = i * Math.PI / 5 - Math.PI / 2;
+ path += (i ? 'L' : '') + (rad * Math.cos(ang)).toFixed(2) + ',' + (rad * Math.sin(ang)).toFixed(2);
+ }
+ return path + 'Z';
+}
+
+Plotly.newPlot('plot1', [{
+ x: [1, 2, 3, 4, 5],
+ y: [2, 3, 4, 3, 2],
+ mode: 'markers+lines',
+ marker: {
+ symbol: [heart, star, 'circle', star, heart], // mix functions and strings
+ size: 25,
+ color: ['red', 'gold', 'blue', 'gold', 'red']
+ }
+}]);
+
+
+ Access customdata[i] to vary shape per point.
+function shapeByData(r, customdata) {
+ if (customdata === 'star') {
+ // Star shape
+ var path = 'M';
+ for (var i = 0; i < 10; i++) {
+ var rad = i % 2 === 0 ? r : r * 0.4;
+ var ang = i * Math.PI / 5 - Math.PI / 2;
+ path += (i ? 'L' : '') + (rad * Math.cos(ang)).toFixed(2) + ',' + (rad * Math.sin(ang)).toFixed(2);
+ }
+ return path + 'Z';
+ }
+ if (customdata === 'big') {
+ r *= 1.4; // Larger diamond
+ }
+ // Default: diamond
+ return 'M' + r + ',0L0,' + r + 'L-' + r + ',0L0,-' + r + 'Z';
+}
+
+Plotly.newPlot('plot2', [{
+ x: [1, 2, 3, 4],
+ y: [1, 1, 1, 1],
+ customdata: ['normal', 'big', 'star', 'normal'],
+ mode: 'markers',
+ marker: { symbol: shapeByData, size: 25, color: '#10b981' }
+}]);
+
+
+ Complex example: sun, cloud, and wind barbs based on weather data.
+ +
+function weatherMarker(r, data) {
+ if (data.type === 'sunny') {
+ // Sun: circle with rays
+ var cr = r * 0.5, path = 'M' + cr + ',0A' + cr + ',' + cr + ' 0 1,1 -' + cr + ',0A' + cr + ',' + cr + ' 0 1,1 ' + cr + ',0';
+ for (var i = 0; i < 8; i++) {
+ var ang = i * Math.PI / 4;
+ path += 'M' + ((cr+2) * Math.cos(ang)).toFixed(1) + ',' + ((cr+2) * Math.sin(ang)).toFixed(1) +
+ 'L' + ((cr+r*0.4) * Math.cos(ang)).toFixed(1) + ',' + ((cr+r*0.4) * Math.sin(ang)).toFixed(1);
+ }
+ return path;
+ }
+ if (data.type === 'cloudy') {
+ return 'M-8,3 A6,6 0 1,1 -2,-4 A7,7 0 1,1 8,-2 A5,5 0 1,1 10,3 Z';
+ }
+ if (data.type === 'wind') {
+ // Wind barb: staff + barbs based on speed
+ var path = 'M0,' + r + 'L0,-' + r, y = -r;
+ for (var i = 0; i < Math.min(data.speed, 3); i++) {
+ path += 'M0,' + y + 'L' + (r*0.6) + ',' + (y + r*0.3);
+ y += r * 0.3;
+ }
+ return path;
+ }
+ return 'M' + r + ',0A' + r + ',' + r + ' 0 1,0 -' + r + ',0A' + r + ',' + r + ' 0 1,0 ' + r + ',0';
+}
+
+var locations = [
+ { name: 'Seattle', lon: -122, lat: 47, weather: { type: 'cloudy' } },
+ { name: 'SF', lon: -122, lat: 38, weather: { type: 'sunny' } },
+ { name: 'Denver', lon: -105, lat: 40, weather: { type: 'sunny' } },
+ { name: 'Chicago', lon: -88, lat: 42, weather: { type: 'cloudy' } },
+ { name: 'NYC', lon: -74, lat: 41, weather: { type: 'cloudy' } },
+ { name: 'Miami', lon: -80, lat: 26, weather: { type: 'sunny' } },
+ // Wind arrows (jet stream)
+ { lon: -115, lat: 46, weather: { type: 'wind', direction: 100, speed: 3 } },
+ { lon: -100, lat: 42, weather: { type: 'wind', direction: 120, speed: 2 } },
+ { lon: -85, lat: 36, weather: { type: 'wind', direction: 150, speed: 1 } }
+];
+
+Plotly.newPlot('plot3', [{
+ x: locations.map(l => l.lon),
+ y: locations.map(l => l.lat),
+ customdata: locations.map(l => l.weather),
+ text: locations.map(l => l.name || ''),
+ mode: 'markers+text',
+ textposition: 'bottom center',
+ marker: {
+ symbol: weatherMarker,
+ size: 30,
+ color: locations.map(l => ({ sunny: '#FFD700', cloudy: '#708090', wind: '#4169E1' }[l.weather.type])),
+ angle: locations.map(l => l.weather.direction || 0)
+ }
+}], { xaxis: { range: [-130, -70] }, yaxis: { range: [20, 52], scaleanchor: 'x' } });
+
+
+
+