You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何旋转整个SVG文本对象而非单个字形?

五度圈游戏开发中的文本旋转问题

我正在开发一款基于音乐概念“五度圈(Circle of fifths)”的游戏。为保证显示正确,已将元素旋转15度,使圆形顶部位于第一个分段的中间而非起始位置(也可认为是旋转-15度)。

目前遇到的问题是无法按需求旋转圆形分段内的文本。每个分段是包含子元素<path>和<text>的<g>元素。当前使用rotate属性,该属性仅适合单个或两个字符的文本,字符数更多时显示异常。根据MDN文档,rotate属性会“单独旋转每个字形的方向”。我希望旋转整个<text>元素,使所有字符保持同一旋转角度,且显示在同一直线上,与文字旋转方向垂直。

尝试过结合使用transform="rotate(15)"和text-anchor: middle替代rotate属性,但元素的X、Y坐标会发生偏移。还尝试过dominant-baseline属性(VSCode提示该属性不存在)以及glyph-orientation-horizontal属性(未产生任何效果)。

以下是我的代码(基于React开发,包含D3代码用于计算路径弧线,可直接放入App.js):

// My component
import './CircleOfFifths.css';
import { useEffect, useState } from 'react';
import * as d3 from 'd3';
import musicKeys from '../../MusicKeys';

export default function CircleOfFifths({ outerRadius }) {

  const diameter = outerRadius * 2;
  const innerRadius = outerRadius * 0.7;
  const innerRadius2 = outerRadius * 0.4;

  const [musicKeysObject, setMusicKeysObject] = useState(musicKeys);

  useEffect(() => {
    let newMusicKeysObject = musicKeysObject;
    for (let i=0; i<newMusicKeysObject.length; i++) {
      newMusicKeysObject[i].segmentMetadata.majorCircle.isVisible = true;
      newMusicKeysObject[i].segmentMetadata.minorCircle.isVisible = true;
    }
    setMusicKeysObject([...newMusicKeysObject]);
  }, [])

  const majorOnMouseUpHandler = (index) => {
    let newMusicKeysObject = musicKeysObject;
    if (newMusicKeysObject[index].segmentMetadata.majorCircle.isVisible) {
      newMusicKeysObject[index].segmentMetadata.majorCircle.isVisible = false;
    } else {
      newMusicKeysObject[index].segmentMetadata.majorCircle.isVisible = true;
    }
    setMusicKeysObject([...newMusicKeysObject]);
  }

  const minorOnMouseUpHandler = (index) => {
    let newMusicKeysObject = musicKeysObject;
    if (newMusicKeysObject[index].segmentMetadata.minorCircle.isVisible) {
      newMusicKeysObject[index].segmentMetadata.minorCircle.isVisible = false;
    } else {
      newMusicKeysObject[index].segmentMetadata.minorCircle.isVisible = true;
    }
    setMusicKeysObject([...newMusicKeysObject]);
  }

  const calculateArc = (innerRadius, outerRadius, startAngle, endAngle) => {
    return d3.arc()
      .innerRadius(innerRadius)
      .outerRadius(outerRadius)
      .startAngle(startAngle)
      .endAngle(endAngle);
  }

  const renderMajorSegment = (musicKey, index) => {
    let arc = calculateArc(outerRadius, innerRadius, musicKey.segmentMetadata.startAngle, musicKey.segmentMetadata.endAngle);
    let [arcCenterX, arcCenterY] = arc.centroid();

    return <g className={`circle-segment ${musicKey.segmentMetadata.majorCircle.isVisible ? 'isVisible': ''}`} key={index} onMouseUp={() => majorOnMouseUpHandler(index)}>
            <path 
              d={arc.apply()} // apply() is needed to generate the string that goes into the 'd' attribute
            />
            {/* Need to adjust center X position due to increased font-size */}
            <text x={arcCenterX-10} y={arcCenterY} rotate={15}>
              {musicKey.chords[0].replace('Major', '')}
            </text>
          </g>
  }

  const renderMinorSegment = (musicKey, index) => {
    let arc = calculateArc(innerRadius, innerRadius2, musicKey.segmentMetadata.startAngle, musicKey.segmentMetadata.endAngle);
    let [arcCenterX, arcCenterY] = arc.centroid();

    return <g className={`circle-segment ${musicKey.segmentMetadata.minorCircle.isVisible ? 'isVisible': ''}`} key={index} onMouseUp={() => minorOnMouseUpHandler(index)}>
            <path 
              d={arc.apply()} // apply() is needed to generate the string that goes into the 'd' attribute
            />
            {/* Need to adjust center X position due to increased font-size */}
            <text x={arcCenterX-10} y={arcCenterY} rotate={15}>
              {musicKey.chords[5].replace(' minor', 'mgh')}
            </text>
          </g>
  }

  return (
    <div className='circle-of-fifths-container'>
      <svg
        height={diameter*1.1}
        width={diameter*1.1}>
        <g className='circle-container' transform={`translate(${diameter/2 + 25},${diameter/2 + 25}) rotate(-15)`}>
          <circle r={outerRadius} className="base-circle"/>
          <g className='outer-circle-segments-container'>
            {musicKeysObject.map((musicKey, index) => renderMajorSegment(musicKey, index))}
          </g>
          <g className='inner-circle-segments-container'>
            {musicKeysObject.map((musicKey, index) => renderMinorSegment(musicKey, index))}
          </g>
        </g>
      </svg>
    </div>
  )
}

对应的CSS:

.circle-of-fifths-container {
  margin: 50px;
  text-align: center;
}

.circle-container {
  text-align: center;
}

.base-circle {
  fill: grey;
  padding: 5px;
  border: 5px;
  margin: 5px;
  stroke: grey;
  stroke-width: 5px;
}

.circle-segment {
  visibility: none;
}

.circle-segment.isVisible {
  visibility: visible;
  > path {
    fill: #D7DCDF;
    stroke: black;
    stroke-width: 5px;
  }
  > text {
    font-size: 30px;
    text-anchor: initial;
  }
}

渲染效果截图

  • 未使用rotate属性的渲染效果:
    未使用rotate属性的渲染效果

  • 使用rotate属性的渲染效果:
    使用rotate属性的渲染效果


内容的提问来源于stack exchange,提问作者Mo0rBy

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.22 05:47:01