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/all_demos.html b/devtools/demos/all_demos.html new file mode 100644 index 00000000000..199efc872cc --- /dev/null +++ b/devtools/demos/all_demos.html @@ -0,0 +1,185 @@ + + + + + Custom Markers - SVG path strings (New API) + + + + +

Custom Markers — SVG path strings

+
+ Pass SVG path strings as marker.symbol to create custom shapes.
+ Paths are precomputed at r=20; Plotly scales them by size/20.
+ Use an array for per-point shapes. Rotation uses marker.angle + (applied as transform="rotate()" via SVG <use>). +
+ + +

1. Basic Custom Markers

+

Precomputed SVG paths at r=20. Mix with built-in symbol names.

+
+
+// Heart shape at r=20
+var HEART = 'M0,-8C-12,-16 -24,-5.33 -24,0C-24,8 0,16 0,24C0,16 24,8 24,0C24,-5.33 12,-16 0,-8Z';
+
+// 5-point star at r=20
+var STAR = 'M0,-20L4.70,-6.47L19.02,-6.18L7.61,2.47L11.76,16.18' +
+           'L0,8L-11.76,16.18L-7.61,2.47L-19.02,-6.18L-4.70,-6.47Z';
+
+Plotly.newPlot('plot1', [{
+    x: [1, 2, 3, 4, 5],
+    y: [2, 3, 4, 3, 2],
+    mode: 'markers+lines',
+    marker: {
+        // mix path strings and built-in symbol names
+        symbol: [HEART, STAR, 'circle', STAR, HEART],
+        size: 25,
+        color: ['red', 'gold', 'blue', 'gold', 'red']
+    }
+}]);
+ + +

2. Per-Point Shapes Driven by Data

+

Build a per-point symbol array from your data instead of using a function.

+
+
+var DIAMOND     = 'M20,0L0,20L-20,0L0,-20Z';
+var BIG_DIAMOND = 'M28,0L0,28L-28,0L0,-28Z';  // 1.4× size
+var STAR = '...';  // (same as Demo 1)
+
+var types = ['normal', 'big', 'star', 'normal'];
+var symbols = types.map(function(t) {
+    if (t === 'big')  return BIG_DIAMOND;
+    if (t === 'star') return STAR;
+    return DIAMOND;
+});
+
+Plotly.newPlot('plot2', [{
+    x: [1, 2, 3, 4],
+    y: [1, 1, 1, 1],
+    mode: 'markers',
+    marker: { symbol: symbols, size: 25, color: '#10b981' }
+}]);
+ + +

3. Weather Map with Rotation

+

Per-point symbols + marker.angle for wind direction rotation via SVG.

+
+
+// Paths at r=20 for each weather type
+var SUN_PATH   = 'M10,0A10,10 0 1,1 0,-10A10,10 0 0,1 10,0Z' + /* rays... */;
+var CLOUD_PATH = 'M-12,4A7,7 0 1,1 -2,-4A8,8 0 1,1 10,-2A6,6 0 1,1 14,4L-12,4Z';
+var WIND_PATHS = {
+    1: 'M0,24L0,-24M0,-24L12,-18',
+    2: 'M0,24L0,-24M0,-24L12,-18M0,-18L12,-12',
+    3: 'M0,24L0,-24M0,-24L12,-18M0,-18L12,-12M0,-12L12,-6'
+};
+
+var symbols = locations.map(function(l) {
+    if (l.weather.type === 'sunny')  return SUN_PATH;
+    if (l.weather.type === 'cloudy') return CLOUD_PATH;
+    return WIND_PATHS[l.weather.speed];
+});
+
+Plotly.newPlot('plot3', [{
+    mode: 'markers+text',
+    marker: {
+        symbol: symbols,
+        angle: locations.map(l => l.weather.direction || 0),  // SVG rotate()
+        size: 30   // scale = 30/20 = 1.5×
+    }
+}]);
+ + + + diff --git a/devtools/demos/backward_compatibility_test.html b/devtools/demos/backward_compatibility_test.html new file mode 100644 index 00000000000..bcd2dec375e --- /dev/null +++ b/devtools/demos/backward_compatibility_test.html @@ -0,0 +1,64 @@ + + + + + Custom Markers - Static SVG Paths (New API) + + + + +

Custom Markers - Static SVG Paths at r=20

+
+ New API: marker.symbol accepts SVG path strings precomputed + at r=20. Plotly scales them by size/20. Use an array for per-point shapes.
+ Rotation is applied as transform="rotate()" via marker.angle. +
+
+ + + + diff --git a/devtools/demos/weather_map_demo.html b/devtools/demos/weather_map_demo.html new file mode 100644 index 00000000000..024b11aab03 --- /dev/null +++ b/devtools/demos/weather_map_demo.html @@ -0,0 +1,114 @@ + + + + + Weather Map Demo - SVG Symbol/Use Rendering + + + + +

Weather Map Demo

+

Custom markers using static SVG path strings at r=20, scaled by size/20.

+
+ New API: marker.symbol accepts an array of SVG path strings + precomputed at r=20. Rotation is applied via marker.angle using + transform="rotate()" on each <use> element. +
+
+
+ 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/package-lock.json b/package-lock.json index 5867cccd72f..86427b191a0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43,7 +43,6 @@ "mouse-event-offset": "^3.0.2", "mouse-wheel": "^1.2.0", "native-promise-only": "^0.8.1", - "parse-svg-path": "^0.1.2", "point-in-polygon": "^1.1.0", "polybooljs": "^1.2.2", "probe-image-size": "^7.2.3", @@ -104,6 +103,7 @@ "minify-stream": "^2.1.0", "npm-link-check": "^5.0.1", "open": "^8.4.2", + "parse-svg-path": "^0.1.2", "pixelmatch": "^5.3.0", "prepend-file": "^2.0.1", "prettysize": "^2.0.0", @@ -454,7 +454,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -478,7 +477,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -968,6 +966,7 @@ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", "integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==", "dev": true, + "peer": true, "dependencies": { "@jridgewell/set-array": "^1.2.1", "@jridgewell/sourcemap-codec": "^1.4.10", @@ -982,6 +981,7 @@ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz", "integrity": "sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w==", "dev": true, + "peer": true, "engines": { "node": ">=6.0.0" } @@ -991,6 +991,7 @@ "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", "dev": true, + "peer": true, "engines": { "node": ">=6.0.0" } @@ -1000,6 +1001,7 @@ "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.5.tgz", "integrity": "sha512-UTYAUj/wviwdsMfzoSJspJxbkH5o1snzwX0//0ENX1u/55kkZZkcTZP6u9bwKGkv+dkk9at4m1Cpt0uY80kcpQ==", "dev": true, + "peer": true, "dependencies": { "@jridgewell/gen-mapping": "^0.3.0", "@jridgewell/trace-mapping": "^0.3.9" @@ -1016,6 +1018,7 @@ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", "dev": true, + "peer": true, "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" @@ -1440,7 +1443,8 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==", - "dev": true + "dev": true, + "peer": true }, "node_modules/@types/geojson": { "version": "7946.0.14", @@ -1670,6 +1674,7 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.12.1.tgz", "integrity": "sha512-EKfMUOPRRUTy5UII4qJDGPpqfwjOmZ5jeGFwid9mnoqIFK+e0vqoi1qH56JpmZSzEL53jKnNzScdmftJyG5xWg==", "dev": true, + "peer": true, "dependencies": { "@webassemblyjs/helper-numbers": "1.11.6", "@webassemblyjs/helper-wasm-bytecode": "1.11.6" @@ -1679,25 +1684,29 @@ "version": "1.11.6", "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.11.6.tgz", "integrity": "sha512-ejAj9hfRJ2XMsNHk/v6Fu2dGS+i4UaXBXGemOfQ/JfQ6mdQg/WXtwleQRLLS4OvfDhv8rYnVwH27YJLMyYsxhw==", - "dev": true + "dev": true, + "peer": true }, "node_modules/@webassemblyjs/helper-api-error": { "version": "1.11.6", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.11.6.tgz", "integrity": "sha512-o0YkoP4pVu4rN8aTJgAyj9hC2Sv5UlkzCHhxqWj8butaLvnpdc2jOwh4ewE6CX0txSfLn/UYaV/pheS2Txg//Q==", - "dev": true + "dev": true, + "peer": true }, "node_modules/@webassemblyjs/helper-buffer": { "version": "1.12.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.12.1.tgz", "integrity": "sha512-nzJwQw99DNDKr9BVCOZcLuJJUlqkJh+kVzVl6Fmq/tI5ZtEyWT1KZMyOXltXLZJmDtvLCDgwsyrkohEtopTXCw==", - "dev": true + "dev": true, + "peer": true }, "node_modules/@webassemblyjs/helper-numbers": { "version": "1.11.6", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.11.6.tgz", "integrity": "sha512-vUIhZ8LZoIWHBohiEObxVm6hwP034jwmc9kuq5GdHZH0wiLVLIPcMCdpJzG4C11cHoQ25TFIQj9kaVADVX7N3g==", "dev": true, + "peer": true, "dependencies": { "@webassemblyjs/floating-point-hex-parser": "1.11.6", "@webassemblyjs/helper-api-error": "1.11.6", @@ -1708,13 +1717,15 @@ "version": "1.11.6", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.11.6.tgz", "integrity": "sha512-sFFHKwcmBprO9e7Icf0+gddyWYDViL8bpPjJJl0WHxCdETktXdmtWLGVzoHbqUcY4Be1LkNfwTmXOJUFZYSJdA==", - "dev": true + "dev": true, + "peer": true }, "node_modules/@webassemblyjs/helper-wasm-section": { "version": "1.12.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.12.1.tgz", "integrity": "sha512-Jif4vfB6FJlUlSbgEMHUyk1j234GTNG9dBJ4XJdOySoj518Xj0oGsNi59cUQF4RRMS9ouBUxDDdyBVfPTypa5g==", "dev": true, + "peer": true, "dependencies": { "@webassemblyjs/ast": "1.12.1", "@webassemblyjs/helper-buffer": "1.12.1", @@ -1727,6 +1738,7 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.11.6.tgz", "integrity": "sha512-LM4p2csPNvbij6U1f19v6WR56QZ8JcHg3QIJTlSwzFcmx6WSORicYj6I63f9yU1kEUtrpG+kjkiIAkevHpDXrg==", "dev": true, + "peer": true, "dependencies": { "@xtuc/ieee754": "^1.2.0" } @@ -1736,6 +1748,7 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.11.6.tgz", "integrity": "sha512-m7a0FhE67DQXgouf1tbN5XQcdWoNgaAuoULHIfGFIEVKA6tu/edls6XnIlkmS6FrXAquJRPni3ZZKjw6FSPjPQ==", "dev": true, + "peer": true, "dependencies": { "@xtuc/long": "4.2.2" } @@ -1744,13 +1757,15 @@ "version": "1.11.6", "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.11.6.tgz", "integrity": "sha512-vtXf2wTQ3+up9Zsg8sa2yWiQpzSsMyXj0qViVP6xKGCUT8p8YJ6HqI7l5eCnWx1T/FYdsv07HQs2wTFbbof/RA==", - "dev": true + "dev": true, + "peer": true }, "node_modules/@webassemblyjs/wasm-edit": { "version": "1.12.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.12.1.tgz", "integrity": "sha512-1DuwbVvADvS5mGnXbE+c9NfA8QRcZ6iKquqjjmR10k6o+zzsRVesil54DKexiowcFCPdr/Q0qaMgB01+SQ1u6g==", "dev": true, + "peer": true, "dependencies": { "@webassemblyjs/ast": "1.12.1", "@webassemblyjs/helper-buffer": "1.12.1", @@ -1767,6 +1782,7 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.12.1.tgz", "integrity": "sha512-TDq4Ojh9fcohAw6OIMXqiIcTq5KUXTGRkVxbSo1hQnSy6lAM5GSdfwWeSxpAo0YzgsgF182E/U0mDNhuA0tW7w==", "dev": true, + "peer": true, "dependencies": { "@webassemblyjs/ast": "1.12.1", "@webassemblyjs/helper-wasm-bytecode": "1.11.6", @@ -1780,6 +1796,7 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.12.1.tgz", "integrity": "sha512-Jg99j/2gG2iaz3hijw857AVYekZe2SAskcqlWIZXjji5WStnOpVoat3gQfT/Q5tb2djnCjBtMocY/Su1GfxPBg==", "dev": true, + "peer": true, "dependencies": { "@webassemblyjs/ast": "1.12.1", "@webassemblyjs/helper-buffer": "1.12.1", @@ -1792,6 +1809,7 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.12.1.tgz", "integrity": "sha512-xikIi7c2FHXysxXe3COrVUPSheuBtpcfhbpFj4gmu7KRLYOzANztwUU0IbsqvMqzuNK2+glRGWCEqZo1WCLyAQ==", "dev": true, + "peer": true, "dependencies": { "@webassemblyjs/ast": "1.12.1", "@webassemblyjs/helper-api-error": "1.11.6", @@ -1806,6 +1824,7 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.12.1.tgz", "integrity": "sha512-+X4WAlOisVWQMikjbcvY2e0rwPsKQ9F688lksZhBcPycBBuii3O7m8FACbDMWDojpAqvjIncrG8J0XHKyQfVeA==", "dev": true, + "peer": true, "dependencies": { "@webassemblyjs/ast": "1.12.1", "@xtuc/long": "4.2.2" @@ -1815,13 +1834,15 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", - "dev": true + "dev": true, + "peer": true }, "node_modules/@xtuc/long": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", - "dev": true + "dev": true, + "peer": true }, "node_modules/abs-svg-path": { "version": "0.1.1", @@ -1847,7 +1868,6 @@ "integrity": "sha512-XtGIhXwF8YM8bJhGxG5kXgjkEuNGLTkoYqVE+KMR+aspr4KGYmKYg7yUe3KghyQ9yheNwLnjmzh/7+gfDBmHCQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1888,7 +1908,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -2450,7 +2469,8 @@ "type": "github", "url": "https://github.com/sponsors/ai" } - ] + ], + "peer": true }, "node_modules/canvas": { "version": "3.1.0", @@ -2459,7 +2479,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "node-addon-api": "^7.0.0", "prebuild-install": "^7.1.1" @@ -2613,6 +2632,7 @@ "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.3.tgz", "integrity": "sha512-p3KULyQg4S7NIHixdwbGX+nFHkoBiA4YQmyWtjb8XngSKV124nJmRysgAeujbUVb15vh+RvFUfCPqU7rXk+hZg==", "dev": true, + "peer": true, "engines": { "node": ">=6.0" } @@ -3848,7 +3868,8 @@ "version": "1.5.6", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.6.tgz", "integrity": "sha512-jwXWsM5RPf6j9dPYzaorcBSUg6AiqocPEyMpkchkvntaH9HGfOOMZwxMJjDY/XEs3T5dM7uyH1VhRMkqUU9qVw==", - "dev": true + "dev": true, + "peer": true }, "node_modules/element-size": { "version": "1.1.1", @@ -4005,7 +4026,8 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.2.1.tgz", "integrity": "sha512-9978wrXM50Y4rTMmW5kXIC09ZdXQZqkE4mxhwkd8VbzsGkXGPgV4zWuqQJgCEzYngdo2dYDa0l8xhX4fkSwJSg==", - "dev": true + "dev": true, + "peer": true }, "node_modules/es-object-atoms": { "version": "1.1.1", @@ -4071,7 +4093,6 @@ "integrity": "sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ==", "dev": true, "hasInstallScript": true, - "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -4249,6 +4270,7 @@ "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", "dev": true, + "peer": true, "dependencies": { "estraverse": "^5.2.0" }, @@ -4261,6 +4283,7 @@ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.2.0.tgz", "integrity": "sha512-BxbNGGNm0RyRYvUdHpIwv9IWzeM9XClbOxwoATuFdOE7ZE6wHL+HQ5T8hoPM+zHvmKzzsEqhgy0GrQ5X13afiQ==", "dev": true, + "peer": true, "engines": { "node": ">=4.0" } @@ -4949,7 +4972,8 @@ "version": "0.4.1", "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", - "dev": true + "dev": true, + "peer": true }, "node_modules/glob/node_modules/brace-expansion": { "version": "2.0.2", @@ -6224,6 +6248,7 @@ "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", "dev": true, + "peer": true, "dependencies": { "@types/node": "*", "merge-stream": "^2.0.0", @@ -6238,6 +6263,7 @@ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "dev": true, + "peer": true, "engines": { "node": ">=8" } @@ -6247,6 +6273,7 @@ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", "dev": true, + "peer": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -6382,7 +6409,6 @@ "resolved": "https://registry.npmjs.org/karma/-/karma-6.4.2.tgz", "integrity": "sha512-C6SU/53LB31BEgRg+omznBEMY4SjHU3ricV6zBcAe1EeILKkeScr+fZXtaI5WyDbkVowJxxAI6h73NcFPmXolQ==", "dev": true, - "peer": true, "dependencies": { "@colors/colors": "1.5.0", "body-parser": "^1.19.0", @@ -6480,7 +6506,6 @@ "resolved": "https://registry.npmjs.org/karma-jasmine/-/karma-jasmine-3.3.1.tgz", "integrity": "sha512-Nxh7eX9mOQMyK0VSsMxdod+bcqrR/ikrmEiWj5M6fwuQ7oI+YEF1FckaDsWfs6TIpULm9f0fTKMjF7XcrvWyqQ==", "dev": true, - "peer": true, "dependencies": { "jasmine-core": "^3.5.0" }, @@ -6610,6 +6635,7 @@ "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz", "integrity": "sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==", "dev": true, + "peer": true, "engines": { "node": ">=6.11.5" } @@ -7052,7 +7078,8 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", - "dev": true + "dev": true, + "peer": true }, "node_modules/merge2": { "version": "1.4.1", @@ -7414,7 +7441,8 @@ "version": "2.6.2", "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", - "dev": true + "dev": true, + "peer": true }, "node_modules/next-tick": { "version": "1.1.0", @@ -7458,7 +7486,8 @@ "version": "2.0.18", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.18.tgz", "integrity": "sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==", - "dev": true + "dev": true, + "peer": true }, "node_modules/node-source-walk": { "version": "7.0.0", @@ -8117,7 +8146,6 @@ "url": "https://github.com/sponsors/ai" } ], - "peer": true, "dependencies": { "nanoid": "^3.3.7", "picocolors": "^1.0.1", @@ -8431,6 +8459,7 @@ "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", "dev": true, + "peer": true, "dependencies": { "safe-buffer": "^5.1.0" } @@ -9055,6 +9084,7 @@ "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", "dev": true, + "peer": true, "dependencies": { "randombytes": "^2.1.0" } @@ -9943,6 +9973,7 @@ "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.10.tgz", "integrity": "sha512-BKFPWlPDndPs+NGGCr1U59t0XScL5317Y0UReNrHaw9/FwhPENlq6bfgs+4yPfyP51vqC1bQ4rp1EfXW5ZSH9w==", "dev": true, + "peer": true, "dependencies": { "@jridgewell/trace-mapping": "^0.3.20", "jest-worker": "^27.4.5", @@ -9978,6 +10009,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -9990,6 +10022,7 @@ "resolved": "https://registry.npmjs.org/terser/-/terser-5.27.0.tgz", "integrity": "sha512-bi1HRwVRskAjheeYl291n3JC4GgO/Ty4z1nVs5AAsmonJulGxpSektecnNedrwK9C7vpvVtcX3cw00VSLt7U2A==", "dev": true, + "peer": true, "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.8.2", @@ -10331,7 +10364,6 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz", "integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==", "dev": true, - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -10468,6 +10500,7 @@ "url": "https://github.com/sponsors/ai" } ], + "peer": true, "dependencies": { "escalade": "^3.1.2", "picocolors": "^1.0.1" @@ -10606,6 +10639,7 @@ "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.2.tgz", "integrity": "sha512-TnbFSbcOCcDgjZ4piURLCbJ3nJhznVh9kw6F6iokjiFPl8ONxe9A6nMDVXDiNbrSfLILs6vB07F7wLBrwPYzJw==", "dev": true, + "peer": true, "dependencies": { "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.1.2" @@ -10697,6 +10731,7 @@ "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.2.3.tgz", "integrity": "sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==", "dev": true, + "peer": true, "engines": { "node": ">=10.13.0" } @@ -10720,6 +10755,7 @@ "resolved": "https://registry.npmjs.org/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz", "integrity": "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==", "dev": true, + "peer": true, "peerDependencies": { "acorn": "^8" } @@ -10729,6 +10765,7 @@ "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", "dev": true, + "peer": true, "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^4.1.1" diff --git a/package.json b/package.json index 61bb926e518..43637b008f2 100644 --- a/package.json +++ b/package.json @@ -101,7 +101,6 @@ "mouse-event-offset": "^3.0.2", "mouse-wheel": "^1.2.0", "native-promise-only": "^0.8.1", - "parse-svg-path": "^0.1.2", "point-in-polygon": "^1.1.0", "polybooljs": "^1.2.2", "probe-image-size": "^7.2.3", @@ -162,6 +161,7 @@ "minify-stream": "^2.1.0", "npm-link-check": "^5.0.1", "open": "^8.4.2", + "parse-svg-path": "^0.1.2", "pixelmatch": "^5.3.0", "prepend-file": "^2.0.1", "prettysize": "^2.0.0", diff --git a/src/components/drawing/index.js b/src/components/drawing/index.js index 861df3131a5..b3fc188d5d8 100644 --- a/src/components/drawing/index.js +++ b/src/components/drawing/index.js @@ -111,9 +111,18 @@ drawing.translatePoint = function (d, sel, xa, ya) { var y = ya.c2p(d.y); if (isNumeric(x) && isNumeric(y) && sel.node()) { - // for multiline text this works better if (sel.node().nodeName === 'text') { sel.attr('x', x).attr('y', y); + } else if (sel.node().nodeName === 'use') { + // For markers: preserve the non-translate suffix (scale/rotate) set by singlePointStyle + // Read directly from DOM node since sel may be a d3 transition + var node = sel.node(); + var scale = node.getAttribute('data-scale'); + var rot = node.getAttribute('data-rot'); + var t = strTranslate(x, y); + if (rot) t += ' ' + rot; + if (scale) t += ' scale(' + scale + ')'; + sel.attr('transform', t); } else { sel.attr('transform', strTranslate(x, y)); } @@ -322,16 +331,17 @@ drawing.fillGroupStyle = function (s, gd, forLegend) { var SYMBOLDEFS = require('./symbol_defs'); drawing.symbolNames = []; -drawing.symbolFuncs = []; +drawing.symbolPaths = []; drawing.symbolBackOffs = []; drawing.symbolNeedLines = {}; drawing.symbolNoDot = {}; drawing.symbolNoFill = {}; drawing.symbolList = []; +var _n = 0; Object.keys(SYMBOLDEFS).forEach(function (k) { var symDef = SYMBOLDEFS[k]; - var n = symDef.n; + var n = _n++; drawing.symbolList.push( n, String(n), @@ -342,7 +352,7 @@ Object.keys(SYMBOLDEFS).forEach(function (k) { k + '-open' ); drawing.symbolNames[n] = k; - drawing.symbolFuncs[n] = symDef.f; + drawing.symbolPaths[n] = symDef.p; drawing.symbolBackOffs[n] = symDef.backoff || 0; if (symDef.needLine) { @@ -367,35 +377,108 @@ 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'; +drawing.symbolDotPath = DOTPATH; + +// Pre-build all four variant paths for every symbol, indexed by the legacy +// numeric code (same encoding the user types as `symbol: N`): +// [0, 100) closed (base path) +// [100, 200) open (same base path — open/closed is CSS-only) +// [200, 300) closed-dot (base + dot sub-path) +// [300, 400) open-dot (same as closed-dot — open is CSS-only) +// +// Symbols with noDot leave the dot-variant slots undefined (those variants are invalid). +for(var _i = 0; _i < MAXSYMBOL; _i++) { + drawing.symbolPaths[_i + 100] = drawing.symbolPaths[_i]; // open = same path + if(!drawing.symbolNoDot[_i]) { + drawing.symbolPaths[_i + 200] = drawing.symbolPaths[_i] + DOTPATH; + drawing.symbolPaths[_i + 300] = drawing.symbolPaths[_i] + DOTPATH; + } +} -drawing.symbolNumber = function (v) { +/** + * Unified symbol lookup. + * Accepts a built-in name ('circle', 'circle-open', 'circle-dot', …), + * a legacy numeric code (0, 100, 200, 300, …), or a raw SVG path string + * (any string starting with 'M'/'m'). + * + * Returns {n, path, open, dot, backoff, noDot, noFill}. + * n matches the legacy numeric encoding unambiguously: + * n = idx + (open ? 100 : 0) + (dot ? 200 : 0) + * so lookupSymbol(100).n === 100, lookupSymbol('circle-open').n === 100, etc. + * n is null for custom SVG paths (id assigned per-SVG by ensureSymbolDef). + * Throws an Error for unrecognised input. + */ +drawing.lookupSymbol = function (v) { + // Raw SVG path — no deterministic n; ensureSymbolDef will assign a per-SVG id. + if (typeof v === 'string' && /^[Mm]/.test(v)) { + return { n: null, path: v, open: false, dot: false, backoff: 0, noDot: false, noFill: false }; + } + + var name, open = false, dot = false, idx; if (isNumeric(v)) { - v = +v; + var n = Math.floor(Math.max(+v, 0)); + if (n >= 400) throw new Error('Unknown marker symbol: ' + v); + open = n % 200 >= 100; + dot = n >= 200; + idx = n % 100; + if (idx >= MAXSYMBOL) throw new Error('Unknown marker symbol: ' + v); + name = drawing.symbolNames[idx]; } else if (typeof v === 'string') { - var vbase = 0; - if (v.indexOf('-open') > 0) { - vbase = 100; - v = v.replace('-open', ''); - } - if (v.indexOf('-dot') > 0) { - vbase += 200; - v = v.replace('-dot', ''); - } - v = drawing.symbolNames.indexOf(v); - if (v >= 0) { - v += vbase; - } + if (v.indexOf('-open') > 0) { open = true; v = v.replace('-open', ''); } + if (v.indexOf('-dot') > 0) { dot = true; v = v.replace('-dot', ''); } + idx = drawing.symbolNames.indexOf(v); + if (idx < 0) throw new Error('Unknown marker symbol: ' + v); + name = v; + } else { + throw new Error('Unknown marker symbol: ' + v); } - return v % 100 >= MAXSYMBOL || v >= 400 ? 0 : Math.floor(Math.max(v, 0)); + var symN = idx + (open ? 100 : 0) + (dot ? 200 : 0); + return { + n: symN, + name: name, + path: drawing.symbolPaths[symN], + open: open, + dot: dot, + backoff: drawing.symbolBackOffs[idx] || 0, + noDot: !!drawing.symbolNoDot[idx], + noFill: !!drawing.symbolNoFill[idx] + }; }; -function makePointPath(symbolNumber, r, t, s) { - var base = symbolNumber % 100; - return drawing.symbolFuncs[base](r, t, s) + (symbolNumber >= 200 ? DOTPATH : ''); -} +// sym.n already equals the legacy numeric encoding, so symbolNumber is a simple wrapper. +drawing.symbolNumber = function (v) { + return drawing.lookupSymbol(v).n; +}; + +drawing.ensureSymbolDef = function (gd, sym) { + var defs = gd._fullLayout._defs; + var node = defs.node(); + // Per-SVG map: built-ins keyed by sym.n (each variant gets its own ); + // custom SVG paths keyed by path string → 'c0', 'c1', … + // Stored on the DOM node so it is freed when the SVG is removed. + var symMap = node._symMap || (node._symMap = {}); + + // Use sym.n as key for built-ins; sym.path for custom (sym.n === null). + var key = sym.n !== null ? sym.n : sym.path; + if(key in symMap) return symMap[key]; + + var id; + if(sym.n !== null) { + id = sym.name + (sym.open ? '-open' : '') + (sym.dot ? '-dot' : ''); + } else { + if(!node._customSymCount) node._customSymCount = 0; + id = 'c' + node._customSymCount++; + } + symMap[key] = id; + + defs.append('symbol') + .attr('id', id) + .attr('overflow', 'visible') + .append('path').attr('d', sym.path); + return id; +}; var stopFormatter = numberFormat('~f'); var gradientInfo = { @@ -914,17 +997,36 @@ 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; + var symbolValue = d.mx || marker.symbol; + var sym = drawing.lookupSymbol(symbolValue); - // save if this marker is open - // because that impacts how to handle colors - d.om = x % 200 >= 100; + // save if this marker is open (impacts color handling) + d.om = sym.open; var angle = getMarkerAngle(d, trace); var standoff = getMarkerStandoff(d, trace); - - sel.attr('d', makePointPath(x, r, angle, standoff)); + var scale = r / 20; + + // Build rotation/standoff suffix for transforms + var rot = ''; + if (angle) rot += 'rotate(' + angle + ')'; + if (standoff) rot += (rot ? ' ' : '') + 'translate(0,' + standoff + ')'; + + sel.attr('href', '#' + drawing.ensureSymbolDef(gd, sym)) + .attr('data-scale', scale) + .attr('data-rot', rot || null); + + // Update full transform: keep existing translate (from translatePoint), append rot+scale + // Use node.getAttribute() since sel may be a d3 transition (no getter support) + var node = sel.node(); + var curT = node ? (node.getAttribute('transform') || '') : ''; + var tPart = curT.match(/^translate\([^)]*\)/); + var newT = (tPart ? tPart[0] : ''); + if (rot) newT += ' ' + rot; + newT += ' scale(' + scale + ')'; + sel.attr('transform', newT.trim()); + + sel.style('vector-effect', gd._context.staticPlot ? 'none' : 'non-scaling-stroke'); } var perPointGradient = false; @@ -1201,13 +1303,16 @@ drawing.selectedPointStyle = function (s, trace) { if (fns.selectedSizeFn) { seq.push(function (pt, d) { - var mx = d.mx || marker.symbol || 0; var mrc2 = fns.selectedSizeFn(d); - - pt.attr( - 'd', - makePointPath(drawing.symbolNumber(mx), mrc2, getMarkerAngle(d, trace), getMarkerStandoff(d, trace)) - ); + var scale = mrc2 / 20; + var node = pt.node(); + var rot = node ? (node.getAttribute('data-rot') || '') : ''; + var curT = node ? (node.getAttribute('transform') || '') : ''; + var tPart = curT.match(/^translate\([^)]*\)/); + var newT = (tPart ? tPart[0] : ''); + if (rot) newT += ' ' + rot; + newT += ' scale(' + scale + ')'; + pt.attr('data-scale', scale).attr('transform', newT.trim()); // save for Drawing.selectedTextStyle d.mrc2 = mrc2; @@ -1498,7 +1603,12 @@ function applyBackoff(pt, start) { var endMarkerSize = endMarker.size; if (Lib.isArrayOrTypedArray(endMarkerSize)) endMarkerSize = endMarkerSize[endI]; - b = endMarker ? drawing.symbolBackOffs[drawing.symbolNumber(endMarkerSymbol)] * endMarkerSize : 0; + if(endMarker) { + var endMarkerSym = drawing.lookupSymbol(endMarkerSymbol); + b = (endMarkerSym.backoff || 0) * endMarkerSize; + } else { + b = 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..4adede8cc61 100644 --- a/src/components/drawing/symbol_defs.js +++ b/src/components/drawing/symbol_defs.js @@ -1,813 +1,247 @@ 'use strict'; -var parseSvgPath = require('parse-svg-path'); -var round = // require('@plotly/d3').round; - function(x, n) { - return n ? Math.round(x * (n = Math.pow(10, n))) / n : Math.round(x); - }; - /** Marker symbol definitions * users can specify markers either by number or name * add 100 (or '-open') and you get an open marker * open markers have no fill and use line color as the stroke color * add 200 (or '-dot') and you get a dot in the middle * add both and you get both + * + * Each symbol has a `p` property: the SVG path string for r=20, centered at origin. + * All coordinates are integers. */ - -var emptyPath = 'M0,0Z'; -var sqrt2 = Math.sqrt(2); -var sqrt3 = Math.sqrt(3); -var PI = Math.PI; -var cos = Math.cos; -var sin = Math.sin; - module.exports = { circle: { - n: 0, - f: function(r, angle, standoff) { - if(skipAngle(angle)) return emptyPath; - - var rs = round(r, 2); - var circle = 'M' + rs + ',0A' + rs + ',' + rs + ' 0 1,1 0,-' + rs + 'A' + rs + ',' + rs + ' 0 0,1 ' + rs + ',0Z'; - return standoff ? align(angle, standoff, circle) : circle; - } + p: 'M20,0A20,20 0 1,1 0,-20A20,20 0 0,1 20,0Z' }, square: { - n: 1, - f: function(r, angle, standoff) { - if(skipAngle(angle)) return emptyPath; - - var rs = round(r, 2); - return align(angle, standoff, 'M' + rs + ',' + rs + 'H-' + rs + 'V-' + rs + 'H' + rs + 'Z'); - } + p: 'M20,20H-20V-20H20Z' }, diamond: { - n: 2, - f: function(r, angle, standoff) { - if(skipAngle(angle)) return emptyPath; - - var rd = round(r * 1.3, 2); - return align(angle, standoff, 'M' + rd + ',0L0,' + rd + 'L-' + rd + ',0L0,-' + rd + 'Z'); - } + p: 'M26,0L0,26L-26,0L0,-26Z' }, cross: { - n: 3, - f: function(r, angle, standoff) { - if(skipAngle(angle)) return emptyPath; - - var rc = round(r * 0.4, 2); - var rc2 = round(r * 1.2, 2); - return align(angle, standoff, 'M' + rc2 + ',' + rc + 'H' + rc + 'V' + rc2 + 'H-' + rc + - 'V' + rc + 'H-' + rc2 + 'V-' + rc + 'H-' + rc + 'V-' + rc2 + - 'H' + rc + 'V-' + rc + 'H' + rc2 + 'Z'); - } + p: 'M24,8H8V24H-8V8H-24V-8H-8V-24H8V-8H24Z' }, x: { - n: 4, - f: function(r, angle, standoff) { - if(skipAngle(angle)) return emptyPath; - - var rx = round(r * 0.8 / sqrt2, 2); - var ne = 'l' + rx + ',' + rx; - var se = 'l' + rx + ',-' + rx; - var sw = 'l-' + rx + ',-' + rx; - var nw = 'l-' + rx + ',' + rx; - return align(angle, standoff, 'M0,' + rx + ne + se + sw + se + sw + nw + sw + nw + ne + nw + ne + 'Z'); - } + p: 'M0,11l11,11l11,-11l-11,-11l11,-11l-11,-11l-11,11l-11,-11l-11,11l11,11l-11,11l11,11Z' }, 'triangle-up': { - n: 5, - f: function(r, angle, standoff) { - if(skipAngle(angle)) return emptyPath; - - var rt = round(r * 2 / sqrt3, 2); - var r2 = round(r / 2, 2); - var rs = round(r, 2); - return align(angle, standoff, 'M-' + rt + ',' + r2 + 'H' + rt + 'L0,-' + rs + 'Z'); - } + p: 'M-23,10H23L0,-20Z' }, 'triangle-down': { - n: 6, - f: function(r, angle, standoff) { - if(skipAngle(angle)) return emptyPath; - - var rt = round(r * 2 / sqrt3, 2); - var r2 = round(r / 2, 2); - var rs = round(r, 2); - return align(angle, standoff, 'M-' + rt + ',-' + r2 + 'H' + rt + 'L0,' + rs + 'Z'); - } + p: 'M-23,-10H23L0,20Z' }, 'triangle-left': { - n: 7, - f: function(r, angle, standoff) { - if(skipAngle(angle)) return emptyPath; - - var rt = round(r * 2 / sqrt3, 2); - var r2 = round(r / 2, 2); - var rs = round(r, 2); - return align(angle, standoff, 'M' + r2 + ',-' + rt + 'V' + rt + 'L-' + rs + ',0Z'); - } + p: 'M10,-23V23L-20,0Z' }, 'triangle-right': { - n: 8, - f: function(r, angle, standoff) { - if(skipAngle(angle)) return emptyPath; - - var rt = round(r * 2 / sqrt3, 2); - var r2 = round(r / 2, 2); - var rs = round(r, 2); - return align(angle, standoff, 'M-' + r2 + ',-' + rt + 'V' + rt + 'L' + rs + ',0Z'); - } + p: 'M-10,-23V23L20,0Z' }, 'triangle-ne': { - n: 9, - f: function(r, angle, standoff) { - if(skipAngle(angle)) return emptyPath; - - var r1 = round(r * 0.6, 2); - var r2 = round(r * 1.2, 2); - return align(angle, standoff, 'M-' + r2 + ',-' + r1 + 'H' + r1 + 'V' + r2 + 'Z'); - } + p: 'M-24,-12H12V24Z' }, 'triangle-se': { - n: 10, - f: function(r, angle, standoff) { - if(skipAngle(angle)) return emptyPath; - - var r1 = round(r * 0.6, 2); - var r2 = round(r * 1.2, 2); - return align(angle, standoff, 'M' + r1 + ',-' + r2 + 'V' + r1 + 'H-' + r2 + 'Z'); - } + p: 'M12,-24V12H-24Z' }, 'triangle-sw': { - n: 11, - f: function(r, angle, standoff) { - if(skipAngle(angle)) return emptyPath; - - var r1 = round(r * 0.6, 2); - var r2 = round(r * 1.2, 2); - return align(angle, standoff, 'M' + r2 + ',' + r1 + 'H-' + r1 + 'V-' + r2 + 'Z'); - } + p: 'M24,12H-12V-24Z' }, 'triangle-nw': { - n: 12, - f: function(r, angle, standoff) { - if(skipAngle(angle)) return emptyPath; - - var r1 = round(r * 0.6, 2); - var r2 = round(r * 1.2, 2); - return align(angle, standoff, 'M-' + r1 + ',' + r2 + 'V-' + r1 + 'H' + r2 + 'Z'); - } + p: 'M-12,24V-12H24Z' }, pentagon: { - n: 13, - f: function(r, angle, standoff) { - if(skipAngle(angle)) return emptyPath; - - var x1 = round(r * 0.951, 2); - var x2 = round(r * 0.588, 2); - var y0 = round(-r, 2); - var y1 = round(r * -0.309, 2); - var y2 = round(r * 0.809, 2); - return align(angle, standoff, 'M' + x1 + ',' + y1 + 'L' + x2 + ',' + y2 + 'H-' + x2 + - 'L-' + x1 + ',' + y1 + 'L0,' + y0 + 'Z'); - } + p: 'M19,-6L12,16H-12L-19,-6L0,-20Z' }, hexagon: { - n: 14, - f: function(r, angle, standoff) { - if(skipAngle(angle)) return emptyPath; - - var y0 = round(r, 2); - var y1 = round(r / 2, 2); - var x = round(r * sqrt3 / 2, 2); - return align(angle, standoff, 'M' + x + ',-' + y1 + 'V' + y1 + 'L0,' + y0 + - 'L-' + x + ',' + y1 + 'V-' + y1 + 'L0,-' + y0 + 'Z'); - } + p: 'M17,-10V10L0,20L-17,10V-10L0,-20Z' }, hexagon2: { - n: 15, - f: function(r, angle, standoff) { - if(skipAngle(angle)) return emptyPath; - - var x0 = round(r, 2); - var x1 = round(r / 2, 2); - var y = round(r * sqrt3 / 2, 2); - return align(angle, standoff, 'M-' + x1 + ',' + y + 'H' + x1 + 'L' + x0 + - ',0L' + x1 + ',-' + y + 'H-' + x1 + 'L-' + x0 + ',0Z'); - } + p: 'M-10,17H10L20,0L10,-17H-10L-20,0Z' }, octagon: { - n: 16, - f: function(r, angle, standoff) { - if(skipAngle(angle)) return emptyPath; - - var a = round(r * 0.924, 2); - var b = round(r * 0.383, 2); - return align(angle, standoff, 'M-' + b + ',-' + a + 'H' + b + 'L' + a + ',-' + b + 'V' + b + - 'L' + b + ',' + a + 'H-' + b + 'L-' + a + ',' + b + 'V-' + b + 'Z'); - } + p: 'M-8,-18H8L18,-8V8L8,18H-8L-18,8V-8Z' }, star: { - n: 17, - f: function(r, angle, standoff) { - if(skipAngle(angle)) return emptyPath; - - var rs = r * 1.4; - var x1 = round(rs * 0.225, 2); - var x2 = round(rs * 0.951, 2); - var x3 = round(rs * 0.363, 2); - var x4 = round(rs * 0.588, 2); - var y0 = round(-rs, 2); - var y1 = round(rs * -0.309, 2); - var y3 = round(rs * 0.118, 2); - var y4 = round(rs * 0.809, 2); - var y5 = round(rs * 0.382, 2); - return align(angle, standoff, 'M' + x1 + ',' + y1 + 'H' + x2 + 'L' + x3 + ',' + y3 + - 'L' + x4 + ',' + y4 + 'L0,' + y5 + 'L-' + x4 + ',' + y4 + - 'L-' + x3 + ',' + y3 + 'L-' + x2 + ',' + y1 + 'H-' + x1 + - 'L0,' + y0 + 'Z'); - } + p: 'M6,-9H27L10,3L16,23L0,11L-16,23L-10,3L-27,-9H-6L0,-28Z' }, hexagram: { - n: 18, - f: function(r, angle, standoff) { - if(skipAngle(angle)) return emptyPath; - - var y = round(r * 0.66, 2); - var x1 = round(r * 0.38, 2); - var x2 = round(r * 0.76, 2); - return align(angle, standoff, 'M-' + x2 + ',0l-' + x1 + ',-' + y + 'h' + x2 + - 'l' + x1 + ',-' + y + 'l' + x1 + ',' + y + 'h' + x2 + - 'l-' + x1 + ',' + y + 'l' + x1 + ',' + y + 'h-' + x2 + - 'l-' + x1 + ',' + y + 'l-' + x1 + ',-' + y + 'h-' + x2 + 'Z'); - } + p: 'M-15,0l-8,-13h15l8,-13l8,13h15l-8,13l8,13h-15l-8,13l-8,-13h-15Z' }, 'star-triangle-up': { - n: 19, - f: function(r, angle, standoff) { - if(skipAngle(angle)) return emptyPath; - - var x = round(r * sqrt3 * 0.8, 2); - var y1 = round(r * 0.8, 2); - var y2 = round(r * 1.6, 2); - var rc = round(r * 4, 2); - var aPart = 'A ' + rc + ',' + rc + ' 0 0 1 '; - return align(angle, standoff, 'M-' + x + ',' + y1 + aPart + x + ',' + y1 + - aPart + '0,-' + y2 + aPart + '-' + x + ',' + y1 + 'Z'); - } + p: 'M-28,16A80,80 0 0 1 28,16A80,80 0 0 1 0,-32A80,80 0 0 1 -28,16Z' }, 'star-triangle-down': { - n: 20, - f: function(r, angle, standoff) { - if(skipAngle(angle)) return emptyPath; - - var x = round(r * sqrt3 * 0.8, 2); - var y1 = round(r * 0.8, 2); - var y2 = round(r * 1.6, 2); - var rc = round(r * 4, 2); - var aPart = 'A ' + rc + ',' + rc + ' 0 0 1 '; - return align(angle, standoff, 'M' + x + ',-' + y1 + aPart + '-' + x + ',-' + y1 + - aPart + '0,' + y2 + aPart + x + ',-' + y1 + 'Z'); - } + p: 'M28,-16A80,80 0 0 1 -28,-16A80,80 0 0 1 0,32A80,80 0 0 1 28,-16Z' }, 'star-square': { - n: 21, - f: function(r, angle, standoff) { - if(skipAngle(angle)) return emptyPath; - - var rp = round(r * 1.1, 2); - var rc = round(r * 2, 2); - var aPart = 'A ' + rc + ',' + rc + ' 0 0 1 '; - return align(angle, standoff, 'M-' + rp + ',-' + rp + aPart + '-' + rp + ',' + rp + - aPart + rp + ',' + rp + aPart + rp + ',-' + rp + - aPart + '-' + rp + ',-' + rp + 'Z'); - } + p: 'M-22,-22A40,40 0 0 1 -22,22A40,40 0 0 1 22,22A40,40 0 0 1 22,-22A40,40 0 0 1 -22,-22Z' }, 'star-diamond': { - n: 22, - f: function(r, angle, standoff) { - if(skipAngle(angle)) return emptyPath; - - var rp = round(r * 1.4, 2); - var rc = round(r * 1.9, 2); - var aPart = 'A ' + rc + ',' + rc + ' 0 0 1 '; - return align(angle, standoff, 'M-' + rp + ',0' + aPart + '0,' + rp + - aPart + rp + ',0' + aPart + '0,-' + rp + - aPart + '-' + rp + ',0' + 'Z'); - } + p: 'M-28,0A38,38 0 0 1 0,28A38,38 0 0 1 28,0A38,38 0 0 1 0,-28A38,38 0 0 1 -28,0Z' }, 'diamond-tall': { - n: 23, - f: function(r, angle, standoff) { - if(skipAngle(angle)) return emptyPath; - - var x = round(r * 0.7, 2); - var y = round(r * 1.4, 2); - return align(angle, standoff, 'M0,' + y + 'L' + x + ',0L0,-' + y + 'L-' + x + ',0Z'); - } + p: 'M0,28L14,0L0,-28L-14,0Z' }, 'diamond-wide': { - n: 24, - f: function(r, angle, standoff) { - if(skipAngle(angle)) return emptyPath; - - var x = round(r * 1.4, 2); - var y = round(r * 0.7, 2); - return align(angle, standoff, 'M0,' + y + 'L' + x + ',0L0,-' + y + 'L-' + x + ',0Z'); - } + p: 'M0,14L28,0L0,-14L-28,0Z' }, hourglass: { - n: 25, - f: function(r, angle, standoff) { - if(skipAngle(angle)) return emptyPath; - - var rs = round(r, 2); - return align(angle, standoff, 'M' + rs + ',' + rs + 'H-' + rs + 'L' + rs + ',-' + rs + 'H-' + rs + 'Z'); - }, + p: 'M20,20H-20L20,-20H-20Z', noDot: true }, bowtie: { - n: 26, - f: function(r, angle, standoff) { - if(skipAngle(angle)) return emptyPath; - - var rs = round(r, 2); - return align(angle, standoff, 'M' + rs + ',' + rs + 'V-' + rs + 'L-' + rs + ',' + rs + 'V-' + rs + 'Z'); - }, + p: 'M20,20V-20L-20,20V-20Z', noDot: true }, 'circle-cross': { - n: 27, - f: function(r, angle, standoff) { - if(skipAngle(angle)) return emptyPath; - - var rs = round(r, 2); - return align(angle, standoff, 'M0,' + rs + 'V-' + rs + 'M' + rs + ',0H-' + rs + - 'M' + rs + ',0A' + rs + ',' + rs + ' 0 1,1 0,-' + rs + - 'A' + rs + ',' + rs + ' 0 0,1 ' + rs + ',0Z'); - }, + p: 'M0,20V-20M20,0H-20M20,0A20,20 0 1,1 0,-20A20,20 0 0,1 20,0Z', needLine: true, noDot: true }, 'circle-x': { - n: 28, - f: function(r, angle, standoff) { - if(skipAngle(angle)) return emptyPath; - - var rs = round(r, 2); - var rc = round(r / sqrt2, 2); - return align(angle, standoff, 'M' + rc + ',' + rc + 'L-' + rc + ',-' + rc + - 'M' + rc + ',-' + rc + 'L-' + rc + ',' + rc + - 'M' + rs + ',0A' + rs + ',' + rs + ' 0 1,1 0,-' + rs + - 'A' + rs + ',' + rs + ' 0 0,1 ' + rs + ',0Z'); - }, + p: 'M14,14L-14,-14M14,-14L-14,14M20,0A20,20 0 1,1 0,-20A20,20 0 0,1 20,0Z', needLine: true, noDot: true }, 'square-cross': { - n: 29, - f: function(r, angle, standoff) { - if(skipAngle(angle)) return emptyPath; - - var rs = round(r, 2); - return align(angle, standoff, 'M0,' + rs + 'V-' + rs + 'M' + rs + ',0H-' + rs + - 'M' + rs + ',' + rs + 'H-' + rs + 'V-' + rs + 'H' + rs + 'Z'); - }, + p: 'M0,20V-20M20,0H-20M20,20H-20V-20H20Z', needLine: true, noDot: true }, 'square-x': { - n: 30, - f: function(r, angle, standoff) { - if(skipAngle(angle)) return emptyPath; - - var rs = round(r, 2); - return align(angle, standoff, 'M' + rs + ',' + rs + 'L-' + rs + ',-' + rs + - 'M' + rs + ',-' + rs + 'L-' + rs + ',' + rs + - 'M' + rs + ',' + rs + 'H-' + rs + 'V-' + rs + 'H' + rs + 'Z'); - }, + p: 'M20,20L-20,-20M20,-20L-20,20M20,20H-20V-20H20Z', needLine: true, noDot: true }, 'diamond-cross': { - n: 31, - f: function(r, angle, standoff) { - if(skipAngle(angle)) return emptyPath; - - var rd = round(r * 1.3, 2); - return align(angle, standoff, 'M' + rd + ',0L0,' + rd + 'L-' + rd + ',0L0,-' + rd + 'Z' + - 'M0,-' + rd + 'V' + rd + 'M-' + rd + ',0H' + rd); - }, + p: 'M26,0L0,26L-26,0L0,-26ZM0,-26V26M-26,0H26', needLine: true, noDot: true }, 'diamond-x': { - n: 32, - f: function(r, angle, standoff) { - if(skipAngle(angle)) return emptyPath; - - var rd = round(r * 1.3, 2); - var r2 = round(r * 0.65, 2); - return align(angle, standoff, 'M' + rd + ',0L0,' + rd + 'L-' + rd + ',0L0,-' + rd + 'Z' + - 'M-' + r2 + ',-' + r2 + 'L' + r2 + ',' + r2 + - 'M-' + r2 + ',' + r2 + 'L' + r2 + ',-' + r2); - }, + p: 'M26,0L0,26L-26,0L0,-26ZM-13,-13L13,13M-13,13L13,-13', needLine: true, noDot: true }, 'cross-thin': { - n: 33, - f: function(r, angle, standoff) { - if(skipAngle(angle)) return emptyPath; - - var rc = round(r * 1.4, 2); - return align(angle, standoff, 'M0,' + rc + 'V-' + rc + 'M' + rc + ',0H-' + rc); - }, + p: 'M0,28V-28M28,0H-28', needLine: true, noDot: true, noFill: true }, 'x-thin': { - n: 34, - f: function(r, angle, standoff) { - if(skipAngle(angle)) return emptyPath; - - var rx = round(r, 2); - return align(angle, standoff, 'M' + rx + ',' + rx + 'L-' + rx + ',-' + rx + - 'M' + rx + ',-' + rx + 'L-' + rx + ',' + rx); - }, + p: 'M20,20L-20,-20M20,-20L-20,20', needLine: true, noDot: true, noFill: true }, asterisk: { - n: 35, - f: function(r, angle, standoff) { - if(skipAngle(angle)) return emptyPath; - - var rc = round(r * 1.2, 2); - var rs = round(r * 0.85, 2); - return align(angle, standoff, 'M0,' + rc + 'V-' + rc + 'M' + rc + ',0H-' + rc + - 'M' + rs + ',' + rs + 'L-' + rs + ',-' + rs + - 'M' + rs + ',-' + rs + 'L-' + rs + ',' + rs); - }, + p: 'M0,24V-24M24,0H-24M17,17L-17,-17M17,-17L-17,17', needLine: true, noDot: true, noFill: true }, hash: { - n: 36, - f: function(r, angle, standoff) { - if(skipAngle(angle)) return emptyPath; - - var r1 = round(r / 2, 2); - var r2 = round(r, 2); - - return align(angle, standoff, 'M' + r1 + ',' + r2 + 'V-' + r2 + - 'M' + (r1 - r2) + ',-' + r2 + 'V' + r2 + - 'M' + r2 + ',' + r1 + 'H-' + r2 + - 'M-' + r2 + ',' + (r1 - r2) + 'H' + r2); - }, + p: 'M10,20V-20M-10,-20V20M20,10H-20M-20,-10H20', needLine: true, noFill: true }, 'y-up': { - n: 37, - f: function(r, angle, standoff) { - if(skipAngle(angle)) return emptyPath; - - var x = round(r * 1.2, 2); - var y0 = round(r * 1.6, 2); - var y1 = round(r * 0.8, 2); - return align(angle, standoff, 'M-' + x + ',' + y1 + 'L0,0M' + x + ',' + y1 + 'L0,0M0,-' + y0 + 'L0,0'); - }, + p: 'M-24,16L0,0M24,16L0,0M0,-32L0,0', needLine: true, noDot: true, noFill: true }, 'y-down': { - n: 38, - f: function(r, angle, standoff) { - if(skipAngle(angle)) return emptyPath; - - var x = round(r * 1.2, 2); - var y0 = round(r * 1.6, 2); - var y1 = round(r * 0.8, 2); - return align(angle, standoff, 'M-' + x + ',-' + y1 + 'L0,0M' + x + ',-' + y1 + 'L0,0M0,' + y0 + 'L0,0'); - }, + p: 'M-24,-16L0,0M24,-16L0,0M0,32L0,0', needLine: true, noDot: true, noFill: true }, 'y-left': { - n: 39, - f: function(r, angle, standoff) { - if(skipAngle(angle)) return emptyPath; - - var y = round(r * 1.2, 2); - var x0 = round(r * 1.6, 2); - var x1 = round(r * 0.8, 2); - return align(angle, standoff, 'M' + x1 + ',' + y + 'L0,0M' + x1 + ',-' + y + 'L0,0M-' + x0 + ',0L0,0'); - }, + p: 'M16,24L0,0M16,-24L0,0M-32,0L0,0', needLine: true, noDot: true, noFill: true }, 'y-right': { - n: 40, - f: function(r, angle, standoff) { - if(skipAngle(angle)) return emptyPath; - - var y = round(r * 1.2, 2); - var x0 = round(r * 1.6, 2); - var x1 = round(r * 0.8, 2); - return align(angle, standoff, 'M-' + x1 + ',' + y + 'L0,0M-' + x1 + ',-' + y + 'L0,0M' + x0 + ',0L0,0'); - }, + p: 'M-16,24L0,0M-16,-24L0,0M32,0L0,0', needLine: true, noDot: true, noFill: true }, 'line-ew': { - n: 41, - f: function(r, angle, standoff) { - if(skipAngle(angle)) return emptyPath; - - var rc = round(r * 1.4, 2); - return align(angle, standoff, 'M' + rc + ',0H-' + rc); - }, + p: 'M28,0H-28', needLine: true, noDot: true, noFill: true }, 'line-ns': { - n: 42, - f: function(r, angle, standoff) { - if(skipAngle(angle)) return emptyPath; - - var rc = round(r * 1.4, 2); - return align(angle, standoff, 'M0,' + rc + 'V-' + rc); - }, + p: 'M0,28V-28', needLine: true, noDot: true, noFill: true }, 'line-ne': { - n: 43, - f: function(r, angle, standoff) { - if(skipAngle(angle)) return emptyPath; - - var rx = round(r, 2); - return align(angle, standoff, 'M' + rx + ',-' + rx + 'L-' + rx + ',' + rx); - }, + p: 'M20,-20L-20,20', needLine: true, noDot: true, noFill: true }, 'line-nw': { - n: 44, - f: function(r, angle, standoff) { - if(skipAngle(angle)) return emptyPath; - - var rx = round(r, 2); - return align(angle, standoff, 'M' + rx + ',' + rx + 'L-' + rx + ',-' + rx); - }, + p: 'M20,20L-20,-20', needLine: true, noDot: true, noFill: true }, 'arrow-up': { - n: 45, - f: function(r, angle, standoff) { - if(skipAngle(angle)) return emptyPath; - - var rx = round(r, 2); - var ry = round(r * 2, 2); - return align(angle, standoff, 'M0,0L-' + rx + ',' + ry + 'H' + rx + 'Z'); - }, + p: 'M0,0L-20,40H20Z', backoff: 1, noDot: true }, 'arrow-down': { - n: 46, - f: function(r, angle, standoff) { - if(skipAngle(angle)) return emptyPath; - - var rx = round(r, 2); - var ry = round(r * 2, 2); - return align(angle, standoff, 'M0,0L-' + rx + ',-' + ry + 'H' + rx + 'Z'); - }, + p: 'M0,0L-20,-40H20Z', noDot: true }, 'arrow-left': { - n: 47, - f: function(r, angle, standoff) { - if(skipAngle(angle)) return emptyPath; - - var rx = round(r * 2, 2); - var ry = round(r, 2); - return align(angle, standoff, 'M0,0L' + rx + ',-' + ry + 'V' + ry + 'Z'); - }, + p: 'M0,0L40,-20V20Z', noDot: true }, 'arrow-right': { - n: 48, - f: function(r, angle, standoff) { - if(skipAngle(angle)) return emptyPath; - - var rx = round(r * 2, 2); - var ry = round(r, 2); - return align(angle, standoff, 'M0,0L-' + rx + ',-' + ry + 'V' + ry + 'Z'); - }, + p: 'M0,0L-40,-20V20Z', noDot: true }, 'arrow-bar-up': { - n: 49, - f: function(r, angle, standoff) { - if(skipAngle(angle)) return emptyPath; - - var rx = round(r, 2); - var ry = round(r * 2, 2); - return align(angle, standoff, 'M-' + rx + ',0H' + rx + 'M0,0L-' + rx + ',' + ry + 'H' + rx + 'Z'); - }, + p: 'M-20,0H20M0,0L-20,40H20Z', backoff: 1, needLine: true, noDot: true }, 'arrow-bar-down': { - n: 50, - f: function(r, angle, standoff) { - if(skipAngle(angle)) return emptyPath; - - var rx = round(r, 2); - var ry = round(r * 2, 2); - return align(angle, standoff, 'M-' + rx + ',0H' + rx + 'M0,0L-' + rx + ',-' + ry + 'H' + rx + 'Z'); - }, + p: 'M-20,0H20M0,0L-20,-40H20Z', needLine: true, noDot: true }, 'arrow-bar-left': { - n: 51, - f: function(r, angle, standoff) { - if(skipAngle(angle)) return emptyPath; - - var rx = round(r * 2, 2); - var ry = round(r, 2); - return align(angle, standoff, 'M0,-' + ry + 'V' + ry + 'M0,0L' + rx + ',-' + ry + 'V' + ry + 'Z'); - }, + p: 'M0,-20V20M0,0L40,-20V20Z', needLine: true, noDot: true }, 'arrow-bar-right': { - n: 52, - f: function(r, angle, standoff) { - if(skipAngle(angle)) return emptyPath; - - var rx = round(r * 2, 2); - var ry = round(r, 2); - return align(angle, standoff, 'M0,-' + ry + 'V' + ry + 'M0,0L-' + rx + ',-' + ry + 'V' + ry + 'Z'); - }, + p: 'M0,-20V20M0,0L-40,-20V20Z', needLine: true, noDot: true }, arrow: { - n: 53, - f: function(r, angle, standoff) { - if(skipAngle(angle)) return emptyPath; - - var headAngle = PI / 2.5; // 36 degrees - golden ratio - var x = 2 * r * cos(headAngle); - var y = 2 * r * sin(headAngle); - - return align(angle, standoff, - 'M0,0' + - 'L' + -x + ',' + y + - 'L' + x + ',' + y + - 'Z' - ); - }, + p: 'M0,0L-12,38L12,38Z', backoff: 0.9, noDot: true }, 'arrow-wide': { - n: 54, - f: function(r, angle, standoff) { - if(skipAngle(angle)) return emptyPath; - - var headAngle = PI / 4; // 90 degrees - var x = 2 * r * cos(headAngle); - var y = 2 * r * sin(headAngle); - - return align(angle, standoff, - 'M0,0' + - 'L' + -x + ',' + y + - 'A ' + 2 * r + ',' + 2 * r + ' 0 0 1 ' + x + ',' + y + - 'Z' - ); - }, + p: 'M0,0L-28,28A40,40 0 0 1 28,28Z', backoff: 0.4, noDot: true } }; - -function skipAngle(angle) { - return angle === null; -} - -var lastPathIn, lastPathOut; -var lastAngle, lastStandoff; - -function align(angle, standoff, path) { - if((!angle || angle % 360 === 0) && !standoff) return path; - - if( - lastAngle === angle && - lastStandoff === standoff && - lastPathIn === path - ) return lastPathOut; - - lastAngle = angle; - lastStandoff = standoff; - lastPathIn = path; - - function rotate(t, xy) { - var cosT = cos(t); - var sinT = sin(t); - - var x = xy[0]; - var y = xy[1] + (standoff || 0); - return [ - x * cosT - y * sinT, - x * sinT + y * cosT - ]; - } - - var t = angle / 180 * PI; - - var x = 0; - var y = 0; - var cmd = parseSvgPath(path); - var str = ''; - - for(var i = 0; i < cmd.length; i++) { - var cmdI = cmd[i]; - var op = cmdI[0]; - - var x0 = x; - var y0 = y; - - if(op === 'M' || op === 'L') { - x = +cmdI[1]; - y = +cmdI[2]; - } else if(op === 'm' || op === 'l') { - x += +cmdI[1]; - y += +cmdI[2]; - } else if(op === 'H') { - x = +cmdI[1]; - } else if(op === 'h') { - x += +cmdI[1]; - } else if(op === 'V') { - y = +cmdI[1]; - } else if(op === 'v') { - y += +cmdI[1]; - } else if(op === 'A') { - x = +cmdI[1]; - y = +cmdI[2]; - - var E = rotate(t, [+cmdI[6], +cmdI[7]]); - cmdI[6] = E[0]; - cmdI[7] = E[1]; - cmdI[3] = +cmdI[3] + angle; - } - - // change from H, V, h, v to L or l - if(op === 'H' || op === 'V') op = 'L'; - if(op === 'h' || op === 'v') op = 'l'; - - if(op === 'm' || op === 'l') { - x -= x0; - y -= y0; - } - - var B = rotate(t, [x, y]); - - if(op === 'H' || op === 'V') op = 'L'; - - - if( - op === 'M' || op === 'L' || - op === 'm' || op === 'l' - ) { - cmdI[1] = B[0]; - cmdI[2] = B[1]; - } - cmdI[0] = op; - - str += cmdI[0] + cmdI.slice(1).join(','); - } - - lastPathOut = str; - - return str; -} diff --git a/src/components/legend/style.js b/src/components/legend/style.js index d2b3350c0c8..887d00a31aa 100644 --- a/src/components/legend/style.js +++ b/src/components/legend/style.js @@ -257,9 +257,9 @@ module.exports = function style(s, gd, legend) { var ptgroup = d3.select(this).select('g.legendpoints'); - var pts = ptgroup.selectAll('path.scatterpts').data(showMarker ? dMod : []); + var pts = ptgroup.selectAll('use.scatterpts').data(showMarker ? dMod : []); // make sure marker is on the bottom, in case it enters after text - pts.enter().insert('path', ':first-child').classed('scatterpts', true).attr('transform', centerTransform); + pts.enter().insert('use', ':first-child').classed('scatterpts', true).attr('transform', centerTransform); pts.exit().remove(); pts.call(Drawing.pointStyle, tMod, gd); diff --git a/src/traces/box/plot.js b/src/traces/box/plot.js index 2a8a654cad6..a071a23595f 100644 --- a/src/traces/box/plot.js +++ b/src/traces/box/plot.js @@ -198,7 +198,7 @@ function plotPoints(sel, axes, trace, t) { gPoints.exit().remove(); - var paths = gPoints.selectAll('path') + var paths = gPoints.selectAll('use') .data(function(d) { var i; var pts = d.pts2; @@ -270,7 +270,7 @@ function plotPoints(sel, axes, trace, t) { return pts; }); - paths.enter().append('path') + paths.enter().append('use') .classed('point', true); paths.exit().remove(); diff --git a/src/traces/box/style.js b/src/traces/box/style.js index 5e28eeefa27..ed03ce7b981 100644 --- a/src/traces/box/style.js +++ b/src/traces/box/style.js @@ -41,7 +41,7 @@ function style(gd, cd, sel) { }) .call(Color.stroke, trace.line.color); - var pts = el.selectAll('path.point'); + var pts = el.selectAll('use.point'); Drawing.pointStyle(pts, trace, gd); } }); @@ -49,7 +49,7 @@ function style(gd, cd, sel) { function styleOnSelect(gd, cd, sel) { var trace = cd[0].trace; - var pts = sel.selectAll('path.point'); + var pts = sel.selectAll('use.point'); if(trace.selectedpoints) { Drawing.selectedPointStyle(pts, trace); diff --git a/src/traces/scatter/plot.js b/src/traces/scatter/plot.js index 3c9e19df936..aa22cd10920 100644 --- a/src/traces/scatter/plot.js +++ b/src/traces/scatter/plot.js @@ -517,11 +517,11 @@ function plotOne(gd, idx, plotinfo, cdscatter, cdscatterAll, element, transition // marker points - selection = points.selectAll('path.point'); + selection = points.selectAll('use.point'); join = selection.data(markerFilter, keyFunc); - var enter = join.enter().append('path') + var enter = join.enter().append('use') .classed('point', true); if(hasTransition) { diff --git a/src/traces/scatter/style.js b/src/traces/scatter/style.js index 734ecf9f9d0..23216de88ba 100644 --- a/src/traces/scatter/style.js +++ b/src/traces/scatter/style.js @@ -33,7 +33,7 @@ function style(gd) { } function stylePoints(sel, trace, gd) { - Drawing.pointStyle(sel.selectAll('path.point'), trace, gd); + Drawing.pointStyle(sel.selectAll('use.point'), trace, gd); } function styleText(sel, trace, gd) { @@ -44,7 +44,7 @@ function styleOnSelect(gd, cd, sel) { var trace = cd[0].trace; if(trace.selectedpoints) { - Drawing.selectedPointStyle(sel.selectAll('path.point'), trace); + Drawing.selectedPointStyle(sel.selectAll('use.point'), trace); Drawing.selectedTextStyle(sel.selectAll('text'), trace); } else { stylePoints(sel, trace, gd); diff --git a/src/traces/scattergeo/plot.js b/src/traces/scattergeo/plot.js index 9124900e8b3..4e9cae57d58 100644 --- a/src/traces/scattergeo/plot.js +++ b/src/traces/scattergeo/plot.js @@ -45,9 +45,9 @@ function plot(gd, geo, calcData) { } if(subTypes.hasMarkers(trace)) { - s.selectAll('path.point') + s.selectAll('use.point') .data(Lib.identity) - .enter().append('path') + .enter().append('use') .classed('point', true) .each(function(calcPt) { removeBADNUM(calcPt, this); }); } diff --git a/src/traces/scattergl/convert.js b/src/traces/scattergl/convert.js index 22e3f48db31..485d8a0b057 100644 --- a/src/traces/scattergl/convert.js +++ b/src/traces/scattergl/convert.js @@ -458,41 +458,77 @@ var SYMBOL_SDF_SIZE = constants.SYMBOL_SDF_SIZE; var SYMBOL_SIZE = constants.SYMBOL_SIZE; var SYMBOL_STROKE = constants.SYMBOL_STROKE; var SYMBOL_SDF = {}; -var SYMBOL_SVG_CIRCLE = Drawing.symbolFuncs[0](SYMBOL_SIZE * 0.05); +// Small circle path (r=1) used as center dot in SDF symbol rendering +var SYMBOL_SVG_CIRCLE = 'M1,0A1,1 0 1,1 0,-1A1,1 0 0,1 1,0Z'; + +// Rotate an SVG path string by angleDeg degrees around the origin, for use +// in the SDF (signed-distance-field) pipeline where svg-path-sdf only accepts +// a flat path string and has no SVG-transform support. +// SVG markers (scatter/box) rotate via transform="rotate(...)" on instead. +// Only handles M/L/H/V/A commands (sufficient for all built-in symbol paths). +function rotatePath(path, angleDeg) { + if (!angleDeg || angleDeg % 360 === 0) return path; + var t = angleDeg * Math.PI / 180; + var cosT = Math.cos(t); + var sinT = Math.sin(t); + function rot(x, y) { return [x * cosT - y * sinT, x * sinT + y * cosT]; } + // Parse path commands with a simple regex + return path.replace(/([MLHVAZmlhva])([^MLHVAZmlhva]*)/g, function(_, op, args) { + var nums = args.trim() ? args.trim().split(/[\s,]+/).map(Number) : []; + var u = op.toUpperCase(); + if (u === 'Z') return op; + if (u === 'M' || u === 'L') { + var p = rot(nums[0], nums[1]); + return op + p[0] + ',' + p[1]; + } + if (u === 'H') { + var ph = rot(nums[0], 0); + return 'L' + ph[0] + ',' + ph[1]; + } + if (u === 'V') { + var pv = rot(0, nums[0]); + return 'L' + pv[0] + ',' + pv[1]; + } + if (u === 'A') { + // args: rx ry x-rotation large-arc-flag sweep-flag x y + var pa = rot(nums[5], nums[6]); + return op + nums[0] + ',' + nums[1] + ' ' + (nums[2] + angleDeg) + ' ' + nums[3] + ' ' + nums[4] + ' ' + pa[0] + ',' + pa[1]; + } + return op + args; + }); +} function getSymbolSdf(d, trace) { var symbol = d.mx; if (symbol === 'circle') return null; var symbolPath, symbolSdf; - var symbolNumber = Drawing.symbolNumber(symbol); - var symbolFunc = Drawing.symbolFuncs[symbolNumber % 100]; - var symbolNoDot = !!Drawing.symbolNoDot[symbolNumber % 100]; - var symbolNoFill = !!Drawing.symbolNoFill[symbolNumber % 100]; - + var sym = Drawing.lookupSymbol(symbol); var isDot = helpers.isDotSymbol(symbol); // until we may handle angles in shader? - if (d.ma) symbol += '_' + d.ma; + var cacheKey = symbol; + if (d.ma) cacheKey += '_' + d.ma; // get symbol sdf from cache or generate it - if (SYMBOL_SDF[symbol]) return SYMBOL_SDF[symbol]; + if (SYMBOL_SDF[cacheKey]) return SYMBOL_SDF[cacheKey]; var angle = Drawing.getMarkerAngle(d, trace); - if (isDot && !symbolNoDot) { - symbolPath = symbolFunc(SYMBOL_SIZE * 1.1, angle) + SYMBOL_SVG_CIRCLE; + var basePath = rotatePath(sym.path, angle); + if (isDot && !sym.noDot) { + symbolPath = basePath + SYMBOL_SVG_CIRCLE; } else { - symbolPath = symbolFunc(SYMBOL_SIZE, angle); + symbolPath = basePath; } symbolSdf = svgSdf(symbolPath, { w: SYMBOL_SDF_SIZE, h: SYMBOL_SDF_SIZE, viewBox: [-SYMBOL_SIZE, -SYMBOL_SIZE, SYMBOL_SIZE, SYMBOL_SIZE], - stroke: symbolNoFill ? SYMBOL_STROKE : -SYMBOL_STROKE + stroke: sym.noFill ? SYMBOL_STROKE : -SYMBOL_STROKE }); - SYMBOL_SDF[symbol] = symbolSdf; + SYMBOL_SDF[cacheKey] = symbolSdf; return symbolSdf || null; } diff --git a/test/jasmine/tests/box_test.js b/test/jasmine/tests/box_test.js index b350804d63c..9afad5d7ba5 100644 --- a/test/jasmine/tests/box_test.js +++ b/test/jasmine/tests/box_test.js @@ -1122,7 +1122,7 @@ describe('Test box restyle:', function() { var trace3 = d3Select(gd).select('.boxlayer > .trace'); _assertOne(msg, exp, trace3, 'boxCnt', 'path.box'); _assertOne(msg, exp, trace3, 'meanlineCnt', 'path.mean'); - _assertOne(msg, exp, trace3, 'ptsCnt', 'path.point'); + _assertOne(msg, exp, trace3, 'ptsCnt', 'use.point'); } Plotly.newPlot(gd, fig) diff --git a/test/jasmine/tests/scatter_symbol_perf_test.js b/test/jasmine/tests/scatter_symbol_perf_test.js new file mode 100644 index 00000000000..6e305aa530f --- /dev/null +++ b/test/jasmine/tests/scatter_symbol_perf_test.js @@ -0,0 +1,221 @@ +'use strict'; + +var Plotly = require('../../../lib/index'); +var Drawing = require('../../../src/components/drawing'); +var createGraphDiv = require('../assets/create_graph_div'); +var destroyGraphDiv = require('../assets/destroy_graph_div'); +var d3Select = require('../../strict-d3').select; +var d3SelectAll = require('../../strict-d3').selectAll; + +describe('lookupSymbol .n property', function() { + it('matches the legacy numeric input exactly', function() { + // The .n property must equal what the user types as symbol:N + // so there are zero doubts about a consistent design. + var cases = [ + // numeric inputs + {v: 0, n: 0}, + {v: 1, n: 1}, + {v: 100, n: 100}, + {v: 101, n: 101}, + {v: 200, n: 200}, + {v: 201, n: 201}, + {v: 300, n: 300}, + {v: 301, n: 301}, + // string inputs resolve to the same n as their numeric equivalent + {v: 'circle', n: 0}, + {v: 'circle-open', n: 100}, + {v: 'circle-dot', n: 200}, + {v: 'circle-open-dot', n: 300}, + {v: 'square', n: 1}, + {v: 'square-open', n: 101}, + {v: 'square-dot', n: 201}, + {v: 'square-open-dot', n: 301}, + ]; + cases.forEach(function(c) { + var sym = Drawing.lookupSymbol(c.v); + expect(sym).toBeTruthy('lookupSymbol(' + c.v + ') should return a symbol object'); + expect(sym.n).toBe(c.n, 'lookupSymbol(' + c.v + ').n'); + }); + }); + + it('open variants share the same SVG path as their closed counterpart', function() { + expect(Drawing.lookupSymbol(0).path).toBe(Drawing.lookupSymbol(100).path, + 'circle and circle-open share path'); + expect(Drawing.lookupSymbol(200).path).toBe(Drawing.lookupSymbol(300).path, + 'circle-dot and circle-open-dot share path'); + expect(Drawing.lookupSymbol(1).path).toBe(Drawing.lookupSymbol(101).path, + 'square and square-open share path'); + }); + + it('all four variant .n values are distinct for the same base symbol', function() { + var ns = [0, 100, 200, 300].map(function(v) { return Drawing.lookupSymbol(v).n; }); + expect(ns).toEqual([0, 100, 200, 300], 'all four circle variant n values are distinct'); + }); +}); + +describe('Marker symbol performance', function() { + var gd; + + beforeEach(function() { gd = createGraphDiv(); }); + afterEach(destroyGraphDiv); + + function getUseHref(useEl) { + return useEl.getAttribute('href') || + useEl.getAttributeNS('http://www.w3.org/1999/xlink', 'href'); + } + + it('should use + with 1 symbol def for 1000 identical markers', function(done) { + var N = 1000; + var x = [], y = []; + for(var i = 0; i < N; i++) { x.push(i); y.push(Math.sin(i / 50)); } + + Plotly.newPlot(gd, [{ + mode: 'markers', + x: x, y: y, + marker: { symbol: 'circle', size: 8 } + }]).then(function() { + var defs = d3Select(gd).select('defs'); + var symbolDefs = defs.selectAll('symbol'); + expect(symbolDefs.size()).toBe(1, 'only 1 definition'); + + var useEls = d3Select(gd).selectAll('use.point'); + expect(useEls.size()).toBe(N, N + ' elements'); + + // No should exist + var pathPts = d3Select(gd).selectAll('path.point'); + expect(pathPts.size()).toBe(0, 'no point elements'); + }).then(done, done.fail); + }); + + it('should produce small SVG with 10 distinct symbols over 1000 points', function(done) { + var N = 1000; + var symbols = ['circle', 'square', 'diamond', 'cross', 'x', + 'triangle-up', 'triangle-down', 'pentagon', 'hexagon', 'star']; + var x = [], y = [], sym = []; + for(var i = 0; i < N; i++) { + x.push(i); y.push(Math.sin(i / 50)); + sym.push(symbols[i % symbols.length]); + } + + Plotly.newPlot(gd, [{ + mode: 'markers', + x: x, y: y, + marker: { symbol: sym, size: 10 } + }]).then(function() { + var svgEl = gd.querySelector('.main-svg'); + var svgStr = new XMLSerializer().serializeToString(svgEl); + var byteSize = new Blob([svgStr]).size; + + // With , 10 symbol defs + 1000 refs should be much smaller + // than 1000 full elements + expect(byteSize).toBeLessThan(400000, 'SVG byte size under 400KB'); + + var symbolDefs = d3Select(gd).select('defs').selectAll('symbol'); + expect(symbolDefs.size()).toBe(10, '10 definitions'); + }).then(done, done.fail); + }); + + it('should re-render on marker size change without new symbol def', function(done) { + var N = 1000; + var x = [], y = []; + for(var i = 0; i < N; i++) { x.push(i); y.push(Math.sin(i / 50)); } + + Plotly.newPlot(gd, [{ + mode: 'markers', + x: x, y: y, + marker: { symbol: 'square', size: 8 } + }]).then(function() { + // Capture current href — it shouldn't change on resize + var firstUse = gd.querySelector('use.point'); + var hrefBefore = getUseHref(firstUse); + var scaleBefore = parseFloat(firstUse.getAttribute('data-scale')); + + return Plotly.restyle(gd, { 'marker.size': 16 }).then(function() { + var firstUseAfter = gd.querySelector('use.point'); + var hrefAfter = getUseHref(firstUseAfter); + var scaleAfter = parseFloat(firstUseAfter.getAttribute('data-scale')); + + expect(hrefAfter).toBe(hrefBefore, 'href unchanged — no new symbol def needed'); + // Scale should have increased (size 16 → scale 0.8 vs size 8 → scale 0.4) + expect(scaleAfter).toBeGreaterThan(scaleBefore + 0.001, 'scale increased after size restyle'); + }); + }).then(done, done.fail); + }); + + it('should apply vector-effect: non-scaling-stroke to marker elements', function(done) { + Plotly.newPlot(gd, [{ + mode: 'markers', + x: [1, 2, 3], y: [1, 2, 3], + marker: { symbol: 'circle', size: 20, line: { width: 2, color: 'red' } } + }]).then(function() { + var useEls = d3SelectAll(gd.querySelectorAll('use.point')); + useEls.each(function() { + var ve = this.style.vectorEffect || d3Select(this).style('vector-effect'); + expect(ve).toBe('non-scaling-stroke', 'non-scaling-stroke applied'); + }); + }).then(done, done.fail); + }); + + it('symbol:0 and symbol:100 each get their own with descriptive ids', function(done) { + // Each variant (closed / open / dot / open-dot) gets its own element, + // even though open variants share the same SVG path as the closed counterpart. + // The open/closed distinction is still CSS-only (fill:none vs filled). + Plotly.newPlot(gd, [{ + mode: 'markers', + x: [1, 2, 3, 4], + y: [1, 2, 3, 4], + marker: { symbol: [0, 100, 0, 100], size: 10 } + }]).then(function() { + var defs = d3Select(gd).select('defs'); + var symbolDefs = defs.selectAll('symbol'); + expect(symbolDefs.size()).toBe(2, '2 defs: one for circle, one for circle-open'); + + var ids = []; + symbolDefs.each(function() { ids.push(this.getAttribute('id')); }); + expect(ids.sort()).toEqual(['circle', 'circle-open'], 'ids are "circle" and "circle-open"'); + + var useEls = gd.querySelectorAll('use.point'); + expect(useEls.length).toBe(4, '4 elements'); + // Even-index points use symbol:0 → #circle; odd-index → symbol:100 → #circle-open + for(var i = 0; i < useEls.length; i++) { + var href = getUseHref(useEls[i]); + expect(href).toBe(i % 2 === 0 ? '#circle' : '#circle-open', 'href matches variant'); + } + }).then(done, done.fail); + }); + + it('symbol:200/300 each get their own with descriptive ids', function(done) { + Plotly.newPlot(gd, [{ + mode: 'markers', + x: [1, 2], + y: [1, 2], + marker: { symbol: [200, 300], size: 10 } + }]).then(function() { + var defs = d3Select(gd).select('defs'); + var symbolDefs = defs.selectAll('symbol'); + expect(symbolDefs.size()).toBe(2, '2 defs: circle-dot and circle-open-dot'); + + var ids = []; + symbolDefs.each(function() { ids.push(this.getAttribute('id')); }); + expect(ids.sort()).toEqual(['circle-dot', 'circle-open-dot'], 'ids are "circle-dot" and "circle-open-dot"'); + }).then(done, done.fail); + }); + + it('all four variants of a symbol get their own with correct ids', function(done) { + Plotly.newPlot(gd, [{ + mode: 'markers', + x: [1, 2, 3, 4], + y: [1, 2, 3, 4], + marker: { symbol: ['square', 'square-open', 'square-dot', 'square-open-dot'], size: 10 } + }]).then(function() { + var defs = d3Select(gd).select('defs'); + var symbolDefs = defs.selectAll('symbol'); + expect(symbolDefs.size()).toBe(4, '4 defs for all square variants'); + + var ids = []; + symbolDefs.each(function() { ids.push(this.getAttribute('id')); }); + expect(ids.sort()).toEqual(['square', 'square-dot', 'square-open', 'square-open-dot'], + 'ids cover all four variants'); + }).then(done, done.fail); + }); +}); diff --git a/test/jasmine/tests/scatter_test.js b/test/jasmine/tests/scatter_test.js index 4c27d0a9bb8..61c3b621e8a 100644 --- a/test/jasmine/tests/scatter_test.js +++ b/test/jasmine/tests/scatter_test.js @@ -27,9 +27,8 @@ var getOpacity = function(node) { return Number(node.style.opacity); }; var getFillOpacity = function(node) { return Number(node.style['fill-opacity']); }; var getColor = function(node) { return node.style.fill; }; var getMarkerSize = function(node) { - // find path arc multiply by 2 to get the corresponding marker.size value - // (works for circles only) - return d3Select(node).attr('d').split('A')[1].split(',')[0] * 2; + // data-scale = r/20, marker.size = r*2, so size = data-scale * 40 + return Number(node.getAttribute('data-scale')) * 40; }; describe('Test scatter', function() {