Search code examples
javascriptd3.jsobservable

Path with rounded corners in Javascript d3js


I would like to create a rounded edge for a corner where the user can specify the corner's radius in D3js.

I found a post that has potential solutions, but the examples are in Observable notebook.

I tried converting to plain Javascript. But it didn't work for me.

https://observablehq.com/@carpiediem/svg-paths-with-circular-corners

Any help is much appreciated, thanks.


Solution

  • Here it is:

    const drag = () =>  {
                function dragstarted(d) {
                    d3.select(this).raise().attr("stroke", "black");
                }
    
                function dragged(d) {
                    d3.select(this)
                        .attr("cx", d.x = d3.event.x)
                        .attr("cy", d.y = d3.event.y);
                    d3.select('path.angled')
                        .attr('d', 'M' + points.map(d => `${d.x} ${d.y}`).join(','));
                        
                    const angle = Math.atan2(points[1].y-points[0].y, points[1].x-points[0].x)
                                - Math.atan2(points[1].y-points[2].y, points[1].x-points[2].x);
                    const acuteAngle = Math.min(Math.abs(angle), 2*Math.PI-Math.abs(angle));
                    const shortestRay = Math.min(
                        Math.sqrt(Math.pow(points[1].x-points[0].x, 2) + Math.pow(points[1].y-points[0].y, 2)),
                        Math.sqrt(Math.pow(points[1].x-points[2].x, 2) + Math.pow(points[1].y-points[2].y, 2))
                    );
                    const radiusToUse = Math.min( cornerRadius, shortestRay * Math.tan(acuteAngle/2) );
                    const distanceToTangentPoint = Math.abs(radiusToUse / Math.tan(acuteAngle/2));
                    const determinant = (points[1].x-points[0].x)*(points[1].y-points[2].y) - (points[1].x-points[2].x)*(points[1].y-points[0].y);
                    const sweepFlag = determinant < 0 ? 1 : 0;
                    
                    const anchorIn = alongSegment(points[1], points[0], distanceToTangentPoint);
                    const anchorOut = alongSegment(points[1], points[2], distanceToTangentPoint);
                    const manualPathDesc = `
                        M${points[0].x} ${points[0].y}
                        L${anchorIn.x} ${anchorIn.y}
                        A${radiusToUse} ${radiusToUse} 0 0 ${sweepFlag} ${anchorOut.x} ${anchorOut.y}
                        L${points[2].x} ${points[2].y}
                    `;
                    
                    d3.select('path.arced').attr('d', manualPathDesc);
                    
                    d3.select('rect.anchor.in')
                        .attr("x", anchorIn.x - 3)
                        .attr("y", anchorIn.y - 3);
    
                    d3.select('rect.anchor.out')
                        .attr("x", anchorOut.x - 3)
                        .attr("y", anchorOut.y - 3);
                    
                    const circleCenter = alongSegment(
                        points[1],
                        { x: (anchorIn.x + anchorOut.x)/2, y: (anchorIn.y + anchorOut.y)/2 },
                        Math.sqrt(Math.pow(radiusToUse, 2) + Math.pow(distanceToTangentPoint, 2))
                    );
    
                    d3.select('path.triangles')
                        .attr("d", `M${points[1].x} ${points[1].y} L${circleCenter.x} ${circleCenter.y} L${anchorIn.x} ${anchorIn.y} L${circleCenter.x} ${circleCenter.y} L${anchorOut.x} ${anchorOut.y}`);
                    
                    d3.select('text.angle').text(`${Math.round(acuteAngle * 180 / Math.PI)}°`);
                    d3.select('text.shortest').text(Math.round(shortestRay));
                    d3.select('text.maxradius').text(Math.round(shortestRay * Math.tan(acuteAngle/2)));
                    d3.select('text.toAnchor').text(Math.round(distanceToTangentPoint));
                    d3.select('text.determinate').text(determinant < 0 ? 'neg.' : 'pos.');
                }
    
                function dragended(d) {
                    d3.select(this).attr("stroke", null);
                }
    
                return d3.drag()
                    .on("start", dragstarted)
                    .on("drag", dragged)
                    .on("end", dragended);
            }
            
            function alongSegment(from, toward, distanceAlong) {
                const bearing = Math.atan2(from.y-toward.y, from.x-toward.x);
                return {
                    bearing,
                    x: from.x - distanceAlong * Math.cos(bearing),
                    y: from.y - distanceAlong * Math.sin(bearing)
                };
            }
    
            const chart = () => {
    
                var color = d3.scaleOrdinal().range(d3.schemeCategory20);
                
                
                const svg = d3.select("svg")
                            .attr("viewBox", [0, 0, width, height]);
    
                const angle = Math.atan2(points[1].y-points[0].y, points[1].x-points[0].x)
                            - Math.atan2(points[1].y-points[2].y, points[1].x-points[2].x);
                const acuteAngle = Math.min(Math.abs(angle), 2*Math.PI-Math.abs(angle));
                const shortestRay = Math.min(
                    Math.sqrt(Math.pow(points[1].x-points[0].x, 2) + Math.pow(points[1].y-points[0].y, 2)),
                    Math.sqrt(Math.pow(points[1].x-points[2].x, 2) + Math.pow(points[1].y-points[2].y, 2))
                );
                const radiusToUse = Math.min( cornerRadius, shortestRay * Math.tan(acuteAngle/2) );
                const distanceToTangentPoint = Math.abs(radiusToUse / Math.tan(acuteAngle/2));
                const determinant = (points[1].x-points[0].x)*(points[1].y-points[2].y) - (points[1].x-points[2].x)*(points[1].y-points[0].y);
                const sweepFlag = determinant < 0 ? 1 : 0;
                
                const anchorIn = alongSegment(points[1], points[0], distanceToTangentPoint);
                const anchorOut = alongSegment(points[1], points[2], distanceToTangentPoint);
                const circleCenter = alongSegment(
                    points[1],
                    { x: (anchorIn.x + anchorOut.x)/2, y: (anchorIn.y + anchorOut.y)/2 },
                    Math.sqrt(Math.pow(radiusToUse, 2) + Math.pow(distanceToTangentPoint, 2))
                );
                
                const manualPathDesc = `M${points[0].x} ${points[0].y} 
                    L${anchorIn.x} ${anchorIn.y}
                    A${radiusToUse} ${radiusToUse} 0 0 ${sweepFlag} ${anchorOut.x} ${anchorOut.y}
                    L${points[2].x} ${points[2].y}
                `;
                
                svg.append('rect')
                    .attr("x", 8)
                    .attr("y", 10)
                    .attr("width", 160)
                    .attr("height", 105)
                    .attr("fill", '#eee');
    
                svg.append('text')
                    .attr("class", 'angle')
                    .attr("x", 35)
                    .attr("y", 25)
                    .attr("text-anchor", "end")
                    .text(`${Math.round(acuteAngle * 180 / Math.PI)}°`);
                svg.append('text')
                    .attr("class", 'shortest')
                    .attr("x", 35)
                    .attr("y", 45)
                    .attr("text-anchor", "end")
                    .text(Math.round(shortestRay));
                svg.append('text')
                    .attr("class", 'maxradius')
                    .attr("x", 35)
                    .attr("y", 65)
                    .attr("text-anchor", "end")
                    .text(Math.round(shortestRay * Math.tan(acuteAngle/2)));
                
                svg.append('text')
                    .attr("class", 'toAnchor')
                    .attr("x", 35)
                    .attr("y", 85)
                    .attr("text-anchor", "end")
                    .text(Math.round(distanceToTangentPoint));
    
                svg.append('text')
                    .attr("class", 'determinate')
                    .attr("x", 35)
                    .attr("y", 105)
                    .attr("text-anchor", "end")
                    .text(determinant < 0 ? 'neg.' : 'pos.');
    
                svg.append('text')
                    .attr("x", 40)
                    .attr("y", 25)
                    .attr("text-anchor", "start")
                    .text('angle between rays');
                svg.append('text')
                    .attr("x", 40)
                    .attr("y", 45)
                    .attr("text-anchor", "start")
                    .text('length of shortest ray');
    
                svg.append('text')
                    .attr("x", 40)
                    .attr("y", 65)
                    .attr("text-anchor", "start")
                    .text('max radius, to fit');
                svg.append('text')
                    .attr("x", 40)
                    .attr("y", 85)
                    .attr("text-anchor", "start")
                    .text('from vertex to anchors');
                
                svg.append('text')
                    .attr("x", 40)
                    .attr("y", 105)
                    .attr("text-anchor", "start")
                    .text('determinant value');
    
                svg.append('path')
                    .attr("class", 'arced')
                    .datum(points)
                    .attr("d", manualPathDesc)
                    .attr("stroke", 'orange')
                    .attr("stroke-width", 5)
                    .attr("fill", 'none');
    
                svg.append('path')
                    .attr("class", 'angled')
                    .attr("d", 'M' + points.map(d => `${d.x} ${d.y}`).join(', '))
                    .attr("stroke", '#888')
                    .attr("fill", 'none');
                svg.append('rect')
                    .attr("class", 'anchor in')
                    .attr("x", anchorIn.x - 3)
                    .attr("y", anchorIn.y - 3)
                    .attr("width", 6)
                    .attr("height", 6)
                    .attr("fill", '#888');
    
                svg.append('rect')
                    .attr("class", 'anchor out')
                    .attr("x", anchorOut.x - 3)
                    .attr("y", anchorOut.y - 3)
                    .attr("width", 6)
                    .attr("height", 6)
                    .attr("fill", '#ccc');
                svg.append('path')
                    .attr("class", 'triangles')
                    .attr("d", `M${points[1].x} ${points[1].y} L${circleCenter.x} ${circleCenter.y} L${anchorIn.x} ${anchorIn.y} L${circleCenter.x} ${circleCenter.y} L${anchorOut.x} ${anchorOut.y}`)
                    .attr("stroke", '#ccc')
                    .attr("fill", 'none');
    
                svg.selectAll("circle")
                    .data(points)
                    .enter()
                    .append("circle")
                    .attr("cx", d => d.x)
                    .attr("cy", d => d.y)
                    .attr("r", 6)
                    .attr("fill", (d, i) => color(i))
                    .on("mouseover", function (d) {d3.select(this).style("cursor", "move");})
                    .on("mouseout", function (d) {})
                    .call(drag());
    
                return svg.node();
            }    
    
            const width = 1000;
            const height = 600;
            const cornerRadius = 50;
    
            const points = d3.range(3).map(i => ({
                    x: Math.random() * (width - 10 * 2) + 10,
                    y: Math.random() * (300 - 10 * 2) + 10,
                }));
    
            chart();
    <script src="https://cdnjs.cloudflare.com/ajax/libs/d3/4.13.0/d3.min.js" integrity="sha512-RJJ1NNC88QhN7dwpCY8rm/6OxI+YdQP48DrLGe/eSAd+n+s1PXwQkkpzzAgoJe4cZFW2GALQoxox61gSY2yQfg==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
    <svg width="1000" height="600"></svg>