Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"changes": [
{
"packageName": "@visactor/vchart-extension",
"comment": "修复 Pictogram SVG text-anchor 水平对齐映射,避免居中或右对齐文字错误偏移及被裁剪,并保留 SVG 画布适配行为。",
"type": "patch"
}
],
"packageName": "@visactor/vchart-extension"
}
2 changes: 2 additions & 0 deletions docs/assets/guide/en/tutorial_docs/Chart_Types/Pictogram.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ SVG characters can be used as materials, and the SVG primitives currently suppor

Including defs/style/switch/C/Q/pattern/use, etc. are not supported for the time being.

For left-to-right text, `text-anchor` supports `start`, `middle`, and `end`, producing left-aligned, centered, and right-aligned text relative to the anchor, respectively. This attribute is inherited through `g`, `text`, and `tspan`, and child elements can override the inherited value. Text defaults to left alignment when no anchor is specified. Horizontal anchoring does not change the vertical baseline.

Here is a simple example, in this example, the chart has no data, so there is no data mapping, just a display of SVG materials.

```javascript livedemo
Expand Down
2 changes: 2 additions & 0 deletions docs/assets/guide/zh/tutorial_docs/Chart_Types/Pictogram.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@

包括 defs/style/switch/C/Q/pattern/use 等用法暂不支持。

对于从左到右的文字,`text-anchor` 支持 `start`、`middle`、`end`,分别表示相对锚点左对齐、居中、右对齐。该属性可通过 `g`、`text`、`tspan` 继承,子元素可覆盖继承值;未配置时默认左对齐。水平锚点不会改变文字的垂直基线。

这里有一个简单的例子,这个例子中,图表没有任何数据,所以没有任何数据映射,只是一个对 SVG 素材的展示。

```javascript livedemo
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import { isValid, merge } from '@visactor/vchart';
import type { DataView, SVGParserResult } from '@visactor/vchart';
import type { DataView, ITextGraphicAttribute, SVGParserResult } from '@visactor/vchart';
import { DEFAULT_DATA_INDEX, measureText } from '@visactor/vchart';

const SVG_TEXT_ANCHOR_TO_ALIGN: Record<string, ITextGraphicAttribute['textAlign']> = {
start: 'left',
middle: 'center',
end: 'right'
};

function isValidStrokeOrFill(attr: any) {
return isValid(attr) && attr !== 'none' && !attr.includes?.('url');
}
Expand Down Expand Up @@ -114,8 +120,7 @@ export const graphicAttributeTransform = {
return {
...commonAttributes(attributes),
text: value,
textAlign: attributes.textAlign ?? 'left',
textBaseLine: attributes.textAnchor ?? 'middle',
textAlign: attributes.textAlign ?? SVG_TEXT_ANCHOR_TO_ALIGN[attributes.textAnchor] ?? 'left',
anchor: [0, 0],
fill: getFill(attributes, '#000')
};
Expand Down
146 changes: 146 additions & 0 deletions packages/vchart/__tests__/unit/extension/pictogram-text-anchor.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import VChart, { createText } from '../../../src';
import type { ITextGraphicAttribute } from '../../../src';
import { registerBrowserEnv } from '../../../src/env';
import { createDiv, removeDom } from '../../util/dom';
import {
graphicAttributeTransform,
pictogram
} from '../../../../vchart-extension/src/charts/pictogram/series/transform';
import { registerPictogramChart } from '../../../../vchart-extension/src/charts/pictogram/pictogram';
import type { IPictogramChartSpec } from '../../../../vchart-extension/src/charts/pictogram/interface';
import type { SVGParsedElementExtend } from '../../../../vchart-extension/src/charts/pictogram/series/pictogram';
import {
clearSVGSource,
getSVGSource,
registerSVGSource
} from '../../../../vchart-extension/src/charts/pictogram/series/svg-source';

const registerTextSVG = (body: string) =>
registerSVGSource('anchor-test', `<svg xmlns="http://www.w3.org/2000/svg" width="200" height="100">${body}</svg>`);

describe('pictogram text anchor', () => {
beforeAll(() => registerBrowserEnv());
afterEach(() => clearSVGSource());

it.each([
['start', 'left', 0],
['middle', 'center', 0.5],
['end', 'right', 1]
])('positions text using %s', (textAnchor, textAlign, ratio) => {
const attributes = graphicAttributeTransform.text(
{ x: 100, y: 40, textAnchor, fontSize: 12, fontFamily: 'Arial' },
'WWWW'
);
const graphic = createText(attributes as unknown as ITextGraphicAttribute);

try {
const bounds = graphic.AABBBounds;
expect(attributes.textAlign).toBe(textAlign);
expect(bounds.width()).toBeGreaterThan(0);
expect(bounds.x1 + bounds.width() * Number(ratio)).toBeCloseTo(100);
expect(attributes).not.toHaveProperty('textBaseLine');
expect(attributes).not.toHaveProperty('textBaseline');
} finally {
graphic.release();
}
});

it('retains explicit textAlign priority and defaults to left', () => {
expect(graphicAttributeTransform.text({ textAnchor: 'middle', textAlign: 'right' }, 'A').textAlign).toBe('right');
expect(graphicAttributeTransform.text({}, 'A').textAlign).toBe('left');
});

it('preserves the vertical baseline when changing the horizontal anchor', () => {
const graphics = ['start', 'middle', 'end'].map(textAnchor =>
createText(
graphicAttributeTransform.text(
{ x: 100, y: 40, textAnchor, fontSize: 12 },
'WWWW'
) as unknown as ITextGraphicAttribute
)
);
const reference = createText({ x: 100, y: 40, fontSize: 12, text: 'WWWW', textBaseline: 'alphabetic' });

try {
for (const graphic of graphics) {
expect(graphic.AABBBounds.y1).toBeCloseTo(reference.AABBBounds.y1);
expect(graphic.AABBBounds.y2).toBeCloseTo(reference.AABBBounds.y2);
}
} finally {
graphics.forEach(graphic => graphic.release());
reference.release();
}
});

it('preserves an explicit vertical baseline independently of the anchor', () => {
expect(graphicAttributeTransform.text({ textAnchor: 'middle', textBaseline: 'top' }, 'A')).toHaveProperty(
'textBaseline',
'top'
);
});

it.each([
['<g text-anchor="middle"><text x="100" y="40">ABC</text></g>', 'center'],
['<g text-anchor="middle"><text x="100" y="40" text-anchor="end">ABC</text></g>', 'right'],
['<text x="100" y="40" text-anchor="middle"><tspan>ABC</tspan></text>', 'center'],
['<text x="100" y="40" text-anchor="middle"><tspan text-anchor="end">ABC</tspan></text>', 'right']
])('inherits and overrides SVG anchors: %s', (body, alignment) => {
registerTextSVG(body);
const elements = pictogram([getSVGSource('anchor-test')!]) as SVGParsedElementExtend[];
const texts = elements.filter(element => element.graphicType === 'text');

expect(texts).toHaveLength(1);
expect(texts[0]._finalAttributes.textAlign).toBe(alignment);
});

it('keeps centered text inside the SVG viewport after rendering and resizing', async () => {
registerPictogramChart();
registerTextSVG(
'<text name="anchor-label" x="170" y="50" text-anchor="middle" font-family="Arial" font-size="12">WWWW</text>'
);
const dom = createDiv();
const chart = new VChart(
{
type: 'pictogram',
width: 200,
height: 100,
padding: 0,
svg: 'anchor-test',
data: { values: [] }
} as IPictogramChartSpec,
{ dom, animation: false }
);

try {
chart.renderSync();
const series = chart.getChart().getAllSeries()[0] as any;
const textElement = series._parsedSvgResult.elements.find(
(element: SVGParsedElementExtend) => element.graphicType === 'text'
);
const textMark = series._idToMark.get(textElement._uniqueId);
const root = series.getPictogramRootGraphic();
const getTextBounds = () => textMark.getGraphics()[0].globalAABBBounds;
const bounds = getTextBounds();
const originalY = { y1: bounds.y1, y2: bounds.y2 };

expect(bounds.width()).toBeGreaterThan(0);
expect((bounds.x1 + bounds.x2) / 2).toBeCloseTo(170);
expect(bounds.x2).toBeLessThanOrEqual(200);
expect(root.attribute.clip).toBe(true);
expect(root.attribute.postMatrix).toEqual(expect.objectContaining({ a: 1, d: 1, e: 0, f: 0 }));

await chart.resize(400, 200);

const resizedBounds = getTextBounds();
expect((resizedBounds.x1 + resizedBounds.x2) / 2).toBeCloseTo(340);
expect(resizedBounds.x2).toBeLessThanOrEqual(400);
expect(resizedBounds.y1).toBeCloseTo(originalY.y1 * 2);
expect(resizedBounds.y2).toBeCloseTo(originalY.y2 * 2);
expect(root.attribute.clip).toBe(true);
expect(root.attribute.postMatrix).toEqual(expect.objectContaining({ a: 2, d: 2, e: 0, f: 0 }));
} finally {
chart.release();
removeDom(dom);
}
});
});
Loading