Как нарисовать сплайн с разной шириной штриха в JS

Я могу нарисовать сплайн с помощью d3 без проблем.

<html>
<head>
    <script src = "https://d3js.org/d3.v7.min.js"></script>
    <style>
        .graph-container {
            width: 100%;
            height: 800px;
            position: relative;
        }
    </style>
</head>
<body>
    <div id = "graph-container" class = "graph-container"></div>
    <script>
        // Define the data points for the spline curve
        const data = [
            { x: 0, y: 80, r: 12 },
            { x: 100, y: 100, r: 40 },
            { x: 200, y: 30, r: 6 },
            { x: 300, y: 50, r: 8 },
            { x: 400, y: 40, r: 10 },
            { x: 500, y: 80, r: 12 },
        ];

        // Set up the SVG container
        const svgWidth = 600;
        const svgHeight = 200;
        const svg = d3.select('#graph-container')  // Select the graph-container div
            .append('svg')
            .attr('width', svgWidth)
            .attr('height', svgHeight);

        // Create the spline generator
        const line = d3.line()
            .x((d) => d.x)
            .y((d) => d.y)
//            .curve(d3.curveMonotoneX);
            .curve(d3.curveCardinal);

        // Draw the spline curve
        svg.append('path')
            .datum(data)
            .attr('d', line)
            .attr('fill', 'none')
            .attr('stroke', 'green')
            .attr('stroke-width', (d) => d.r);
    </script>
</body>
</html>

Моя проблема состоит в том, чтобы нарисовать каждый сегмент с разной толщиной (шириной штриха). Я хочу, чтобы каждый сегмент линии плавно переходил к следующей контрольной точке.

Кажется, что d3 .attr('stroke-width', (d) => d.r) не работает. Как я могу сделать это в React JS?

Спасибо,

Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
В JavaScript одним из самых запутанных понятий является поведение ключевого слова "this" в стрелочной и обычной функциях.
Концепция локализации и ее применение в приложениях React ⚡️
Концепция локализации и ее применение в приложениях React ⚡️
Локализация - это процесс адаптации приложения к различным языкам и культурным требованиям. Это позволяет пользователям получить опыт, соответствующий...
Навигация по приложениям React: Исчерпывающее руководство по React Router
Навигация по приложениям React: Исчерпывающее руководство по React Router
React Router стала незаменимой библиотекой для создания одностраничных приложений с навигацией в React. В этой статье блога мы подробно рассмотрим...
Массив зависимостей в React
Массив зависимостей в React
Все о массиве Dependency и его связи с useEffect.
3
0
67
2
Перейти к ответу Данный вопрос помечен как решенный

Ответы 2

Ответ принят как подходящий

Вот сплайн, построенный как аппроксимированная мультилиния:

const data = [
  { x: 0, y: 80, r: 12 },
  { x: 100, y: 100, r: 30 },
  { x: 200, y: 30, r: 6 },
  { x: 300, y: 50, r: 8 },
  { x: 400, y: 40, r: 10 },
  { x: 500, y: 80, r: 12 },
];

const rValueByX = (x) => {
    if (x === data[0].x) {
    return data[0].r;
  }

  const index = data.findIndex(item => item.x >= x);
  const prevX = data[index - 1].x;
  const nextX = data[index].x;
  const delta = (x - prevX) / (nextX - prevX);
  const prevR = data[index - 1].r;
  const nextR = data[index].r;
  return (nextR - prevR) * delta + prevR;
}

// Set up the SVG container
const svgWidth = 600;
const svgHeight = 150;
const svg = d3.select('#graph-container')
  .append('svg')
  .attr('width', svgWidth)
  .attr('height', svgHeight);

const line = d3.line()
  .x((d) => d.x)
  .y((d) => d.y)
  .curve(d3.curveCardinal);
            
const path = svg.append('path')
  .attr('d', line(data))
  .attr('fill', 'none')
  .attr('stroke', 'green');
  
const total = path.node().getTotalLength();
const step = total / 100;

for (let len = 0; len <= total; len += step) {
  const fromLen = Math.max(0, len - step);
  const toLen = Math.min(len + step, total);
  const point = path.node().getPointAtLength(len);
  const r = rValueByX(point.x);
  const from = path.node().getPointAtLength(fromLen);
  const to = path.node().getPointAtLength(toLen);
  
  svg.append('line')
    .attr('x1', from.x)
    .attr('y1', from.y)
    .attr('x2', to.x)
    .attr('y2', to.y)
    .attr('stroke-width', r)
    .attr('stroke', 'green');
}
<script src = "https://d3js.org/d3.v7.min.js"></script>
<div id = "graph-container"/>

Отлично! Да, кажется, что рисование мультилинии — лучшее приближение к тому, чего я хочу достичь. Большое спасибо.

Chang L. 30.05.2023 00:49

На основе rValueByX() помощника по интерполяции Майкла Ровински вы также можете нарисовать 2 сплайна и соединить их.

Редактировать: добавить значения r непосредственно в y

мы можем даже еще больше упростить сценарий, так как нам не нужны никакие интерполированные значения r для сплайна Эрмита.

let data = [
  { x: 0, y: 80, r: 12 },
  { x: 100, y: 100, r: 30 },
  { x: 200, y: 30, r: 6 },
  { x: 300, y: 50, r: 8 },
  { x: 400, y: 40, r: 10 },
  { x: 500, y: 80, r: 12 }
];



// Set up the SVG container
const svgWidth = 600;
const svgHeight = 150;
// last index
const dataL = data.length-1;

const svg = d3
  .select("#graph-container")
  .append("svg")
  .attr("width", svgWidth)
  .attr("height", svgHeight);

const lineTop = d3
  .line()
  .x( d => d.x)
  .y( d => {
    return d.y - d.r/ 2;
  })
  .curve(d3.curveCardinal);


const lineBottom = d3
  .line()
  .x((d, i) => data[dataL-i].x)
  .y( (d,i) => data[dataL-i].y +  data[dataL-i].r  / 2)
  .curve(d3.curveCardinal);

const lineMiddle= d3
  .line()
  .x( d => d.x)
  .y( d => d.y )
  .curve(d3.curveCardinal);


const path = svg
  .append("path")
  // concatenate paths
  .attr("d", lineTop(data) + (lineBottom(data).replaceAll('M', 'L') +'z' ) )
  .attr("fill", "green")
  .attr("fill-opacity", "0.5");


const pathMiddle = svg
  .append("path")
  .attr("d", lineMiddle(data))
  .attr("fill", "none")
  .attr("stroke", "green");
<script src = "https://d3js.org/d3.v7.min.js"></script>
<div id = "graph-container" />

На самом деле мы рисуем верхний и нижний сплайны, используя смещение по оси Y в соответствии с текущим r радиусом штриха (как определено в значении объекта данных r).

const lineTop = d3
  .line()
  .x( d => d.x)
  .y( d => {
    return d.y - d.r/ 2;
  })
  .curve(d3.curveCardinal);


const lineBottom = d3
  .line()
  .x((d, i) => data[dataL-i].x)
  .y( (d,i) => data[dataL-i].y +  data[dataL-i].r  / 2)
  .curve(d3.curveCardinal);

Нижний сплайн должен изменить координаты данных. Затем мы можем объединить оба сплайна в один путь:

const path = svg
  .append("path")
  // concatenate paths
  .attr("d", lineTop(data) + (lineBottom(data).replaceAll('M', 'L') +'z' ) )
  .attr("fill", "green")
  .attr("fill-opacity", "0.5");

Преимущество такого подхода — нам не нужно аппроксимировать форму множеством <line> элементов.

Стоит отметить: сотни вызовов getPointAtLength() могут значительно повлиять на производительность рендеринга.

Упрощенный подход Vanilla JS

Если вам не нужны все навороты d3.js, вы можете взглянуть на сообщение Милана Джи "Сплайн-интерполяция Catmull-Rom".

В следующем примере используется слегка измененная версия вспомогательной функции toCatmullRomBezier() Милана Г.

const data = [{
    x: 0,
    y: 80,
    r: 12
  },
  {
    x: 100,
    y: 100,
    r: 30
  },
  {
    x: 200,
    y: 30,
    r: 6
  },
  {
    x: 300,
    y: 50,
    r: 8
  },
  {
    x: 400,
    y: 40,
    r: 10
  },
  {
    x: 500,
    y: 80,
    r: 12
  },
];

let options = {
  decimals: 2
};
let dMiddle = toCatmullRomBezier(data, options);
pathMid.setAttribute('d', dMiddle)

let dStroke = drawStrokedCatmullSpline(data, options)
catmullPath.setAttribute('d', dStroke);


function drawStrokedCatmullSpline(data, options) {
  let dataTop = [];
  let dataBottom = [];

  for (let i = 0; i < data.length; i++) {
    let item = data[i];
    let itemRev = data[data.length - 1 - i];

    dataTop.push({
      x: item.x,
      y: item.y - item.r / 2
    });

    dataBottom.push({
      x: itemRev.x,
      y: itemRev.y + itemRev.r / 2
    });
  }

  let lineTop = toCatmullRomBezier(dataTop, options)
  let lineBottom = toCatmullRomBezier(dataBottom, options);

  // concatenate paths
  let dCombined = lineTop + lineBottom.replaceAll('M', 'L') + 'z';
  return dCombined;

}


/**
 * based on:
 * https://observablehq.com/@milangress/catmull-rom-spline-interpolation
 */
function toCatmullRomBezier(coords = [], options = {}) {

  // get options / set defaults
  options = {
    tension: options.tension ? options.tension : 0.5,
    closing: options.closing ? options.closing : false,
    decimals: options.decimals ? options.decimals : 3,
    returnPathData: options.returnPathData ? options.returnPathData : false
  }

  let {
    tension,
    closing,
    decimals,
    returnPathData
  } = options;

  // normalize coords array or object
  let points = [];
  if (Array.isArray(coords[0])) {
    for (let i = 0; i < coords.length; i += 2) {
      points.push([coords[i], coords[i + 1]]);
    }
  } else {
    for (let i = 0; i < coords.length; i++) {
      points.push([coords[i].x, coords[i].y]);
    }
  }

  // sets tension [0.0, 1.0] +/-
  let tens = tension !== 0 ? tension * 12 : 0.5 * 12;
  // duplicate First point to the end if the Path is not closed
  const PointList = closing ? [...copyFirstPointToLast(points)] : [...points];
  // Make sure Points have correct type
  const floats = PointList.map((x) => x.map((x) => parseFloat(x)));
  // Set starting point for SVG

  const M = {
    x: floats[0][0],
    y: floats[0][1]
  };

  const firstMoveto = ["M" + floats[0][0] + " " + floats[0][1] + " "];
  // Generate Point Matrix from points
  const matrixPoints = floats
    .map((point, i, arr) => {
      if (i == 0) {
        return getMatrix([arr[i], arr[i], arr[i + 1], arr[i + 2]]);
      } else if (i == arr.length - 2) {
        return getMatrix([arr[i - 1], arr[i], arr[i + 1], arr[i + 1]]);
      } else {
        return getMatrix([arr[i - 1], arr[i], arr[i + 1], arr[i + 2]]);
      }
    })
    .filter((mx) => mx[3] !== undefined);
  // some Matrix Multiplication for the Bezier points
  const matrixMathToBezier = matrixPoints.map((p) => {
    let points = [{
        x: p[1].x,
        y: p[1].y
      },
      {
        x: (-p[0].x + tens * p[1].x + p[2].x) / tens,
        y: (-p[0].y + tens * p[1].y + p[2].y) / tens
      },
      {
        x: (p[1].x + tens * p[2].x - p[3].x) / tens,
        y: (p[1].y + tens * p[2].y - p[3].y) / tens
      },
      {
        x: p[2].x,
        y: p[2].y
      }
    ];
    // round
    for (let i = 0; i < points.length; i++) {
      points[i].x = +points[i].x.toFixed(decimals);
      points[i].y = +points[i].y.toFixed(decimals);
    }
    return points;
  });


  // collect pathdata
  let pathData = [{
    type: 'M',
    values: [M.x, M.y]
  }];
  const toSVGNotation =
    matrixMathToBezier.map((bp) => {
      let d = ["C", bp[1].x, bp[1].y, bp[2].x, bp[2].y, bp[3].x, bp[3].y].join(' ');
      pathData.push({
        type: 'C',
        values: [bp[1].x, bp[1].y, bp[2].x, bp[2].y, bp[3].x, bp[3].y]
      })
      return d;
    });


  // Add the Moveto comand and join to string
  if (returnPathData) {
    return pathData;
  } else {
    return firstMoveto.concat(toSVGNotation).join(" ");
  }

  // Functions:
  // Seperate X and Y values from Point Array for clarity -> eg. [[x,y],[x,y],[x,y]]
  function getMatrix(arr) {
    return arr.map((p) => {
      if (p !== undefined) {
        return {
          x: p[0],
          y: p[1]
        };
      }
    });
  }
  // Duplicate first element to the end
  function copyFirstPointToLast(arr) {
    return arr[0] !== arr[arr.length - 1] ? [...arr, arr[0]] : arr;
  }
}
svg {
  border: 1px solid #ccc;
  width: 100%;
  display: inline-block;
}

#pathOut {
  fill: none;
  transform: translate(0, 50px);
  stroke: red;
}

#catmullPath {
  fill: none;
}
<svg class = "svg" overflow = "visible" viewBox = "0 0 600 200">
        <path id = "pathMid" stroke = "#ccc" stroke-width = "0.5" fill = "none" />
        <path id = "catmullPath" d = "" fill = "" stroke = "red" stroke-width = "1" />
    </svg>

Другие вопросы по теме