Skip to content

Commit 550d01f

Browse files
committed
Modification - VueUiWordCloud - Refactor and add unit tests for word cloud algo
1 parent 0f195dd commit 550d01f

File tree

4 files changed

+618
-151
lines changed

4 files changed

+618
-151
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,4 +107,4 @@
107107
"vitest": "^3.1.1",
108108
"vue": "^3.5.13"
109109
}
110-
}
110+
}

src/components/vue-ui-word-cloud.vue

Lines changed: 8 additions & 150 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import PenAndPaper from '../atoms/PenAndPaper.vue';
2929
import { useUserOptionState } from '../useUserOptionState';
3030
import { useChartAccessibility } from '../useChartAccessibility';
3131
import usePanZoom from '../usePanZoom';
32+
import { positionWords } from '../wordcloud';
3233
3334
const { 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
134136
const 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-
352213
const positionedWords = ref([]);
353214
354215
watch(() => 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-
363217
function 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
385243
const table = computed(() => {

0 commit comments

Comments
 (0)