@@ -29,6 +29,7 @@ import PenAndPaper from '../atoms/PenAndPaper.vue';
2929import { useUserOptionState } from ' ../useUserOptionState' ;
3030import { useChartAccessibility } from ' ../useChartAccessibility' ;
3131import usePanZoom from ' ../usePanZoom' ;
32+ import { positionWords } from ' ../wordcloud' ;
3233
3334const { vue_ui_word_cloud: DEFAULT_CONFIG } = useConfig ();
3435
@@ -128,7 +129,8 @@ const svg = ref({
128129 width: FINAL_CONFIG .value .style .chart .width ,
129130 height: FINAL_CONFIG .value .style .chart .height ,
130131 maxFontSize: FINAL_CONFIG .value .style .chart .words .maxFontSize ,
131- minFontSize: FINAL_CONFIG .value .style .chart .words .minFontSize
132+ minFontSize: FINAL_CONFIG .value .style .chart .words .minFontSize ,
133+ bold: FINAL_CONFIG .value .style .chart .words .bold
132134});
133135
134136const debounceUpdateCloud = debounce (() => {
@@ -208,158 +210,10 @@ function measureTextSize(text, fontSize, fontFamily = "Arial") {
208210 };
209211}
210212
211- function positionWords (words , width , height ) {
212- const maskW = Math .round (width);
213- const maskH = Math .round (height);
214- const minFontSize = 1 ;
215- const configMinFontSize = svg .value .minFontSize ;
216- const maxFontSize = svg .value .maxFontSize ;
217- const proximity = FINAL_CONFIG .value .style .chart .words .proximity || 0 ;
218- const values = words .map (w => w .value );
219- const minValue = Math .min (... values);
220- const maxValue = Math .max (... values);
221-
222- const mask = new Uint8Array (maskW * maskH);
223- const canvas = document .createElement (' canvas' );
224- const ctx = canvas .getContext (' 2d' , { willReadFrequently: true });
225- canvas .width = maskW;
226- canvas .height = maskH;
227-
228- function getWordBitmap (word , fontSize , pad ) {
229- ctx .save ();
230- ctx .font = ` ${ svg .value .style && svg .value .style .bold ? ' bold ' : ' ' }${ fontSize} px Arial` ;
231- const metrics = ctx .measureText (word .name );
232- const textW = Math .ceil (metrics .width ) + 2 + (pad ? pad * 2 : 0 );
233- const textH = Math .ceil (fontSize) + 2 + (pad ? pad * 2 : 0 );
234-
235- canvas .width = textW;
236- canvas .height = textH;
237- ctx .clearRect (0 , 0 , textW, textH);
238- ctx .font = ` ${ svg .value .style && svg .value .style .bold ? ' bold ' : ' ' }${ fontSize} px Arial` ;
239- ctx .textAlign = " center" ;
240- ctx .textBaseline = " middle" ;
241- ctx .fillStyle = " black" ;
242- ctx .fillText (word .name , textW / 2 , textH / 2 );
243- const image = ctx .getImageData (0 , 0 , textW, textH);
244- const data = image .data ;
245- const wordMask = [];
246- for (let y = 0 ; y < textH; y += 1 ) {
247- for (let x = 0 ; x < textW; x += 1 ) {
248- if (data[(y * textW + x) * 4 + 3 ] > 1 ) wordMask .push ([x, y]);
249- }
250- }
251- ctx .restore ();
252- return { w: textW, h: textH, wordMask };
253- }
254-
255- function canPlaceAt (mask , maskW , maskH , wx , wy , wordMask ) {
256- for (let i = 0 ; i < wordMask .length ; i += 1 ) {
257- const x = wx + wordMask[i][0 ];
258- const y = wy + wordMask[i][1 ];
259- if (x < 0 || y < 0 || x >= maskW || y >= maskH) return false ;
260- if (mask[y * maskW + x]) return false ;
261- }
262- return true ;
263- }
264- function markMask (mask , maskW , maskH , wx , wy , wordMask ) {
265- for (let i = 0 ; i < wordMask .length ; i += 1 ) {
266- const x = wx + wordMask[i][0 ];
267- const y = wy + wordMask[i][1 ];
268- if (x >= 0 && y >= 0 && x < maskW && y < maskH) mask[y * maskW + x] = 1 ;
269- }
270- }
271-
272- const spiralStep = 6 , spiralRadiusStep = 2 ;
273- const fallbackSpiralStep = 2 , fallbackSpiralRadiusStep = 1 ;
274- const cx = Math .floor (maskW / 2 ), cy = Math .floor (maskH / 2 );
275-
276- const sorted = [... words].sort ((a , b ) => b .value - a .value );
277- const positionedWords = [];
278-
279- function dilateWordMask (wordMask , w , h , dilation = 1 ) {
280- const set = new Set (wordMask .map (([x , y ]) => ` ${ x} ,${ y} ` ));
281- const result = new Set (set);
282- for (let [x, y] of wordMask) {
283- for (let dx = - dilation; dx <= dilation; dx += 1 ) {
284- for (let dy = - dilation; dy <= dilation; dy += 1 ) {
285- if (dx === 0 && dy === 0 ) continue ;
286- const nx = x + dx, ny = y + dy;
287- if (nx >= 0 && nx < w && ny >= 0 && ny < h) {
288- result .add (` ${ nx} ,${ ny} ` );
289- }
290- }
291- }
292- }
293- return Array .from (result).map (s => s .split (' ,' ).map (Number ));
294- }
295-
296- for (const wordRaw of sorted) {
297- let targetFontSize = configMinFontSize;
298- if (maxValue !== minValue) {
299- targetFontSize = (wordRaw .value - minValue) / (maxValue - minValue) * (maxFontSize - configMinFontSize) + configMinFontSize;
300- }
301- targetFontSize = Math .max (configMinFontSize, Math .min (maxFontSize, targetFontSize));
302-
303- let placed = false ;
304- let fontSize = targetFontSize;
305-
306- while (! placed && fontSize >= minFontSize) {
307- let { w, h, wordMask } = getWordBitmap (wordRaw, fontSize, proximity);
308- wordMask = dilateWordMask (wordMask, w, h, 2 );
309- let r = 0 , attempts = 0 ;
310- while (r < Math .max (maskW, maskH) && ! placed && attempts < 10000 ) {
311- for (let theta = 0 ; theta < 360 ; theta += spiralStep) {
312- attempts += 1 ;
313- const px = Math .round (cx + r * Math .cos (theta * Math .PI / 180 ) - w / 2 );
314- const py = Math .round (cy + r * Math .sin (theta * Math .PI / 180 ) - h / 2 );
315- if (px < 0 || py < 0 || px + w > maskW || py + h > maskH) continue ;
316- if (canPlaceAt (mask, maskW, maskH, px, py, wordMask)) {
317- positionedWords .push ({ ... wordRaw, x: px - maskW / 2 , y: py - maskH / 2 , fontSize, width: w, height: h, angle: 0 });
318- markMask (mask, maskW, maskH, px, py, wordMask);
319- placed = true ;
320- break ;
321- }
322- }
323- r += spiralRadiusStep;
324- }
325- if (! placed) fontSize -= 1 ;
326- }
327-
328- if (! placed && fontSize < minFontSize) {
329- fontSize = minFontSize;
330- const { w , h , wordMask } = getWordBitmap (wordRaw, fontSize, proximity);
331- let r = 0 , attempts = 0 , bestPlacement = null ;
332- while (r < Math .max (maskW, maskH) && ! placed && attempts < 25000 ) {
333- for (let theta = 0 ; theta < 360 ; theta += fallbackSpiralStep) {
334- attempts += 1 ;
335- const px = Math .round (cx + r * Math .cos (theta * Math .PI / 180 ) - w / 2 );
336- const py = Math .round (cy + r * Math .sin (theta * Math .PI / 180 ) - h / 2 );
337- if (px < 0 || py < 0 || px + w > maskW || py + h > maskH) continue ;
338- if (canPlaceAt (mask, maskW, maskH, px, py, wordMask)) {
339- positionedWords .push ({ ... wordRaw, x: px - maskW / 2 , y: py - maskH / 2 , fontSize, width: w, height: h, angle: 0 });
340- markMask (mask, maskW, maskH, px, py, wordMask);
341- placed = true ;
342- break ;
343- }
344- }
345- r += fallbackSpiralRadiusStep;
346- }
347- }
348- }
349- return positionedWords;
350- }
351-
352213const positionedWords = ref ([]);
353214
354215watch (() => props .dataset , generateWordCloud, { immediate: true });
355216
356- const wordMin = computed (() => {
357- return Math .round (Math .min (... drawableDataset .value .map (w => w .value )));
358- })
359- const wordMax = computed (() => {
360- return Math .round (Math .max (... drawableDataset .value .map (w => w .value )));
361- })
362-
363217function generateWordCloud () {
364218 const values = [... drawableDataset .value ].map (d => d .value );
365219 const maxValue = Math .max (... values);
@@ -379,7 +233,11 @@ function generateWordCloud() {
379233 };
380234 });
381235
382- positionedWords .value = positionWords (scaledWords, svg .value .width , svg .value .height ).sort ((a , b ) => b .fontSize - a .fontSize );
236+ positionedWords .value = positionWords ({
237+ words: scaledWords,
238+ svg: svg .value ,
239+ proximity: FINAL_CONFIG .value .style .chart .words .proximity ,
240+ });
383241}
384242
385243const table = computed (() => {
0 commit comments