简体   繁体   中英

Convert SVG Path d attribute to a array of points

When I can create a line as follows:

var lineData = [{ "x": 50, "y": 50 }, {"x": 100,"y": 100}, {"x": 150,"y": 150}, {"x": 200, "y": 200}];
var lineFunction = d3.svg.line()
   .x(function(d) { return d.x; })
   .y(function(d) { return d.y; })
   .interpolate("basis");
var myLine = lineEnter.append("path")
   .attr("d", lineFunction(lineData))

Now I want to add a text to the second point of this lineArray:

lineEnter.append("text").text("Yaprak").attr("y", function(d){ 
console.log(d); // This is null
console.log("MyLine");
console.log(myLine.attr("d")) // This is the string given below, unfortunately as a String
// return lineData[1].x
return 10;

} );

Output of the line console.log(myLine.attr("d")) :

M50,50L58.33333333333332,58.33333333333332C66.66666666666666,66.66666666666666,83.33333333333331,83.33333333333331,99.99999999999999,99.99999999999999C116.66666666666666,116.66666666666666,133.33333333333331,133.33333333333331,150,150C166.66666666666666,166.66666666666666,183.33333333333331,183.33333333333331,191.66666666666663,191.66666666666663L200,200

I can get the path data in string format. Can I convert this data back to lineData array? Or, is there any other and simple way to regenerate or get the lineData when appending a text?

Please refer to this JSFiddle .

TheSVGPathElement API has built-in methods for getting this info. You do not need to parse the data-string yourself.

Since you stored a selection for your line as a variable, you can easily access the path element's api using myLine.node() to refer to the path element itself.

For example:

var pathElement = myLine.node();

Then you can access the list of commands used to construct the path by accessing the pathSegList property:

var pathSegList = pathElement.pathSegList;

Using the length property of this object, you can easily loop through it to get the coordinates associated with each path segment:

for (var i = 0; i < pathSegList.length; i++) {
  console.log(pathSegList[i]);
}

Inspecting the console output, you will find that each path segment has properties for x and y representing the endpoint of that segment. For bezier curves, arcs, and the like, the control points are also given as x1 , y1 , x2 , and y2 as necessary.

In your case, regardless of whether you use this method or choose to parse the string yourself, you will run into difficulties because you used interpolate('basis') for your line interpolation. Therefore, the line generator outputs 6 commands (in your specific case) rather than 4, and their endpoints do not always correspond to the original points in the data. If you use interpolate('linear') you will be able to reconstruct the original dataset, since the linear interpolation has a one-to-one correspondence with the path data output.

Assuming you used linear interpolation, reconstructing the original dataset could be done as follows:

var pathSegList = myLine.node().pathSegList;

var restoredDataset = [];

// loop through segments, adding each endpoint to the restored dataset
for (var i = 0; i < pathSegList.length; i++) {
  restoredDataset.push({
    "x": pathSegList[i].x,
    "y": pathSegList[i].y
  })
}

EDIT:

As far as using the original data when appending text... I'm assuming you are looking to append labels to the points, there's no need to go through all the trouble of reconstructing the data. In fact the real issue is that you never used data-binding in the first place to make your line graph. Try binding the data using the .datum() method for your path, and using the .data() method for the labels. Also you might want to rename lineEnter since you're not using an enter selection and it simply represents a group. For example:

// THIS USED TO BE CALLED `lineEnter`
var lineGroup = svgContainer.append("g");

var myLine = lineGroup.append("path")
    // HERE IS WHERE YOU BIND THE DATA FOR THE PATH
    .datum(lineData)
    // NOW YOU SIMPLY CALL `lineFunction` AND THE BOUND DATA IS USED AUTOMATICALLY
    .attr("d", lineFunction)
    .attr("stroke", "blue")
    .attr("stroke-width", 2)
    .attr("fill", "none");

// FOR THE LABELS, CREATE AN EMPTY SELECTION
var myLabels = lineGroup.selectAll('.label')
    // FILTER THE LINE DATA SINCE YOU ONLY WANT THE SECOND POINT
    .data(lineData.filter(function(d,i) {return i === 1;})
    // APPEND A TEXT ELEMENT FOR EACH ELEMENT IN THE ENTER SELECTION
    .enter().append('text')
    // NOW YOU CAN USE THE DATA TO SET THE POSITION OF THE TEXT
    .attr('x', function(d) {return d.x;})
    .attr('y', function(d) {return d.y;})
    // FINALLY, ADD THE TEXT ITSELF
    .text('Yaprak')

You can break the line into individual commands by splitting the string on the L , M , and C characters:

var str = "M50,50L58.33333333333332,58.33333333333332C66.66666666666666,
  66.66666666666666,83.33333333333331,83.33333333333331,
  99.99999999999999,99.99999999999999C116.66666666666666,116.66666666666666,
  133.33333333333331,133.33333333333331,150,150C166.66666666666666,
  166.66666666666666,183.33333333333331,183.33333333333331,191.66666666666663,
  191.66666666666663L200,200"

var commands = str.split(/(?=[LMC])/);

This gives the sequence of commands that are used to render the path. Each will be a string comprised of a character (L, M, or C) followed by a bunch of numbers separated by commas. They will look something like this:

"C66.66666666666666,66.66666666666666,83.33333333333331,
83.33333333333331,99.99999999999999,99.99999999999999"

That describes a curve through three points, [66,66], [83,83], and [99,99]. You can process these into arrays of pairs points with another split command and a loop, contained in a map:

var pointArrays = commands.map(function(d){
    var pointsArray = d.slice(1, d.length).split(',');
    var pairsArray = [];
    for(var i = 0; i < pointsArray.length; i += 2){
        pairsArray.push([+pointsArray[i], +pointsArray[i+1]]);
    }
    return pairsArray;
});

This will return an array containing each command as an array of length-2 arrays, each of which is an (x,y) coordinate pair for a point in the corresponding part of the path.

You could also modify the function in map to return object that contain both the command type and the points in the array.

EDIT : If you want to be able to access lineData , you can add it as data to a group, and then append the path to the group, and the text to the group.

var group = d3.selectAll('g').data([lineData])
  .append('g');

var myLine = group.append('path')
  .attr('d', function(d){ return lineFunction(d); });

var myText = group.append('text')
  .attr('text', function(d){ return 'x = ' + d[1][0]; });

This would be a more d3-esque way of accessing the data than reverse-engineering the path. Also probably more understandable.

More info on SVG path elements

A little hacky, but you can use animateMotion to animate an object (eg a rect or a circle) along the path and then sample the x/y position of the object. You will have to make a bunch of choices (eg how fast do you animated the object, how fast do you sample the x/y position, etc.). You could also run this process multiple times and take some kind of average or median.

Full code (see it in action: http://jsfiddle.net/mqmkc7xz/ )

<html>
  <body>
    <svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
      <path id="mypath"
      style="fill:none;stroke:#000000;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
      d="m 70,67 15,0 c 0,0 -7.659111,-14.20627 -10.920116,-27.28889 -3.261005,-13.08262 9.431756,-13.85172 6.297362,-15.57166 -3.134394,-1.71994 -7.526366,-1.75636 -2.404447,-3.77842 3.016991,-1.19107 9.623655,-5.44678 0.801482,-9.67404 C 76.821958,10 70,10 70,10"
      />
    </svg>
    <div id="points"></div>
    <script>
    /**
     * Converts a path into an array of points.
     *
     * Uses animateMotion and setInterval to "steal" the points from the path.
     * It's very hacky and I have no idea how well it works.
     *
     * @param SVGPathElement  path to convert
     * @param int             approximate number of points to read
     * @param callback        gets called once the data is ready
     */
    function PathToPoints(path, resolution, onDone) {
      var ctx = {};
      ctx.resolution = resolution;
      ctx.onDone = onDone;
      ctx.points = [];
      ctx.interval = null;

      // Walk up nodes until we find the root svg node
      var svg = path;
      while (!(svg instanceof SVGSVGElement)) {
        svg = svg.parentElement;
      }
      // Create a rect, which will be used to trace the path

      var rect = document.createElementNS("http://www.w3.org/2000/svg", "rect");
      ctx.rect = rect;
      svg.appendChild(rect);

      var motion = document.createElementNS("http://www.w3.org/2000/svg", "animateMotion");
      motion.setAttribute("path", path.getAttribute("d"));
      motion.setAttribute("begin", "0");
      motion.setAttribute("dur", "3"); // TODO: set this to some larger value, e.g. 10 seconds?
      motion.setAttribute("repeatCount", "1");
      motion.onbegin = PathToPoints.beginRecording.bind(this, ctx);
      motion.onend = PathToPoints.stopRecording.bind(this, ctx);

      // Add rect
      rect.appendChild(motion);
    }

    PathToPoints.beginRecording = function(ctx) {
      var m = ctx.rect.getScreenCTM();
      ctx.points.push({x: m.e, y: m.f});
      ctx.interval = setInterval(PathToPoints.recordPosition.bind(this, ctx), 1000*3/ctx.resolution);
    }

    PathToPoints.stopRecording = function(ctx) {
      clearInterval(ctx.interval);

      // Remove the rect
      ctx.rect.remove();

      ctx.onDone(ctx.points);
    }

    PathToPoints.recordPosition = function(ctx) {
      var m = ctx.rect.getScreenCTM();
      ctx.points.push({x: m.e, y: m.f});
    }
    PathToPoints(mypath, 100, function(p){points.textContent = JSON.stringify(p)});
    </script>
  </body>
</html>

pathSegList is supported in old Chrome and is removed since Chrome 48.
But Chrome has not implemented the new API .

Use path seg polyfill to work with old API.

Use path data polyfill to work with new API . It's recommended.

var path = myLine.node();
//Be sure you have added the pathdata polyfill to your page before use getPathData
var pathdata = path.getPathData();
console.log(pathdata);
//you will get an Array object contains all path data details
//like this:
[
    {
        "type": "M",
        "values": [ 50, 50 ]
    },
    {
        "type": "L",
        "values": [ 58.33333333333332, 58.33333333333332 ]
    },
    {
        "type": "C",
        "values": [ 66.66666666666666, 66.66666666666666, 83.33333333333331, 83.33333333333331, 99.99999999999999, 99.99999999999999 ]
    },
    {
        "type": "C",
        "values": [ 116.66666666666666, 116.66666666666666, 133.33333333333331, 133.33333333333331, 150, 150 ]
    },
    {
        "type": "C",
        "values": [ 166.66666666666666, 166.66666666666666, 183.33333333333331, 183.33333333333331, 191.66666666666663, 191.66666666666663 ]
    },
    {
        "type": "L",
        "values": [ 200, 200 ]
    }
]

I found this question by Google. What I needed was simply the pathSegList property of a SVG path object:

var points = pathElement.pathSegList;

Every point looks like

y: 57, x: 109, pathSegTypeAsLetter: "L", pathSegType: 4, PATHSEG_UNKNOWN: 0…}

See

I've successfully used this to render a list of x,y points:

https://shinao.github.io/PathToPoints/

Code is more than what I could fit into this textbox, but here is probably a good start: https://github.com/Shinao/PathToPoints/blob/master/js/pathtopoints.js#L209

Expanding on @cuixiping answer : getPathData() also includes a normalization option:

getPathData({normalize:true}) that will convert relative and shorthand commands to use only M , L , C and z .

So you don't have to worry about highly optimized/minified d strings (containing relative commands, shorthands etc).

 let pathData = path1.getPathData({ normalize: true }); let lineData = pathDataToPoints(pathData); pointsOut.value=JSON.stringify(lineData, null, '\t') /** * create point array * from path data **/ function pathDataToPoints(pathData) { let points = []; pathData.forEach((com) => { let values = com.values; let valuesL = values.length; // the last 2 coordinates represent a segments end point if (valuesL) { let p = { x: values[valuesL - 2], y: values[valuesL - 1] }; points.push(p); } }); return points; } /** * render points from array * just for illustration **/ renderPoints(svg, lineData); function renderPoints(svg, points) { points.forEach(point=>{ renderPoint(svg, point); }) } function renderPoint(svg, coords, fill = "red", r = "2") { if (Array.isArray(coords)) { coords = { x: coords[0], y: coords[1] }; } let marker = `<circle cx="${coords.x}" cy="${coords.y}" r="${r}" fill="${fill}"> <title>${coords.x} ${coords.y}</title></circle>`; svg.insertAdjacentHTML("beforeend", marker); }
 svg{ width:20em; border:1px solid red; overflow:visible; } path{ stroke:#000; stroke-width:1 } textarea{ width:100%; min-height:20em }
 <svg id="svg" viewBox='0 0 250 250'> <path id="path1" d="M50 50l8.33 8.33c8.33 8.33 25 25 41.67 41.67s33.33 33.33 50 50s33.33 33.33 41.67 41.67l8.33 8.33" stroke="#000" /> </svg> <h3>Points</h3> <textarea id="pointsOut"></textarea> <script src="https://cdn.jsdelivr.net/npm/path-data-polyfill@1.0.4/path-data-polyfill.min.js"></script>

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM