feat: custom image mask for wordcloud shape

- Select '自定义图片' from shape dropdown
- Upload any silhouette image (black=words, white=empty)
- Image preview with delete button
- maskImage passed to echarts-wordcloud with keepAspect

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hailin 2026-04-05 21:47:39 -07:00
parent fa8e2714ca
commit 579ed2d174
2 changed files with 68 additions and 10 deletions

View File

@ -224,8 +224,25 @@ export function buildWordcloudOption(
};
});
// Build mask image for shape
const maskImage = getMask(wc.shape);
// Build mask: custom image or canvas shape
let maskImage: HTMLCanvasElement | HTMLImageElement | null = null;
if (wc.shape === 'custom' && wc.customImageUrl && typeof window !== 'undefined') {
// Use custom uploaded image as mask
const cacheKey = 'custom-' + wc.customImageUrl.substring(0, 50);
const cached = maskCache.get(cacheKey);
if (cached) {
maskImage = cached as any;
} else {
const img = new Image();
img.src = wc.customImageUrl;
maskImage = img;
// Cache after load
img.onload = () => { maskCache.set(cacheKey, img as any); };
}
} else if (wc.shape !== 'rectangle') {
maskImage = getMask(wc.shape);
}
const series: Record<string, any> = {
type: 'wordCloud',
@ -240,7 +257,7 @@ export function buildWordcloudOption(
shrinkToFit: true,
drawOutOfBound: false,
layoutAnimation: true,
keepAspect: false,
keepAspect: wc.shape === 'custom',
textStyle: {
fontFamily: wc.fontFamily,
fontWeight: wc.fontWeight,
@ -255,12 +272,8 @@ export function buildWordcloudOption(
data: wordData,
};
// Apply mask for non-default shapes
if (maskImage) {
series.maskImage = maskImage;
series.shape = 'circle'; // base shape, mask overrides it
} else {
series.shape = 'circle';
}
option.series = [series];

View File

@ -1,7 +1,8 @@
'use client';
import React, { useCallback } from 'react';
import { Select, Slider, InputNumber, Space, Typography, Radio } from 'antd';
import React, { useCallback, useRef } from 'react';
import { Select, Slider, InputNumber, Space, Typography, Radio, Button, Upload } from 'antd';
import { UploadOutlined, DeleteOutlined } from '@ant-design/icons';
import { useChartConfig } from '@/frameworks/hooks/useChartConfig';
const { Text } = Typography;
@ -14,6 +15,7 @@ const SHAPES = [
{ label: '三角形', value: 'triangle' },
{ label: '五角星', value: 'star' },
{ label: '五边形', value: 'pentagon' },
{ label: '自定义图片', value: 'custom' },
];
const FONTS = [
@ -63,13 +65,56 @@ export default function WordcloudConfig() {
<Text strong style={{ fontSize: 12 }}></Text>
<Select
value={config.shape}
onChange={(val) => update({ shape: val })}
onChange={(val) => update({ shape: val, customImageUrl: val === 'custom' ? config.customImageUrl : undefined })}
options={SHAPES}
size="small"
style={{ width: '100%', marginTop: 4 }}
/>
</div>
{/* Custom image upload - only when shape=custom */}
{config.shape === 'custom' && (
<div>
<Text strong style={{ fontSize: 12 }}></Text>
<Text style={{ fontSize: 11, color: '#999', display: 'block', margin: '2px 0 6px' }}>
</Text>
{config.customImageUrl ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<img
src={config.customImageUrl}
alt="mask"
style={{ width: 60, height: 60, objectFit: 'contain', border: '1px solid #d9d9d9', borderRadius: 4, background: '#fafafa' }}
/>
<Button
size="small"
danger
icon={<DeleteOutlined />}
onClick={() => update({ customImageUrl: undefined })}
>
</Button>
</div>
) : (
<Upload
accept="image/*"
showUploadList={false}
beforeUpload={(file) => {
const reader = new FileReader();
reader.onload = (e) => {
const dataUrl = e.target?.result as string;
if (dataUrl) update({ customImageUrl: dataUrl });
};
reader.readAsDataURL(file);
return false;
}}
>
<Button size="small" icon={<UploadOutlined />}></Button>
</Upload>
)}
</div>
)}
{/* Font Size Range */}
<div>
<Text strong style={{ fontSize: 12 }}></Text>