@memberjunction/ng-word-cloud 0.0.1 → 5.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +119 -28
- package/dist/__tests__/word-cloud-layout.test.d.ts +2 -0
- package/dist/__tests__/word-cloud-layout.test.d.ts.map +1 -0
- package/dist/__tests__/word-cloud-layout.test.js +198 -0
- package/dist/__tests__/word-cloud-layout.test.js.map +1 -0
- package/dist/lib/word-cloud.component.d.ts +78 -0
- package/dist/lib/word-cloud.component.d.ts.map +1 -0
- package/dist/lib/word-cloud.component.js +241 -0
- package/dist/lib/word-cloud.component.js.map +1 -0
- package/dist/lib/word-cloud.layout.d.ts +19 -0
- package/dist/lib/word-cloud.layout.d.ts.map +1 -0
- package/dist/lib/word-cloud.layout.js +182 -0
- package/dist/lib/word-cloud.layout.js.map +1 -0
- package/dist/lib/word-cloud.types.d.ts +42 -0
- package/dist/lib/word-cloud.types.d.ts.map +1 -0
- package/dist/lib/word-cloud.types.js +2 -0
- package/dist/lib/word-cloud.types.js.map +1 -0
- package/dist/public-api.d.ts +9 -0
- package/dist/public-api.d.ts.map +1 -0
- package/dist/public-api.js +14 -0
- package/dist/public-api.js.map +1 -0
- package/package.json +36 -6
package/README.md
CHANGED
|
@@ -1,45 +1,136 @@
|
|
|
1
1
|
# @memberjunction/ng-word-cloud
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A reusable, SVG-based word cloud component for Angular. Renders weighted text items in a spiral or rectangular layout with collision detection. Supports multiple color modes using MJ design tokens, optional entry animation, and click/hover interaction.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
## Installation
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
```bash
|
|
8
|
+
npm install @memberjunction/ng-word-cloud
|
|
9
|
+
```
|
|
8
10
|
|
|
9
|
-
##
|
|
11
|
+
## Usage
|
|
10
12
|
|
|
11
|
-
|
|
12
|
-
1. Configure OIDC trusted publishing for the package name `@memberjunction/ng-word-cloud`
|
|
13
|
-
2. Enable secure, token-less publishing from CI/CD workflows
|
|
14
|
-
3. Establish provenance for packages published under this name
|
|
13
|
+
The component is standalone and can be imported directly:
|
|
15
14
|
|
|
16
|
-
|
|
15
|
+
```typescript
|
|
16
|
+
import { MJWordCloudComponent } from '@memberjunction/ng-word-cloud';
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
@Component({
|
|
19
|
+
imports: [MJWordCloudComponent],
|
|
20
|
+
template: `
|
|
21
|
+
<mj-word-cloud
|
|
22
|
+
[Items]="Words"
|
|
23
|
+
[MaxFontSize]="56"
|
|
24
|
+
ColorMode="categorical"
|
|
25
|
+
(ItemClick)="OnWordClick($event)">
|
|
26
|
+
</mj-word-cloud>
|
|
27
|
+
`
|
|
28
|
+
})
|
|
29
|
+
export class MyComponent {
|
|
30
|
+
Words: WordCloudItem[] = [
|
|
31
|
+
{ Text: 'Machine Learning', Weight: 1.0 },
|
|
32
|
+
{ Text: 'Data Science', Weight: 0.8, Category: 'analytics' },
|
|
33
|
+
{ Text: 'Neural Networks', Weight: 0.6, Category: 'ai' },
|
|
34
|
+
];
|
|
19
35
|
|
|
20
|
-
|
|
36
|
+
OnWordClick(event: WordCloudItemEvent): void {
|
|
37
|
+
console.log('Clicked:', event.Item.Text);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
```
|
|
21
41
|
|
|
22
|
-
|
|
42
|
+
## Component API
|
|
23
43
|
|
|
24
|
-
|
|
25
|
-
2. Configure the trusted publisher (e.g., GitHub Actions)
|
|
26
|
-
3. Specify the repository and workflow that should be allowed to publish
|
|
27
|
-
4. Use the configured workflow to publish your actual package
|
|
44
|
+
### Selector
|
|
28
45
|
|
|
29
|
-
|
|
46
|
+
`mj-word-cloud`
|
|
30
47
|
|
|
31
|
-
|
|
32
|
-
- Contains no executable code
|
|
33
|
-
- Provides no functionality
|
|
34
|
-
- Should not be installed as a dependency
|
|
35
|
-
- Exists only for administrative purposes
|
|
48
|
+
### Inputs
|
|
36
49
|
|
|
37
|
-
|
|
50
|
+
| Input | Type | Default | Description |
|
|
51
|
+
|-------|------|---------|-------------|
|
|
52
|
+
| `Items` | `WordCloudItem[]` | `[]` | Data items to display in the cloud |
|
|
53
|
+
| `MinFontSize` | `number` | `12` | Minimum font size in pixels for the lowest-weighted item |
|
|
54
|
+
| `MaxFontSize` | `number` | `48` | Maximum font size in pixels for the highest-weighted item |
|
|
55
|
+
| `Layout` | `'spiral' \| 'rectangular'` | `'spiral'` | Layout algorithm for word placement |
|
|
56
|
+
| `ColorMode` | `'brand' \| 'categorical' \| 'weight-gradient'` | `'brand'` | How colors are assigned to words (see Color Modes below) |
|
|
57
|
+
| `Interactive` | `boolean` | `true` | Whether words are clickable and hoverable |
|
|
58
|
+
| `MaxItems` | `number` | `100` | Maximum number of items to display (heaviest items are kept) |
|
|
59
|
+
| `Animate` | `boolean` | `true` | Whether to animate words in with a staggered fade |
|
|
38
60
|
|
|
39
|
-
|
|
40
|
-
- [npm Trusted Publishing Documentation](https://docs.npmjs.com/generating-provenance-statements)
|
|
41
|
-
- [GitHub Actions OIDC Documentation](https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect)
|
|
61
|
+
### Outputs
|
|
42
62
|
|
|
43
|
-
|
|
63
|
+
| Output | Type | Description |
|
|
64
|
+
|--------|------|-------------|
|
|
65
|
+
| `ItemClick` | `EventEmitter<WordCloudItemEvent>` | Emitted when a word is clicked |
|
|
66
|
+
| `ItemHover` | `EventEmitter<WordCloudItemEvent>` | Emitted when the pointer enters a word |
|
|
67
|
+
| `ItemLeave` | `EventEmitter<WordCloudItemEvent>` | Emitted when the pointer leaves a word |
|
|
44
68
|
|
|
45
|
-
|
|
69
|
+
## Types
|
|
70
|
+
|
|
71
|
+
### WordCloudItem
|
|
72
|
+
|
|
73
|
+
```typescript
|
|
74
|
+
interface WordCloudItem {
|
|
75
|
+
Text: string; // Display text
|
|
76
|
+
Weight: number; // Importance (0.0 - 1.0), determines font size
|
|
77
|
+
Category?: string; // Optional category for color grouping
|
|
78
|
+
Metadata?: Record<string, unknown>; // Optional metadata passed through events
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### WordCloudItemEvent
|
|
83
|
+
|
|
84
|
+
```typescript
|
|
85
|
+
interface WordCloudItemEvent {
|
|
86
|
+
Item: WordCloudItem; // The item that was interacted with
|
|
87
|
+
Event: MouseEvent; // The original DOM event
|
|
88
|
+
}
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## Color Modes
|
|
92
|
+
|
|
93
|
+
### `brand`
|
|
94
|
+
|
|
95
|
+
All words use `--mj-brand-primary`. Opacity varies with weight (range 0.4--1.0), giving higher-weighted words more visual prominence.
|
|
96
|
+
|
|
97
|
+
### `categorical`
|
|
98
|
+
|
|
99
|
+
Words with the same `Category` share a color from a fixed palette of MJ design tokens: `--mj-brand-primary`, `--mj-status-success`, `--mj-status-warning`, `--mj-status-error`, `--mj-status-info`, and others. Words without a `Category` each get their own color.
|
|
100
|
+
|
|
101
|
+
### `weight-gradient`
|
|
102
|
+
|
|
103
|
+
Interpolates between `--mj-text-muted` (low weight) and `--mj-brand-primary` (high weight) using `color-mix()`. Provides a smooth gradient that adapts to both light and dark themes.
|
|
104
|
+
|
|
105
|
+
## Rendering Approach
|
|
106
|
+
|
|
107
|
+
The component renders entirely in SVG using an `<svg>` element with a computed `viewBox` and `preserveAspectRatio="xMidYMid meet"` so the cloud scales to fill its container.
|
|
108
|
+
|
|
109
|
+
### Layout Algorithm
|
|
110
|
+
|
|
111
|
+
1. Items are sorted by weight (heaviest first) and trimmed to `MaxItems`.
|
|
112
|
+
2. For each item, the engine computes a font size by linear interpolation between `MinFontSize` and `MaxFontSize` based on weight.
|
|
113
|
+
3. Roughly 20% of words are rotated 90 degrees for visual variety (deterministic based on index).
|
|
114
|
+
4. The engine generates candidate positions using the selected algorithm:
|
|
115
|
+
- **Spiral** -- Archimedean spiral expanding from the center.
|
|
116
|
+
- **Rectangular** -- concentric rectangular rings expanding from the center.
|
|
117
|
+
5. For each candidate position, an axis-aligned bounding box is computed (accounting for rotation) and tested against all previously placed items.
|
|
118
|
+
6. The first non-colliding position is accepted. If no position is found after 500 attempts, the word is skipped.
|
|
119
|
+
7. The overall SVG `viewBox` is computed to fit all placed items with 20px padding.
|
|
120
|
+
|
|
121
|
+
Text width is estimated using an average character width ratio of 0.6x the font size (no DOM measurement required), making the layout engine fully deterministic and SSR-compatible.
|
|
122
|
+
|
|
123
|
+
## Design Token Compliance
|
|
124
|
+
|
|
125
|
+
All colors use MJ semantic design tokens exclusively. No hardcoded hex values are used in the component's styles or color logic. The component adapts automatically to light and dark themes via the `--mj-*` CSS custom properties.
|
|
126
|
+
|
|
127
|
+
Tokens used:
|
|
128
|
+
|
|
129
|
+
- `--mj-brand-primary`, `--mj-brand-primary-hover` -- brand color mode
|
|
130
|
+
- `--mj-status-success`, `--mj-status-warning`, `--mj-status-error`, `--mj-status-info` -- categorical palette
|
|
131
|
+
- `--mj-text-muted` -- weight-gradient low end
|
|
132
|
+
- `--mj-font-family` -- inherited font family
|
|
133
|
+
|
|
134
|
+
## Change Detection
|
|
135
|
+
|
|
136
|
+
The component uses `ChangeDetectionStrategy.OnPush` for performance. The layout is recomputed whenever any `@Input` changes (via `ngOnChanges`).
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"word-cloud-layout.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/word-cloud-layout.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { computeWordCloudLayout } from '../lib/word-cloud.layout';
|
|
3
|
+
const defaultConfig = {
|
|
4
|
+
MinFontSize: 12,
|
|
5
|
+
MaxFontSize: 48,
|
|
6
|
+
Layout: 'spiral',
|
|
7
|
+
MaxItems: 100,
|
|
8
|
+
};
|
|
9
|
+
function makeItems(count, baseWeight = 1.0) {
|
|
10
|
+
return Array.from({ length: count }, (_, i) => ({
|
|
11
|
+
Text: `word${i}`,
|
|
12
|
+
Weight: baseWeight - (i * (baseWeight / count)),
|
|
13
|
+
}));
|
|
14
|
+
}
|
|
15
|
+
describe('computeWordCloudLayout', () => {
|
|
16
|
+
describe('empty and edge cases', () => {
|
|
17
|
+
it('should return empty layout for empty items array', () => {
|
|
18
|
+
const result = computeWordCloudLayout([], defaultConfig);
|
|
19
|
+
expect(result.Items).toHaveLength(0);
|
|
20
|
+
expect(result.ViewBox).toBe('0 0 100 100');
|
|
21
|
+
});
|
|
22
|
+
it('should return empty layout for null-ish items', () => {
|
|
23
|
+
const result = computeWordCloudLayout(null, defaultConfig);
|
|
24
|
+
expect(result.Items).toHaveLength(0);
|
|
25
|
+
});
|
|
26
|
+
it('should handle a single item', () => {
|
|
27
|
+
const items = [{ Text: 'hello', Weight: 1.0 }];
|
|
28
|
+
const result = computeWordCloudLayout(items, defaultConfig);
|
|
29
|
+
expect(result.Items).toHaveLength(1);
|
|
30
|
+
expect(result.Items[0].Text).toBe('hello');
|
|
31
|
+
expect(result.Items[0].FontSize).toBe(48); // max font for weight 1.0
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
describe('sorting and limiting', () => {
|
|
35
|
+
it('should sort items by weight descending (heaviest first)', () => {
|
|
36
|
+
const items = [
|
|
37
|
+
{ Text: 'low', Weight: 0.2 },
|
|
38
|
+
{ Text: 'high', Weight: 0.9 },
|
|
39
|
+
{ Text: 'mid', Weight: 0.5 },
|
|
40
|
+
];
|
|
41
|
+
const result = computeWordCloudLayout(items, defaultConfig);
|
|
42
|
+
expect(result.Items[0].Text).toBe('high');
|
|
43
|
+
expect(result.Items[1].Text).toBe('mid');
|
|
44
|
+
expect(result.Items[2].Text).toBe('low');
|
|
45
|
+
});
|
|
46
|
+
it('should limit items to MaxItems', () => {
|
|
47
|
+
const items = makeItems(50);
|
|
48
|
+
const config = { ...defaultConfig, MaxItems: 10 };
|
|
49
|
+
const result = computeWordCloudLayout(items, config);
|
|
50
|
+
expect(result.Items.length).toBeLessThanOrEqual(10);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
describe('font size mapping', () => {
|
|
54
|
+
it('should map weight 1.0 to MaxFontSize', () => {
|
|
55
|
+
const items = [{ Text: 'max', Weight: 1.0 }];
|
|
56
|
+
const result = computeWordCloudLayout(items, defaultConfig);
|
|
57
|
+
expect(result.Items[0].FontSize).toBe(48);
|
|
58
|
+
});
|
|
59
|
+
it('should map weight 0.0 to MinFontSize', () => {
|
|
60
|
+
const items = [{ Text: 'min', Weight: 0.0 }];
|
|
61
|
+
const result = computeWordCloudLayout(items, defaultConfig);
|
|
62
|
+
expect(result.Items[0].FontSize).toBe(12);
|
|
63
|
+
});
|
|
64
|
+
it('should map weight 0.5 to midpoint font size', () => {
|
|
65
|
+
const items = [{ Text: 'mid', Weight: 0.5 }];
|
|
66
|
+
const result = computeWordCloudLayout(items, defaultConfig);
|
|
67
|
+
expect(result.Items[0].FontSize).toBe(30); // 12 + 0.5 * (48 - 12) = 30
|
|
68
|
+
});
|
|
69
|
+
it('should clamp weights above 1.0', () => {
|
|
70
|
+
const items = [{ Text: 'over', Weight: 1.5 }];
|
|
71
|
+
const result = computeWordCloudLayout(items, defaultConfig);
|
|
72
|
+
expect(result.Items[0].FontSize).toBe(48);
|
|
73
|
+
});
|
|
74
|
+
it('should clamp weights below 0.0', () => {
|
|
75
|
+
const items = [{ Text: 'under', Weight: -0.5 }];
|
|
76
|
+
const result = computeWordCloudLayout(items, defaultConfig);
|
|
77
|
+
expect(result.Items[0].FontSize).toBe(12);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
describe('positioning', () => {
|
|
81
|
+
it('should place the first item near the center', () => {
|
|
82
|
+
const items = [{ Text: 'center', Weight: 1.0 }];
|
|
83
|
+
const result = computeWordCloudLayout(items, defaultConfig);
|
|
84
|
+
// First item should be at or very near (0, 0)
|
|
85
|
+
expect(Math.abs(result.Items[0].X)).toBeLessThan(10);
|
|
86
|
+
expect(Math.abs(result.Items[0].Y)).toBeLessThan(10);
|
|
87
|
+
});
|
|
88
|
+
it('should not have overlapping items', () => {
|
|
89
|
+
const items = makeItems(20);
|
|
90
|
+
const result = computeWordCloudLayout(items, defaultConfig);
|
|
91
|
+
// Verify no two items share the exact same position
|
|
92
|
+
for (let i = 0; i < result.Items.length; i++) {
|
|
93
|
+
for (let j = i + 1; j < result.Items.length; j++) {
|
|
94
|
+
const samePos = result.Items[i].X === result.Items[j].X &&
|
|
95
|
+
result.Items[i].Y === result.Items[j].Y;
|
|
96
|
+
expect(samePos).toBe(false);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
it('should place items progressively further from center', () => {
|
|
101
|
+
const items = makeItems(10, 1.0);
|
|
102
|
+
const result = computeWordCloudLayout(items, defaultConfig);
|
|
103
|
+
// The average distance from center should increase for later items
|
|
104
|
+
if (result.Items.length >= 2) {
|
|
105
|
+
const firstDist = Math.sqrt(result.Items[0].X ** 2 + result.Items[0].Y ** 2);
|
|
106
|
+
const lastDist = Math.sqrt(result.Items[result.Items.length - 1].X ** 2 +
|
|
107
|
+
result.Items[result.Items.length - 1].Y ** 2);
|
|
108
|
+
expect(lastDist).toBeGreaterThanOrEqual(firstDist);
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
describe('rotation', () => {
|
|
113
|
+
it('should rotate approximately 20% of words (every 5th at index 3)', () => {
|
|
114
|
+
const items = makeItems(20);
|
|
115
|
+
const result = computeWordCloudLayout(items, defaultConfig);
|
|
116
|
+
const rotated = result.Items.filter(i => i.Rotation === 90);
|
|
117
|
+
const unrotated = result.Items.filter(i => i.Rotation === 0);
|
|
118
|
+
// Roughly 20% should be rotated (deterministic based on index % 5 === 3)
|
|
119
|
+
expect(rotated.length).toBeGreaterThan(0);
|
|
120
|
+
expect(unrotated.length).toBeGreaterThan(rotated.length);
|
|
121
|
+
});
|
|
122
|
+
it('should only use 0 or 90 degree rotation', () => {
|
|
123
|
+
const items = makeItems(30);
|
|
124
|
+
const result = computeWordCloudLayout(items, defaultConfig);
|
|
125
|
+
for (const item of result.Items) {
|
|
126
|
+
expect([0, 90]).toContain(item.Rotation);
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
describe('layout modes', () => {
|
|
131
|
+
it('should produce valid layout with spiral mode', () => {
|
|
132
|
+
const items = makeItems(15);
|
|
133
|
+
const result = computeWordCloudLayout(items, { ...defaultConfig, Layout: 'spiral' });
|
|
134
|
+
expect(result.Items.length).toBeGreaterThan(0);
|
|
135
|
+
expect(result.ViewBox).not.toBe('0 0 100 100'); // should have computed viewBox
|
|
136
|
+
});
|
|
137
|
+
it('should produce valid layout with rectangular mode', () => {
|
|
138
|
+
const items = makeItems(15);
|
|
139
|
+
const result = computeWordCloudLayout(items, { ...defaultConfig, Layout: 'rectangular' });
|
|
140
|
+
expect(result.Items.length).toBeGreaterThan(0);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
describe('viewBox computation', () => {
|
|
144
|
+
it('should produce a viewBox that encompasses all items', () => {
|
|
145
|
+
const items = makeItems(10);
|
|
146
|
+
const result = computeWordCloudLayout(items, defaultConfig);
|
|
147
|
+
const [vx, vy, vw, vh] = result.ViewBox.split(' ').map(Number);
|
|
148
|
+
for (const item of result.Items) {
|
|
149
|
+
expect(item.X).toBeGreaterThanOrEqual(vx);
|
|
150
|
+
expect(item.X).toBeLessThanOrEqual(vx + vw);
|
|
151
|
+
expect(item.Y).toBeGreaterThanOrEqual(vy);
|
|
152
|
+
expect(item.Y).toBeLessThanOrEqual(vy + vh);
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
describe('index assignment', () => {
|
|
157
|
+
it('should assign sequential indices to layout items', () => {
|
|
158
|
+
const items = makeItems(5);
|
|
159
|
+
const result = computeWordCloudLayout(items, defaultConfig);
|
|
160
|
+
for (let i = 0; i < result.Items.length; i++) {
|
|
161
|
+
expect(result.Items[i].Index).toBe(i);
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
describe('metadata preservation', () => {
|
|
166
|
+
it('should preserve Category from input items', () => {
|
|
167
|
+
const items = [
|
|
168
|
+
{ Text: 'test', Weight: 0.8, Category: 'tech' },
|
|
169
|
+
];
|
|
170
|
+
const result = computeWordCloudLayout(items, defaultConfig);
|
|
171
|
+
expect(result.Items[0].Category).toBe('tech');
|
|
172
|
+
});
|
|
173
|
+
it('should preserve Metadata from input items', () => {
|
|
174
|
+
const meta = { id: '123', source: 'test' };
|
|
175
|
+
const items = [
|
|
176
|
+
{ Text: 'test', Weight: 0.8, Metadata: meta },
|
|
177
|
+
];
|
|
178
|
+
const result = computeWordCloudLayout(items, defaultConfig);
|
|
179
|
+
expect(result.Items[0].Metadata).toEqual(meta);
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
describe('large datasets', () => {
|
|
183
|
+
it('should handle 100 items without crashing', () => {
|
|
184
|
+
const items = makeItems(100);
|
|
185
|
+
const result = computeWordCloudLayout(items, defaultConfig);
|
|
186
|
+
expect(result.Items.length).toBeGreaterThan(0);
|
|
187
|
+
expect(result.Items.length).toBeLessThanOrEqual(100);
|
|
188
|
+
});
|
|
189
|
+
it('should complete layout within reasonable time', () => {
|
|
190
|
+
const items = makeItems(100);
|
|
191
|
+
const start = performance.now();
|
|
192
|
+
computeWordCloudLayout(items, defaultConfig);
|
|
193
|
+
const elapsed = performance.now() - start;
|
|
194
|
+
expect(elapsed).toBeLessThan(5000); // should complete in < 5 seconds
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
//# sourceMappingURL=word-cloud-layout.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"word-cloud-layout.test.js","sourceRoot":"","sources":["../../src/__tests__/word-cloud-layout.test.ts"],"names":[],"mappings":"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","sourcesContent":["import { describe, it, expect } from 'vitest';\nimport { computeWordCloudLayout, WordCloudLayoutConfig } from '../lib/word-cloud.layout';\nimport { WordCloudItem } from '../lib/word-cloud.types';\n\nconst defaultConfig: WordCloudLayoutConfig = {\n MinFontSize: 12,\n MaxFontSize: 48,\n Layout: 'spiral',\n MaxItems: 100,\n};\n\nfunction makeItems(count: number, baseWeight = 1.0): WordCloudItem[] {\n return Array.from({ length: count }, (_, i) => ({\n Text: `word${i}`,\n Weight: baseWeight - (i * (baseWeight / count)),\n }));\n}\n\ndescribe('computeWordCloudLayout', () => {\n describe('empty and edge cases', () => {\n it('should return empty layout for empty items array', () => {\n const result = computeWordCloudLayout([], defaultConfig);\n expect(result.Items).toHaveLength(0);\n expect(result.ViewBox).toBe('0 0 100 100');\n });\n\n it('should return empty layout for null-ish items', () => {\n const result = computeWordCloudLayout(null as unknown as WordCloudItem[], defaultConfig);\n expect(result.Items).toHaveLength(0);\n });\n\n it('should handle a single item', () => {\n const items: WordCloudItem[] = [{ Text: 'hello', Weight: 1.0 }];\n const result = computeWordCloudLayout(items, defaultConfig);\n expect(result.Items).toHaveLength(1);\n expect(result.Items[0].Text).toBe('hello');\n expect(result.Items[0].FontSize).toBe(48); // max font for weight 1.0\n });\n });\n\n describe('sorting and limiting', () => {\n it('should sort items by weight descending (heaviest first)', () => {\n const items: WordCloudItem[] = [\n { Text: 'low', Weight: 0.2 },\n { Text: 'high', Weight: 0.9 },\n { Text: 'mid', Weight: 0.5 },\n ];\n const result = computeWordCloudLayout(items, defaultConfig);\n expect(result.Items[0].Text).toBe('high');\n expect(result.Items[1].Text).toBe('mid');\n expect(result.Items[2].Text).toBe('low');\n });\n\n it('should limit items to MaxItems', () => {\n const items = makeItems(50);\n const config = { ...defaultConfig, MaxItems: 10 };\n const result = computeWordCloudLayout(items, config);\n expect(result.Items.length).toBeLessThanOrEqual(10);\n });\n });\n\n describe('font size mapping', () => {\n it('should map weight 1.0 to MaxFontSize', () => {\n const items: WordCloudItem[] = [{ Text: 'max', Weight: 1.0 }];\n const result = computeWordCloudLayout(items, defaultConfig);\n expect(result.Items[0].FontSize).toBe(48);\n });\n\n it('should map weight 0.0 to MinFontSize', () => {\n const items: WordCloudItem[] = [{ Text: 'min', Weight: 0.0 }];\n const result = computeWordCloudLayout(items, defaultConfig);\n expect(result.Items[0].FontSize).toBe(12);\n });\n\n it('should map weight 0.5 to midpoint font size', () => {\n const items: WordCloudItem[] = [{ Text: 'mid', Weight: 0.5 }];\n const result = computeWordCloudLayout(items, defaultConfig);\n expect(result.Items[0].FontSize).toBe(30); // 12 + 0.5 * (48 - 12) = 30\n });\n\n it('should clamp weights above 1.0', () => {\n const items: WordCloudItem[] = [{ Text: 'over', Weight: 1.5 }];\n const result = computeWordCloudLayout(items, defaultConfig);\n expect(result.Items[0].FontSize).toBe(48);\n });\n\n it('should clamp weights below 0.0', () => {\n const items: WordCloudItem[] = [{ Text: 'under', Weight: -0.5 }];\n const result = computeWordCloudLayout(items, defaultConfig);\n expect(result.Items[0].FontSize).toBe(12);\n });\n });\n\n describe('positioning', () => {\n it('should place the first item near the center', () => {\n const items: WordCloudItem[] = [{ Text: 'center', Weight: 1.0 }];\n const result = computeWordCloudLayout(items, defaultConfig);\n // First item should be at or very near (0, 0)\n expect(Math.abs(result.Items[0].X)).toBeLessThan(10);\n expect(Math.abs(result.Items[0].Y)).toBeLessThan(10);\n });\n\n it('should not have overlapping items', () => {\n const items = makeItems(20);\n const result = computeWordCloudLayout(items, defaultConfig);\n\n // Verify no two items share the exact same position\n for (let i = 0; i < result.Items.length; i++) {\n for (let j = i + 1; j < result.Items.length; j++) {\n const samePos = result.Items[i].X === result.Items[j].X &&\n result.Items[i].Y === result.Items[j].Y;\n expect(samePos).toBe(false);\n }\n }\n });\n\n it('should place items progressively further from center', () => {\n const items = makeItems(10, 1.0);\n const result = computeWordCloudLayout(items, defaultConfig);\n\n // The average distance from center should increase for later items\n if (result.Items.length >= 2) {\n const firstDist = Math.sqrt(result.Items[0].X ** 2 + result.Items[0].Y ** 2);\n const lastDist = Math.sqrt(\n result.Items[result.Items.length - 1].X ** 2 +\n result.Items[result.Items.length - 1].Y ** 2\n );\n expect(lastDist).toBeGreaterThanOrEqual(firstDist);\n }\n });\n });\n\n describe('rotation', () => {\n it('should rotate approximately 20% of words (every 5th at index 3)', () => {\n const items = makeItems(20);\n const result = computeWordCloudLayout(items, defaultConfig);\n const rotated = result.Items.filter(i => i.Rotation === 90);\n const unrotated = result.Items.filter(i => i.Rotation === 0);\n // Roughly 20% should be rotated (deterministic based on index % 5 === 3)\n expect(rotated.length).toBeGreaterThan(0);\n expect(unrotated.length).toBeGreaterThan(rotated.length);\n });\n\n it('should only use 0 or 90 degree rotation', () => {\n const items = makeItems(30);\n const result = computeWordCloudLayout(items, defaultConfig);\n for (const item of result.Items) {\n expect([0, 90]).toContain(item.Rotation);\n }\n });\n });\n\n describe('layout modes', () => {\n it('should produce valid layout with spiral mode', () => {\n const items = makeItems(15);\n const result = computeWordCloudLayout(items, { ...defaultConfig, Layout: 'spiral' });\n expect(result.Items.length).toBeGreaterThan(0);\n expect(result.ViewBox).not.toBe('0 0 100 100'); // should have computed viewBox\n });\n\n it('should produce valid layout with rectangular mode', () => {\n const items = makeItems(15);\n const result = computeWordCloudLayout(items, { ...defaultConfig, Layout: 'rectangular' });\n expect(result.Items.length).toBeGreaterThan(0);\n });\n });\n\n describe('viewBox computation', () => {\n it('should produce a viewBox that encompasses all items', () => {\n const items = makeItems(10);\n const result = computeWordCloudLayout(items, defaultConfig);\n const [vx, vy, vw, vh] = result.ViewBox.split(' ').map(Number);\n\n for (const item of result.Items) {\n expect(item.X).toBeGreaterThanOrEqual(vx);\n expect(item.X).toBeLessThanOrEqual(vx + vw);\n expect(item.Y).toBeGreaterThanOrEqual(vy);\n expect(item.Y).toBeLessThanOrEqual(vy + vh);\n }\n });\n });\n\n describe('index assignment', () => {\n it('should assign sequential indices to layout items', () => {\n const items = makeItems(5);\n const result = computeWordCloudLayout(items, defaultConfig);\n for (let i = 0; i < result.Items.length; i++) {\n expect(result.Items[i].Index).toBe(i);\n }\n });\n });\n\n describe('metadata preservation', () => {\n it('should preserve Category from input items', () => {\n const items: WordCloudItem[] = [\n { Text: 'test', Weight: 0.8, Category: 'tech' },\n ];\n const result = computeWordCloudLayout(items, defaultConfig);\n expect(result.Items[0].Category).toBe('tech');\n });\n\n it('should preserve Metadata from input items', () => {\n const meta = { id: '123', source: 'test' };\n const items: WordCloudItem[] = [\n { Text: 'test', Weight: 0.8, Metadata: meta },\n ];\n const result = computeWordCloudLayout(items, defaultConfig);\n expect(result.Items[0].Metadata).toEqual(meta);\n });\n });\n\n describe('large datasets', () => {\n it('should handle 100 items without crashing', () => {\n const items = makeItems(100);\n const result = computeWordCloudLayout(items, defaultConfig);\n expect(result.Items.length).toBeGreaterThan(0);\n expect(result.Items.length).toBeLessThanOrEqual(100);\n });\n\n it('should complete layout within reasonable time', () => {\n const items = makeItems(100);\n const start = performance.now();\n computeWordCloudLayout(items, defaultConfig);\n const elapsed = performance.now() - start;\n expect(elapsed).toBeLessThan(5000); // should complete in < 5 seconds\n });\n });\n});\n"]}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { EventEmitter, OnChanges, SimpleChanges } from '@angular/core';
|
|
2
|
+
import { WordCloudItem, WordCloudItemEvent, WordCloudLayoutItem } from './word-cloud.types';
|
|
3
|
+
import * as i0 from "@angular/core";
|
|
4
|
+
/**
|
|
5
|
+
* A reusable, SVG-based word cloud component.
|
|
6
|
+
*
|
|
7
|
+
* Renders weighted text items in a spiral or rectangular layout with
|
|
8
|
+
* collision detection. Supports multiple color modes using MJ design tokens,
|
|
9
|
+
* optional entry animation, and click/hover interaction.
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* ```html
|
|
13
|
+
* <mj-word-cloud
|
|
14
|
+
* [Items]="words"
|
|
15
|
+
* [MaxFontSize]="56"
|
|
16
|
+
* ColorMode="categorical"
|
|
17
|
+
* (ItemClick)="onWordClick($event)">
|
|
18
|
+
* </mj-word-cloud>
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
export declare class MJWordCloudComponent implements OnChanges {
|
|
22
|
+
/** Data items to display in the cloud. */
|
|
23
|
+
Items: WordCloudItem[];
|
|
24
|
+
/** Minimum font size in pixels for the lowest-weighted item. */
|
|
25
|
+
MinFontSize: number;
|
|
26
|
+
/** Maximum font size in pixels for the highest-weighted item. */
|
|
27
|
+
MaxFontSize: number;
|
|
28
|
+
/** Layout algorithm used for word placement. */
|
|
29
|
+
Layout: 'spiral' | 'rectangular';
|
|
30
|
+
/**
|
|
31
|
+
* How colors are assigned to words.
|
|
32
|
+
* - `brand`: all words use --mj-brand-primary with opacity based on weight.
|
|
33
|
+
* - `categorical`: distinct colors from a fixed palette, assigned by Category.
|
|
34
|
+
* - `weight-gradient`: interpolates from muted to brand-primary based on weight.
|
|
35
|
+
*/
|
|
36
|
+
ColorMode: 'brand' | 'categorical' | 'weight-gradient';
|
|
37
|
+
/** Whether words are clickable / hoverable. */
|
|
38
|
+
Interactive: boolean;
|
|
39
|
+
/** Maximum number of items to display (heaviest are kept). */
|
|
40
|
+
MaxItems: number;
|
|
41
|
+
/** Whether to animate words in with a staggered fade. */
|
|
42
|
+
Animate: boolean;
|
|
43
|
+
/** Emitted when a word is clicked. */
|
|
44
|
+
ItemClick: EventEmitter<WordCloudItemEvent>;
|
|
45
|
+
/** Emitted when the pointer enters a word. */
|
|
46
|
+
ItemHover: EventEmitter<WordCloudItemEvent>;
|
|
47
|
+
/** Emitted when the pointer leaves a word. */
|
|
48
|
+
ItemLeave: EventEmitter<WordCloudItemEvent>;
|
|
49
|
+
/** Positioned items ready for rendering. */
|
|
50
|
+
LayoutItems: WordCloudLayoutItem[];
|
|
51
|
+
/** SVG viewBox attribute value. */
|
|
52
|
+
ViewBox: string;
|
|
53
|
+
ngOnChanges(_changes: SimpleChanges): void;
|
|
54
|
+
OnItemClick(item: WordCloudLayoutItem, event: MouseEvent): void;
|
|
55
|
+
OnItemHover(item: WordCloudLayoutItem, event: MouseEvent): void;
|
|
56
|
+
OnItemLeave(item: WordCloudLayoutItem, event: MouseEvent): void;
|
|
57
|
+
private recomputeLayout;
|
|
58
|
+
/**
|
|
59
|
+
* Applies color and opacity to each layout item based on the current ColorMode.
|
|
60
|
+
*/
|
|
61
|
+
private applyColors;
|
|
62
|
+
/**
|
|
63
|
+
* Brand mode: all words use --mj-brand-primary; opacity varies with weight.
|
|
64
|
+
*/
|
|
65
|
+
private applyBrandColors;
|
|
66
|
+
/**
|
|
67
|
+
* Categorical mode: words with the same Category share a color from the palette.
|
|
68
|
+
*/
|
|
69
|
+
private applyCategoricalColors;
|
|
70
|
+
/**
|
|
71
|
+
* Weight-gradient mode: low-weight words use --mj-text-muted, high-weight
|
|
72
|
+
* words use --mj-brand-primary, interpolated via color-mix().
|
|
73
|
+
*/
|
|
74
|
+
private applyWeightGradientColors;
|
|
75
|
+
static ɵfac: i0.ɵɵFactoryDeclaration<MJWordCloudComponent, never>;
|
|
76
|
+
static ɵcmp: i0.ɵɵComponentDeclaration<MJWordCloudComponent, "mj-word-cloud", never, { "Items": { "alias": "Items"; "required": false; }; "MinFontSize": { "alias": "MinFontSize"; "required": false; }; "MaxFontSize": { "alias": "MaxFontSize"; "required": false; }; "Layout": { "alias": "Layout"; "required": false; }; "ColorMode": { "alias": "ColorMode"; "required": false; }; "Interactive": { "alias": "Interactive"; "required": false; }; "MaxItems": { "alias": "MaxItems"; "required": false; }; "Animate": { "alias": "Animate"; "required": false; }; }, { "ItemClick": "ItemClick"; "ItemHover": "ItemHover"; "ItemLeave": "ItemLeave"; }, never, never, true, never>;
|
|
77
|
+
}
|
|
78
|
+
//# sourceMappingURL=word-cloud.component.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"word-cloud.component.d.ts","sourceRoot":"","sources":["../../src/lib/word-cloud.component.ts"],"names":[],"mappings":"AAAA,OAAO,EAIL,YAAY,EACZ,SAAS,EACT,aAAa,EAEd,MAAM,eAAe,CAAC;AACvB,OAAO,EAAE,aAAa,EAAE,kBAAkB,EAAE,mBAAmB,EAAE,MAAM,oBAAoB,CAAC;;AAe5F;;;;;;;;;;;;;;;;GAgBG;AACH,qBAwEa,oBAAqB,YAAW,SAAS;IAGpD,0CAA0C;IACjC,KAAK,EAAE,aAAa,EAAE,CAAM;IAErC,gEAAgE;IACvD,WAAW,SAAM;IAE1B,iEAAiE;IACxD,WAAW,SAAM;IAE1B,gDAAgD;IACvC,MAAM,EAAE,QAAQ,GAAG,aAAa,CAAY;IAErD;;;;;OAKG;IACM,SAAS,EAAE,OAAO,GAAG,aAAa,GAAG,iBAAiB,CAAW;IAE1E,+CAA+C;IACtC,WAAW,UAAQ;IAE5B,8DAA8D;IACrD,QAAQ,SAAO;IAExB,yDAAyD;IAChD,OAAO,UAAQ;IAIxB,sCAAsC;IAC5B,SAAS,mCAA0C;IAE7D,8CAA8C;IACpC,SAAS,mCAA0C;IAE7D,8CAA8C;IACpC,SAAS,mCAA0C;IAI7D,4CAA4C;IAC5C,WAAW,EAAE,mBAAmB,EAAE,CAAM;IAExC,mCAAmC;IACnC,OAAO,SAAiB;IAIxB,WAAW,CAAC,QAAQ,EAAE,aAAa,GAAG,IAAI;IAM1C,WAAW,CAAC,IAAI,EAAE,mBAAmB,EAAE,KAAK,EAAE,UAAU,GAAG,IAAI;IAM/D,WAAW,CAAC,IAAI,EAAE,mBAAmB,EAAE,KAAK,EAAE,UAAU,GAAG,IAAI;IAM/D,WAAW,CAAC,IAAI,EAAE,mBAAmB,EAAE,KAAK,EAAE,UAAU,GAAG,IAAI;IAQ/D,OAAO,CAAC,eAAe;IAavB;;OAEG;IACH,OAAO,CAAC,WAAW;IAanB;;OAEG;IACH,OAAO,CAAC,gBAAgB;IAQxB;;OAEG;IACH,OAAO,CAAC,sBAAsB;IAmB9B;;;OAGG;IACH,OAAO,CAAC,yBAAyB;yCAjJtB,oBAAoB;2CAApB,oBAAoB;CA2JhC"}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy, } from '@angular/core';
|
|
2
|
+
import { computeWordCloudLayout } from './word-cloud.layout';
|
|
3
|
+
import * as i0 from "@angular/core";
|
|
4
|
+
const _forTrack0 = ($index, $item) => $item.Text;
|
|
5
|
+
function MJWordCloudComponent_For_2_Template(rf, ctx) { if (rf & 1) {
|
|
6
|
+
const _r1 = i0.ɵɵgetCurrentView();
|
|
7
|
+
i0.ɵɵnamespaceSVG();
|
|
8
|
+
i0.ɵɵdomElementStart(0, "text", 2);
|
|
9
|
+
i0.ɵɵdomListener("click", function MJWordCloudComponent_For_2_Template_text_click_0_listener($event) { const item_r2 = i0.ɵɵrestoreView(_r1).$implicit; const ctx_r2 = i0.ɵɵnextContext(); return i0.ɵɵresetView(ctx_r2.OnItemClick(item_r2, $event)); })("mouseenter", function MJWordCloudComponent_For_2_Template_text_mouseenter_0_listener($event) { const item_r2 = i0.ɵɵrestoreView(_r1).$implicit; const ctx_r2 = i0.ɵɵnextContext(); return i0.ɵɵresetView(ctx_r2.OnItemHover(item_r2, $event)); })("mouseleave", function MJWordCloudComponent_For_2_Template_text_mouseleave_0_listener($event) { const item_r2 = i0.ɵɵrestoreView(_r1).$implicit; const ctx_r2 = i0.ɵɵnextContext(); return i0.ɵɵresetView(ctx_r2.OnItemLeave(item_r2, $event)); });
|
|
10
|
+
i0.ɵɵtext(1);
|
|
11
|
+
i0.ɵɵdomElementEnd();
|
|
12
|
+
} if (rf & 2) {
|
|
13
|
+
const item_r2 = ctx.$implicit;
|
|
14
|
+
const ctx_r2 = i0.ɵɵnextContext();
|
|
15
|
+
i0.ɵɵstyleProp("fill", item_r2.Color)("opacity", item_r2.Opacity)("animation-delay", ctx_r2.Animate ? item_r2.Index * 30 + "ms" : "0ms");
|
|
16
|
+
i0.ɵɵclassProp("mj-word-cloud-interactive", ctx_r2.Interactive);
|
|
17
|
+
i0.ɵɵattribute("x", item_r2.X)("y", item_r2.Y)("font-size", item_r2.FontSize + "px")("transform", "rotate(" + item_r2.Rotation + " " + item_r2.X + " " + item_r2.Y + ")");
|
|
18
|
+
i0.ɵɵadvance();
|
|
19
|
+
i0.ɵɵtextInterpolate1(" ", item_r2.Text, " ");
|
|
20
|
+
} }
|
|
21
|
+
/** Fixed palette for categorical color mode, using MJ design-token-friendly CSS values. */
|
|
22
|
+
const CATEGORICAL_PALETTE = [
|
|
23
|
+
'var(--mj-brand-primary)',
|
|
24
|
+
'var(--mj-status-success)',
|
|
25
|
+
'var(--mj-status-warning)',
|
|
26
|
+
'var(--mj-status-error)',
|
|
27
|
+
'var(--mj-status-info)',
|
|
28
|
+
'var(--mj-brand-primary-hover)',
|
|
29
|
+
'var(--mj-status-success-text)',
|
|
30
|
+
'var(--mj-status-warning-text)',
|
|
31
|
+
];
|
|
32
|
+
/**
|
|
33
|
+
* A reusable, SVG-based word cloud component.
|
|
34
|
+
*
|
|
35
|
+
* Renders weighted text items in a spiral or rectangular layout with
|
|
36
|
+
* collision detection. Supports multiple color modes using MJ design tokens,
|
|
37
|
+
* optional entry animation, and click/hover interaction.
|
|
38
|
+
*
|
|
39
|
+
* @example
|
|
40
|
+
* ```html
|
|
41
|
+
* <mj-word-cloud
|
|
42
|
+
* [Items]="words"
|
|
43
|
+
* [MaxFontSize]="56"
|
|
44
|
+
* ColorMode="categorical"
|
|
45
|
+
* (ItemClick)="onWordClick($event)">
|
|
46
|
+
* </mj-word-cloud>
|
|
47
|
+
* ```
|
|
48
|
+
*/
|
|
49
|
+
export class MJWordCloudComponent {
|
|
50
|
+
// --------------- Inputs ---------------
|
|
51
|
+
/** Data items to display in the cloud. */
|
|
52
|
+
Items = [];
|
|
53
|
+
/** Minimum font size in pixels for the lowest-weighted item. */
|
|
54
|
+
MinFontSize = 12;
|
|
55
|
+
/** Maximum font size in pixels for the highest-weighted item. */
|
|
56
|
+
MaxFontSize = 48;
|
|
57
|
+
/** Layout algorithm used for word placement. */
|
|
58
|
+
Layout = 'spiral';
|
|
59
|
+
/**
|
|
60
|
+
* How colors are assigned to words.
|
|
61
|
+
* - `brand`: all words use --mj-brand-primary with opacity based on weight.
|
|
62
|
+
* - `categorical`: distinct colors from a fixed palette, assigned by Category.
|
|
63
|
+
* - `weight-gradient`: interpolates from muted to brand-primary based on weight.
|
|
64
|
+
*/
|
|
65
|
+
ColorMode = 'brand';
|
|
66
|
+
/** Whether words are clickable / hoverable. */
|
|
67
|
+
Interactive = true;
|
|
68
|
+
/** Maximum number of items to display (heaviest are kept). */
|
|
69
|
+
MaxItems = 100;
|
|
70
|
+
/** Whether to animate words in with a staggered fade. */
|
|
71
|
+
Animate = true;
|
|
72
|
+
// --------------- Outputs ---------------
|
|
73
|
+
/** Emitted when a word is clicked. */
|
|
74
|
+
ItemClick = new EventEmitter();
|
|
75
|
+
/** Emitted when the pointer enters a word. */
|
|
76
|
+
ItemHover = new EventEmitter();
|
|
77
|
+
/** Emitted when the pointer leaves a word. */
|
|
78
|
+
ItemLeave = new EventEmitter();
|
|
79
|
+
// --------------- Public computed state ---------------
|
|
80
|
+
/** Positioned items ready for rendering. */
|
|
81
|
+
LayoutItems = [];
|
|
82
|
+
/** SVG viewBox attribute value. */
|
|
83
|
+
ViewBox = '0 0 100 100';
|
|
84
|
+
// --------------- Lifecycle ---------------
|
|
85
|
+
ngOnChanges(_changes) {
|
|
86
|
+
this.recomputeLayout();
|
|
87
|
+
}
|
|
88
|
+
// --------------- Event handlers ---------------
|
|
89
|
+
OnItemClick(item, event) {
|
|
90
|
+
if (this.Interactive) {
|
|
91
|
+
this.ItemClick.emit({ Item: item, Event: event });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
OnItemHover(item, event) {
|
|
95
|
+
if (this.Interactive) {
|
|
96
|
+
this.ItemHover.emit({ Item: item, Event: event });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
OnItemLeave(item, event) {
|
|
100
|
+
if (this.Interactive) {
|
|
101
|
+
this.ItemLeave.emit({ Item: item, Event: event });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
// --------------- Private helpers ---------------
|
|
105
|
+
recomputeLayout() {
|
|
106
|
+
const config = {
|
|
107
|
+
MinFontSize: this.MinFontSize,
|
|
108
|
+
MaxFontSize: this.MaxFontSize,
|
|
109
|
+
Layout: this.Layout,
|
|
110
|
+
MaxItems: this.MaxItems,
|
|
111
|
+
};
|
|
112
|
+
const result = computeWordCloudLayout(this.Items, config);
|
|
113
|
+
this.ViewBox = result.ViewBox;
|
|
114
|
+
this.LayoutItems = this.applyColors(result.Items);
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Applies color and opacity to each layout item based on the current ColorMode.
|
|
118
|
+
*/
|
|
119
|
+
applyColors(items) {
|
|
120
|
+
switch (this.ColorMode) {
|
|
121
|
+
case 'brand':
|
|
122
|
+
return this.applyBrandColors(items);
|
|
123
|
+
case 'categorical':
|
|
124
|
+
return this.applyCategoricalColors(items);
|
|
125
|
+
case 'weight-gradient':
|
|
126
|
+
return this.applyWeightGradientColors(items);
|
|
127
|
+
default:
|
|
128
|
+
return items;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Brand mode: all words use --mj-brand-primary; opacity varies with weight.
|
|
133
|
+
*/
|
|
134
|
+
applyBrandColors(items) {
|
|
135
|
+
return items.map(item => ({
|
|
136
|
+
...item,
|
|
137
|
+
Color: 'var(--mj-brand-primary)',
|
|
138
|
+
Opacity: 0.4 + item.Weight * 0.6, // range 0.4 - 1.0
|
|
139
|
+
}));
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Categorical mode: words with the same Category share a color from the palette.
|
|
143
|
+
*/
|
|
144
|
+
applyCategoricalColors(items) {
|
|
145
|
+
const categoryIndex = new Map();
|
|
146
|
+
let nextIndex = 0;
|
|
147
|
+
return items.map(item => {
|
|
148
|
+
const key = item.Category ?? item.Text;
|
|
149
|
+
if (!categoryIndex.has(key)) {
|
|
150
|
+
categoryIndex.set(key, nextIndex);
|
|
151
|
+
nextIndex++;
|
|
152
|
+
}
|
|
153
|
+
const colorIdx = categoryIndex.get(key) % CATEGORICAL_PALETTE.length;
|
|
154
|
+
return {
|
|
155
|
+
...item,
|
|
156
|
+
Color: CATEGORICAL_PALETTE[colorIdx],
|
|
157
|
+
Opacity: 1,
|
|
158
|
+
};
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Weight-gradient mode: low-weight words use --mj-text-muted, high-weight
|
|
163
|
+
* words use --mj-brand-primary, interpolated via color-mix().
|
|
164
|
+
*/
|
|
165
|
+
applyWeightGradientColors(items) {
|
|
166
|
+
return items.map(item => {
|
|
167
|
+
const pct = Math.round(item.Weight * 100);
|
|
168
|
+
return {
|
|
169
|
+
...item,
|
|
170
|
+
Color: `color-mix(in srgb, var(--mj-brand-primary) ${pct}%, var(--mj-text-muted))`,
|
|
171
|
+
Opacity: 1,
|
|
172
|
+
};
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
static ɵfac = function MJWordCloudComponent_Factory(__ngFactoryType__) { return new (__ngFactoryType__ || MJWordCloudComponent)(); };
|
|
176
|
+
static ɵcmp = /*@__PURE__*/ i0.ɵɵdefineComponent({ type: MJWordCloudComponent, selectors: [["mj-word-cloud"]], inputs: { Items: "Items", MinFontSize: "MinFontSize", MaxFontSize: "MaxFontSize", Layout: "Layout", ColorMode: "ColorMode", Interactive: "Interactive", MaxItems: "MaxItems", Animate: "Animate" }, outputs: { ItemClick: "ItemClick", ItemHover: "ItemHover", ItemLeave: "ItemLeave" }, features: [i0.ɵɵNgOnChangesFeature], decls: 3, vars: 3, consts: [["preserveAspectRatio", "xMidYMid meet", 1, "mj-word-cloud"], ["text-anchor", "middle", "dominant-baseline", "central", 1, "mj-word-cloud-item", 3, "fill", "opacity", "animation-delay", "mj-word-cloud-interactive"], ["text-anchor", "middle", "dominant-baseline", "central", 1, "mj-word-cloud-item", 3, "click", "mouseenter", "mouseleave"]], template: function MJWordCloudComponent_Template(rf, ctx) { if (rf & 1) {
|
|
177
|
+
i0.ɵɵnamespaceSVG();
|
|
178
|
+
i0.ɵɵdomElementStart(0, "svg", 0);
|
|
179
|
+
i0.ɵɵrepeaterCreate(1, MJWordCloudComponent_For_2_Template, 2, 13, ":svg:text", 1, _forTrack0);
|
|
180
|
+
i0.ɵɵdomElementEnd();
|
|
181
|
+
} if (rf & 2) {
|
|
182
|
+
i0.ɵɵclassProp("mj-word-cloud-animated", ctx.Animate);
|
|
183
|
+
i0.ɵɵattribute("viewBox", ctx.ViewBox);
|
|
184
|
+
i0.ɵɵadvance();
|
|
185
|
+
i0.ɵɵrepeater(ctx.LayoutItems);
|
|
186
|
+
} }, styles: ["[_nghost-%COMP%] {\n display: block;\n width: 100%;\n height: 100%;\n }\n\n .mj-word-cloud[_ngcontent-%COMP%] {\n width: 100%;\n height: 100%;\n }\n\n .mj-word-cloud-item[_ngcontent-%COMP%] {\n font-family: var(--mj-font-family, inherit);\n transition: opacity 0.2s ease, filter 0.2s ease;\n user-select: none;\n }\n\n .mj-word-cloud-interactive[_ngcontent-%COMP%] {\n cursor: pointer;\n }\n\n .mj-word-cloud-interactive[_ngcontent-%COMP%]:hover {\n opacity: 0.8 !important;\n filter: brightness(1.2);\n }\n\n .mj-word-cloud-animated[_ngcontent-%COMP%] .mj-word-cloud-item[_ngcontent-%COMP%] {\n animation: _ngcontent-%COMP%_mj-word-cloud-fade-in 0.4s ease-out both;\n }\n\n @keyframes _ngcontent-%COMP%_mj-word-cloud-fade-in {\n from {\n opacity: 0;\n }\n to {\n opacity: 1;\n }\n }"], changeDetection: 0 });
|
|
187
|
+
}
|
|
188
|
+
(() => { (typeof ngDevMode === "undefined" || ngDevMode) && i0.ɵsetClassMetadata(MJWordCloudComponent, [{
|
|
189
|
+
type: Component,
|
|
190
|
+
args: [{ selector: 'mj-word-cloud', standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, template: `
|
|
191
|
+
<svg
|
|
192
|
+
[attr.viewBox]="ViewBox"
|
|
193
|
+
class="mj-word-cloud"
|
|
194
|
+
[class.mj-word-cloud-animated]="Animate"
|
|
195
|
+
preserveAspectRatio="xMidYMid meet">
|
|
196
|
+
@for (item of LayoutItems; track item.Text) {
|
|
197
|
+
<text
|
|
198
|
+
[attr.x]="item.X"
|
|
199
|
+
[attr.y]="item.Y"
|
|
200
|
+
[attr.font-size]="item.FontSize + 'px'"
|
|
201
|
+
[attr.transform]="'rotate(' + item.Rotation + ' ' + item.X + ' ' + item.Y + ')'"
|
|
202
|
+
[style.fill]="item.Color"
|
|
203
|
+
[style.opacity]="item.Opacity"
|
|
204
|
+
[style.animation-delay]="Animate ? (item.Index * 30) + 'ms' : '0ms'"
|
|
205
|
+
class="mj-word-cloud-item"
|
|
206
|
+
[class.mj-word-cloud-interactive]="Interactive"
|
|
207
|
+
text-anchor="middle"
|
|
208
|
+
dominant-baseline="central"
|
|
209
|
+
(click)="OnItemClick(item, $event)"
|
|
210
|
+
(mouseenter)="OnItemHover(item, $event)"
|
|
211
|
+
(mouseleave)="OnItemLeave(item, $event)">
|
|
212
|
+
{{ item.Text }}
|
|
213
|
+
</text>
|
|
214
|
+
}
|
|
215
|
+
</svg>
|
|
216
|
+
`, styles: ["\n :host {\n display: block;\n width: 100%;\n height: 100%;\n }\n\n .mj-word-cloud {\n width: 100%;\n height: 100%;\n }\n\n .mj-word-cloud-item {\n font-family: var(--mj-font-family, inherit);\n transition: opacity 0.2s ease, filter 0.2s ease;\n user-select: none;\n }\n\n .mj-word-cloud-interactive {\n cursor: pointer;\n }\n\n .mj-word-cloud-interactive:hover {\n opacity: 0.8 !important;\n filter: brightness(1.2);\n }\n\n .mj-word-cloud-animated .mj-word-cloud-item {\n animation: mj-word-cloud-fade-in 0.4s ease-out both;\n }\n\n @keyframes mj-word-cloud-fade-in {\n from {\n opacity: 0;\n }\n to {\n opacity: 1;\n }\n }\n "] }]
|
|
217
|
+
}], null, { Items: [{
|
|
218
|
+
type: Input
|
|
219
|
+
}], MinFontSize: [{
|
|
220
|
+
type: Input
|
|
221
|
+
}], MaxFontSize: [{
|
|
222
|
+
type: Input
|
|
223
|
+
}], Layout: [{
|
|
224
|
+
type: Input
|
|
225
|
+
}], ColorMode: [{
|
|
226
|
+
type: Input
|
|
227
|
+
}], Interactive: [{
|
|
228
|
+
type: Input
|
|
229
|
+
}], MaxItems: [{
|
|
230
|
+
type: Input
|
|
231
|
+
}], Animate: [{
|
|
232
|
+
type: Input
|
|
233
|
+
}], ItemClick: [{
|
|
234
|
+
type: Output
|
|
235
|
+
}], ItemHover: [{
|
|
236
|
+
type: Output
|
|
237
|
+
}], ItemLeave: [{
|
|
238
|
+
type: Output
|
|
239
|
+
}] }); })();
|
|
240
|
+
(() => { (typeof ngDevMode === "undefined" || ngDevMode) && i0.ɵsetClassDebugInfo(MJWordCloudComponent, { className: "MJWordCloudComponent", filePath: "src/lib/word-cloud.component.ts", lineNumber: 114 }); })();
|
|
241
|
+
//# sourceMappingURL=word-cloud.component.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"word-cloud.component.js","sourceRoot":"","sources":["../../src/lib/word-cloud.component.ts"],"names":[],"mappings":"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","sourcesContent":["import {\n Component,\n Input,\n Output,\n EventEmitter,\n OnChanges,\n SimpleChanges,\n ChangeDetectionStrategy,\n} from '@angular/core';\nimport { WordCloudItem, WordCloudItemEvent, WordCloudLayoutItem } from './word-cloud.types';\nimport { computeWordCloudLayout, WordCloudLayoutConfig } from './word-cloud.layout';\n\n/** Fixed palette for categorical color mode, using MJ design-token-friendly CSS values. */\nconst CATEGORICAL_PALETTE: string[] = [\n 'var(--mj-brand-primary)',\n 'var(--mj-status-success)',\n 'var(--mj-status-warning)',\n 'var(--mj-status-error)',\n 'var(--mj-status-info)',\n 'var(--mj-brand-primary-hover)',\n 'var(--mj-status-success-text)',\n 'var(--mj-status-warning-text)',\n];\n\n/**\n * A reusable, SVG-based word cloud component.\n *\n * Renders weighted text items in a spiral or rectangular layout with\n * collision detection. Supports multiple color modes using MJ design tokens,\n * optional entry animation, and click/hover interaction.\n *\n * @example\n * ```html\n * <mj-word-cloud\n * [Items]=\"words\"\n * [MaxFontSize]=\"56\"\n * ColorMode=\"categorical\"\n * (ItemClick)=\"onWordClick($event)\">\n * </mj-word-cloud>\n * ```\n */\n@Component({\n selector: 'mj-word-cloud',\n standalone: true,\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <svg\n [attr.viewBox]=\"ViewBox\"\n class=\"mj-word-cloud\"\n [class.mj-word-cloud-animated]=\"Animate\"\n preserveAspectRatio=\"xMidYMid meet\">\n @for (item of LayoutItems; track item.Text) {\n <text\n [attr.x]=\"item.X\"\n [attr.y]=\"item.Y\"\n [attr.font-size]=\"item.FontSize + 'px'\"\n [attr.transform]=\"'rotate(' + item.Rotation + ' ' + item.X + ' ' + item.Y + ')'\"\n [style.fill]=\"item.Color\"\n [style.opacity]=\"item.Opacity\"\n [style.animation-delay]=\"Animate ? (item.Index * 30) + 'ms' : '0ms'\"\n class=\"mj-word-cloud-item\"\n [class.mj-word-cloud-interactive]=\"Interactive\"\n text-anchor=\"middle\"\n dominant-baseline=\"central\"\n (click)=\"OnItemClick(item, $event)\"\n (mouseenter)=\"OnItemHover(item, $event)\"\n (mouseleave)=\"OnItemLeave(item, $event)\">\n {{ item.Text }}\n </text>\n }\n </svg>\n `,\n styles: [`\n :host {\n display: block;\n width: 100%;\n height: 100%;\n }\n\n .mj-word-cloud {\n width: 100%;\n height: 100%;\n }\n\n .mj-word-cloud-item {\n font-family: var(--mj-font-family, inherit);\n transition: opacity 0.2s ease, filter 0.2s ease;\n user-select: none;\n }\n\n .mj-word-cloud-interactive {\n cursor: pointer;\n }\n\n .mj-word-cloud-interactive:hover {\n opacity: 0.8 !important;\n filter: brightness(1.2);\n }\n\n .mj-word-cloud-animated .mj-word-cloud-item {\n animation: mj-word-cloud-fade-in 0.4s ease-out both;\n }\n\n @keyframes mj-word-cloud-fade-in {\n from {\n opacity: 0;\n }\n to {\n opacity: 1;\n }\n }\n `],\n})\nexport class MJWordCloudComponent implements OnChanges {\n // --------------- Inputs ---------------\n\n /** Data items to display in the cloud. */\n @Input() Items: WordCloudItem[] = [];\n\n /** Minimum font size in pixels for the lowest-weighted item. */\n @Input() MinFontSize = 12;\n\n /** Maximum font size in pixels for the highest-weighted item. */\n @Input() MaxFontSize = 48;\n\n /** Layout algorithm used for word placement. */\n @Input() Layout: 'spiral' | 'rectangular' = 'spiral';\n\n /**\n * How colors are assigned to words.\n * - `brand`: all words use --mj-brand-primary with opacity based on weight.\n * - `categorical`: distinct colors from a fixed palette, assigned by Category.\n * - `weight-gradient`: interpolates from muted to brand-primary based on weight.\n */\n @Input() ColorMode: 'brand' | 'categorical' | 'weight-gradient' = 'brand';\n\n /** Whether words are clickable / hoverable. */\n @Input() Interactive = true;\n\n /** Maximum number of items to display (heaviest are kept). */\n @Input() MaxItems = 100;\n\n /** Whether to animate words in with a staggered fade. */\n @Input() Animate = true;\n\n // --------------- Outputs ---------------\n\n /** Emitted when a word is clicked. */\n @Output() ItemClick = new EventEmitter<WordCloudItemEvent>();\n\n /** Emitted when the pointer enters a word. */\n @Output() ItemHover = new EventEmitter<WordCloudItemEvent>();\n\n /** Emitted when the pointer leaves a word. */\n @Output() ItemLeave = new EventEmitter<WordCloudItemEvent>();\n\n // --------------- Public computed state ---------------\n\n /** Positioned items ready for rendering. */\n LayoutItems: WordCloudLayoutItem[] = [];\n\n /** SVG viewBox attribute value. */\n ViewBox = '0 0 100 100';\n\n // --------------- Lifecycle ---------------\n\n ngOnChanges(_changes: SimpleChanges): void {\n this.recomputeLayout();\n }\n\n // --------------- Event handlers ---------------\n\n OnItemClick(item: WordCloudLayoutItem, event: MouseEvent): void {\n if (this.Interactive) {\n this.ItemClick.emit({ Item: item, Event: event });\n }\n }\n\n OnItemHover(item: WordCloudLayoutItem, event: MouseEvent): void {\n if (this.Interactive) {\n this.ItemHover.emit({ Item: item, Event: event });\n }\n }\n\n OnItemLeave(item: WordCloudLayoutItem, event: MouseEvent): void {\n if (this.Interactive) {\n this.ItemLeave.emit({ Item: item, Event: event });\n }\n }\n\n // --------------- Private helpers ---------------\n\n private recomputeLayout(): void {\n const config: WordCloudLayoutConfig = {\n MinFontSize: this.MinFontSize,\n MaxFontSize: this.MaxFontSize,\n Layout: this.Layout,\n MaxItems: this.MaxItems,\n };\n\n const result = computeWordCloudLayout(this.Items, config);\n this.ViewBox = result.ViewBox;\n this.LayoutItems = this.applyColors(result.Items);\n }\n\n /**\n * Applies color and opacity to each layout item based on the current ColorMode.\n */\n private applyColors(items: WordCloudLayoutItem[]): WordCloudLayoutItem[] {\n switch (this.ColorMode) {\n case 'brand':\n return this.applyBrandColors(items);\n case 'categorical':\n return this.applyCategoricalColors(items);\n case 'weight-gradient':\n return this.applyWeightGradientColors(items);\n default:\n return items;\n }\n }\n\n /**\n * Brand mode: all words use --mj-brand-primary; opacity varies with weight.\n */\n private applyBrandColors(items: WordCloudLayoutItem[]): WordCloudLayoutItem[] {\n return items.map(item => ({\n ...item,\n Color: 'var(--mj-brand-primary)',\n Opacity: 0.4 + item.Weight * 0.6, // range 0.4 - 1.0\n }));\n }\n\n /**\n * Categorical mode: words with the same Category share a color from the palette.\n */\n private applyCategoricalColors(items: WordCloudLayoutItem[]): WordCloudLayoutItem[] {\n const categoryIndex = new Map<string, number>();\n let nextIndex = 0;\n\n return items.map(item => {\n const key = item.Category ?? item.Text;\n if (!categoryIndex.has(key)) {\n categoryIndex.set(key, nextIndex);\n nextIndex++;\n }\n const colorIdx = categoryIndex.get(key)! % CATEGORICAL_PALETTE.length;\n return {\n ...item,\n Color: CATEGORICAL_PALETTE[colorIdx],\n Opacity: 1,\n };\n });\n }\n\n /**\n * Weight-gradient mode: low-weight words use --mj-text-muted, high-weight\n * words use --mj-brand-primary, interpolated via color-mix().\n */\n private applyWeightGradientColors(items: WordCloudLayoutItem[]): WordCloudLayoutItem[] {\n return items.map(item => {\n const pct = Math.round(item.Weight * 100);\n return {\n ...item,\n Color: `color-mix(in srgb, var(--mj-brand-primary) ${pct}%, var(--mj-text-muted))`,\n Opacity: 1,\n };\n });\n }\n}\n"]}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { WordCloudItem, WordCloudLayoutItem } from './word-cloud.types';
|
|
2
|
+
/**
|
|
3
|
+
* Configuration for the layout engine.
|
|
4
|
+
*/
|
|
5
|
+
export interface WordCloudLayoutConfig {
|
|
6
|
+
MinFontSize: number;
|
|
7
|
+
MaxFontSize: number;
|
|
8
|
+
Layout: 'spiral' | 'rectangular';
|
|
9
|
+
MaxItems: number;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Computes the layout for all word cloud items, returning positioned items
|
|
13
|
+
* and the overall bounding viewBox.
|
|
14
|
+
*/
|
|
15
|
+
export declare function computeWordCloudLayout(items: WordCloudItem[], config: WordCloudLayoutConfig): {
|
|
16
|
+
Items: WordCloudLayoutItem[];
|
|
17
|
+
ViewBox: string;
|
|
18
|
+
};
|
|
19
|
+
//# sourceMappingURL=word-cloud.layout.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"word-cloud.layout.d.ts","sourceRoot":"","sources":["../../src/lib/word-cloud.layout.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,mBAAmB,EAAE,MAAM,oBAAoB,CAAC;AAExE;;GAEG;AACH,MAAM,WAAW,qBAAqB;IACpC,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,MAAM,CAAC;IACpB,MAAM,EAAE,QAAQ,GAAG,aAAa,CAAC;IACjC,QAAQ,EAAE,MAAM,CAAC;CAClB;AAkID;;;GAGG;AACH,wBAAgB,sBAAsB,CACpC,KAAK,EAAE,aAAa,EAAE,EACtB,MAAM,EAAE,qBAAqB,GAC5B;IAAE,KAAK,EAAE,mBAAmB,EAAE,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CA2DnD"}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Estimates the width of a text string at a given font size.
|
|
3
|
+
* Uses an average character width ratio since we cannot measure DOM elements
|
|
4
|
+
* in a pure layout function.
|
|
5
|
+
*/
|
|
6
|
+
function estimateTextWidth(text, fontSize) {
|
|
7
|
+
// Average character width is roughly 0.6x the font size for sans-serif fonts
|
|
8
|
+
const avgCharWidthRatio = 0.6;
|
|
9
|
+
return text.length * fontSize * avgCharWidthRatio;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Creates a bounding box for a word at a given position and font size,
|
|
13
|
+
* accounting for rotation.
|
|
14
|
+
*/
|
|
15
|
+
function createBoundingBox(x, y, text, fontSize, rotation) {
|
|
16
|
+
const textWidth = estimateTextWidth(text, fontSize);
|
|
17
|
+
const textHeight = fontSize * 1.2; // line-height factor
|
|
18
|
+
const padding = fontSize * 0.15; // small padding between words
|
|
19
|
+
// For 90-degree rotation, width and height swap
|
|
20
|
+
const effectiveWidth = rotation === 90 ? textHeight : textWidth;
|
|
21
|
+
const effectiveHeight = rotation === 90 ? textWidth : textHeight;
|
|
22
|
+
return {
|
|
23
|
+
Left: x - effectiveWidth / 2 - padding,
|
|
24
|
+
Top: y - effectiveHeight / 2 - padding,
|
|
25
|
+
Right: x + effectiveWidth / 2 + padding,
|
|
26
|
+
Bottom: y + effectiveHeight / 2 + padding,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Checks whether two bounding boxes overlap.
|
|
31
|
+
*/
|
|
32
|
+
function boxesOverlap(a, b) {
|
|
33
|
+
return a.Left < b.Right && a.Right > b.Left && a.Top < b.Bottom && a.Bottom > b.Top;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Checks whether a candidate bounding box collides with any of the placed boxes.
|
|
37
|
+
*/
|
|
38
|
+
function hasCollision(candidate, placed) {
|
|
39
|
+
for (const box of placed) {
|
|
40
|
+
if (boxesOverlap(candidate, box)) {
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Generates (x, y) positions along an Archimedean spiral from the center.
|
|
48
|
+
*/
|
|
49
|
+
function* spiralPositions(centerX, centerY, maxAttempts) {
|
|
50
|
+
const spiralStep = 1; // angular step in radians
|
|
51
|
+
const radiusGrowth = 2; // pixels per radian
|
|
52
|
+
for (let i = 0; i < maxAttempts; i++) {
|
|
53
|
+
const angle = spiralStep * i * 0.1;
|
|
54
|
+
const radius = radiusGrowth * angle;
|
|
55
|
+
yield {
|
|
56
|
+
X: centerX + radius * Math.cos(angle),
|
|
57
|
+
Y: centerY + radius * Math.sin(angle),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Generates positions in a rectangular grid expanding from the center.
|
|
63
|
+
*/
|
|
64
|
+
function* rectangularPositions(centerX, centerY, maxAttempts) {
|
|
65
|
+
const step = 8;
|
|
66
|
+
yield { X: centerX, Y: centerY };
|
|
67
|
+
let attempt = 1;
|
|
68
|
+
for (let ring = 1; attempt < maxAttempts; ring++) {
|
|
69
|
+
const offset = ring * step;
|
|
70
|
+
// Top and bottom edges
|
|
71
|
+
for (let dx = -offset; dx <= offset && attempt < maxAttempts; dx += step) {
|
|
72
|
+
yield { X: centerX + dx, Y: centerY - offset };
|
|
73
|
+
attempt++;
|
|
74
|
+
yield { X: centerX + dx, Y: centerY + offset };
|
|
75
|
+
attempt++;
|
|
76
|
+
}
|
|
77
|
+
// Left and right edges (excluding corners)
|
|
78
|
+
for (let dy = -offset + step; dy < offset && attempt < maxAttempts; dy += step) {
|
|
79
|
+
yield { X: centerX - offset, Y: centerY + dy };
|
|
80
|
+
attempt++;
|
|
81
|
+
yield { X: centerX + offset, Y: centerY + dy };
|
|
82
|
+
attempt++;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Maps a weight (0-1) to a font size between min and max.
|
|
88
|
+
*/
|
|
89
|
+
function weightToFontSize(weight, minFont, maxFont) {
|
|
90
|
+
const clamped = Math.max(0, Math.min(1, weight));
|
|
91
|
+
return minFont + clamped * (maxFont - minFont);
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Decides whether a word should be rotated (90 degrees) based on its index.
|
|
95
|
+
* Roughly 20% of words are rotated for visual variety.
|
|
96
|
+
*/
|
|
97
|
+
function shouldRotate(index) {
|
|
98
|
+
// Deterministic pseudo-random based on index
|
|
99
|
+
return index % 5 === 3;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Computes the layout for all word cloud items, returning positioned items
|
|
103
|
+
* and the overall bounding viewBox.
|
|
104
|
+
*/
|
|
105
|
+
export function computeWordCloudLayout(items, config) {
|
|
106
|
+
if (!items || items.length === 0) {
|
|
107
|
+
return { Items: [], ViewBox: '0 0 100 100' };
|
|
108
|
+
}
|
|
109
|
+
// Take top N items by weight
|
|
110
|
+
const sorted = [...items]
|
|
111
|
+
.sort((a, b) => b.Weight - a.Weight)
|
|
112
|
+
.slice(0, config.MaxItems);
|
|
113
|
+
const placedBoxes = [];
|
|
114
|
+
const layoutItems = [];
|
|
115
|
+
const maxAttempts = 500;
|
|
116
|
+
const centerX = 0;
|
|
117
|
+
const centerY = 0;
|
|
118
|
+
for (let i = 0; i < sorted.length; i++) {
|
|
119
|
+
const item = sorted[i];
|
|
120
|
+
const fontSize = weightToFontSize(item.Weight, config.MinFontSize, config.MaxFontSize);
|
|
121
|
+
const rotation = shouldRotate(i) ? 90 : 0;
|
|
122
|
+
const positionGenerator = config.Layout === 'rectangular'
|
|
123
|
+
? rectangularPositions(centerX, centerY, maxAttempts)
|
|
124
|
+
: spiralPositions(centerX, centerY, maxAttempts);
|
|
125
|
+
let placed = false;
|
|
126
|
+
for (const pos of positionGenerator) {
|
|
127
|
+
const box = createBoundingBox(pos.X, pos.Y, item.Text, fontSize, rotation);
|
|
128
|
+
if (!hasCollision(box, placedBoxes)) {
|
|
129
|
+
placedBoxes.push(box);
|
|
130
|
+
layoutItems.push({
|
|
131
|
+
...item,
|
|
132
|
+
FontSize: fontSize,
|
|
133
|
+
X: pos.X,
|
|
134
|
+
Y: pos.Y,
|
|
135
|
+
Rotation: rotation,
|
|
136
|
+
Color: '', // assigned by the component based on ColorMode
|
|
137
|
+
Opacity: 1,
|
|
138
|
+
Index: layoutItems.length,
|
|
139
|
+
});
|
|
140
|
+
placed = true;
|
|
141
|
+
break;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
// If no position found after maxAttempts, skip the word
|
|
145
|
+
if (!placed) {
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
// Compute the overall bounding box for the viewBox
|
|
150
|
+
const viewBox = computeViewBox(placedBoxes);
|
|
151
|
+
return { Items: layoutItems, ViewBox: viewBox };
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Computes an SVG viewBox string that fits all placed bounding boxes
|
|
155
|
+
* with a small amount of padding.
|
|
156
|
+
*/
|
|
157
|
+
function computeViewBox(boxes) {
|
|
158
|
+
if (boxes.length === 0) {
|
|
159
|
+
return '0 0 100 100';
|
|
160
|
+
}
|
|
161
|
+
let minX = Infinity;
|
|
162
|
+
let minY = Infinity;
|
|
163
|
+
let maxX = -Infinity;
|
|
164
|
+
let maxY = -Infinity;
|
|
165
|
+
for (const box of boxes) {
|
|
166
|
+
if (box.Left < minX)
|
|
167
|
+
minX = box.Left;
|
|
168
|
+
if (box.Top < minY)
|
|
169
|
+
minY = box.Top;
|
|
170
|
+
if (box.Right > maxX)
|
|
171
|
+
maxX = box.Right;
|
|
172
|
+
if (box.Bottom > maxY)
|
|
173
|
+
maxY = box.Bottom;
|
|
174
|
+
}
|
|
175
|
+
const padding = 20;
|
|
176
|
+
const x = minX - padding;
|
|
177
|
+
const y = minY - padding;
|
|
178
|
+
const width = maxX - minX + padding * 2;
|
|
179
|
+
const height = maxY - minY + padding * 2;
|
|
180
|
+
return `${x} ${y} ${width} ${height}`;
|
|
181
|
+
}
|
|
182
|
+
//# sourceMappingURL=word-cloud.layout.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"word-cloud.layout.js","sourceRoot":"","sources":["../../src/lib/word-cloud.layout.ts"],"names":[],"mappings":"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","sourcesContent":["import { WordCloudItem, WordCloudLayoutItem } from './word-cloud.types';\n\n/**\n * Configuration for the layout engine.\n */\nexport interface WordCloudLayoutConfig {\n MinFontSize: number;\n MaxFontSize: number;\n Layout: 'spiral' | 'rectangular';\n MaxItems: number;\n}\n\n/**\n * Axis-aligned bounding box for collision detection.\n */\ninterface BoundingBox {\n Left: number;\n Top: number;\n Right: number;\n Bottom: number;\n}\n\n/**\n * Estimates the width of a text string at a given font size.\n * Uses an average character width ratio since we cannot measure DOM elements\n * in a pure layout function.\n */\nfunction estimateTextWidth(text: string, fontSize: number): number {\n // Average character width is roughly 0.6x the font size for sans-serif fonts\n const avgCharWidthRatio = 0.6;\n return text.length * fontSize * avgCharWidthRatio;\n}\n\n/**\n * Creates a bounding box for a word at a given position and font size,\n * accounting for rotation.\n */\nfunction createBoundingBox(\n x: number,\n y: number,\n text: string,\n fontSize: number,\n rotation: number\n): BoundingBox {\n const textWidth = estimateTextWidth(text, fontSize);\n const textHeight = fontSize * 1.2; // line-height factor\n const padding = fontSize * 0.15; // small padding between words\n\n // For 90-degree rotation, width and height swap\n const effectiveWidth = rotation === 90 ? textHeight : textWidth;\n const effectiveHeight = rotation === 90 ? textWidth : textHeight;\n\n return {\n Left: x - effectiveWidth / 2 - padding,\n Top: y - effectiveHeight / 2 - padding,\n Right: x + effectiveWidth / 2 + padding,\n Bottom: y + effectiveHeight / 2 + padding,\n };\n}\n\n/**\n * Checks whether two bounding boxes overlap.\n */\nfunction boxesOverlap(a: BoundingBox, b: BoundingBox): boolean {\n return a.Left < b.Right && a.Right > b.Left && a.Top < b.Bottom && a.Bottom > b.Top;\n}\n\n/**\n * Checks whether a candidate bounding box collides with any of the placed boxes.\n */\nfunction hasCollision(candidate: BoundingBox, placed: BoundingBox[]): boolean {\n for (const box of placed) {\n if (boxesOverlap(candidate, box)) {\n return true;\n }\n }\n return false;\n}\n\n/**\n * Generates (x, y) positions along an Archimedean spiral from the center.\n */\nfunction* spiralPositions(centerX: number, centerY: number, maxAttempts: number): Generator<{ X: number; Y: number }> {\n const spiralStep = 1; // angular step in radians\n const radiusGrowth = 2; // pixels per radian\n\n for (let i = 0; i < maxAttempts; i++) {\n const angle = spiralStep * i * 0.1;\n const radius = radiusGrowth * angle;\n yield {\n X: centerX + radius * Math.cos(angle),\n Y: centerY + radius * Math.sin(angle),\n };\n }\n}\n\n/**\n * Generates positions in a rectangular grid expanding from the center.\n */\nfunction* rectangularPositions(centerX: number, centerY: number, maxAttempts: number): Generator<{ X: number; Y: number }> {\n const step = 8;\n yield { X: centerX, Y: centerY };\n let attempt = 1;\n\n for (let ring = 1; attempt < maxAttempts; ring++) {\n const offset = ring * step;\n // Top and bottom edges\n for (let dx = -offset; dx <= offset && attempt < maxAttempts; dx += step) {\n yield { X: centerX + dx, Y: centerY - offset };\n attempt++;\n yield { X: centerX + dx, Y: centerY + offset };\n attempt++;\n }\n // Left and right edges (excluding corners)\n for (let dy = -offset + step; dy < offset && attempt < maxAttempts; dy += step) {\n yield { X: centerX - offset, Y: centerY + dy };\n attempt++;\n yield { X: centerX + offset, Y: centerY + dy };\n attempt++;\n }\n }\n}\n\n/**\n * Maps a weight (0-1) to a font size between min and max.\n */\nfunction weightToFontSize(weight: number, minFont: number, maxFont: number): number {\n const clamped = Math.max(0, Math.min(1, weight));\n return minFont + clamped * (maxFont - minFont);\n}\n\n/**\n * Decides whether a word should be rotated (90 degrees) based on its index.\n * Roughly 20% of words are rotated for visual variety.\n */\nfunction shouldRotate(index: number): boolean {\n // Deterministic pseudo-random based on index\n return index % 5 === 3;\n}\n\n/**\n * Computes the layout for all word cloud items, returning positioned items\n * and the overall bounding viewBox.\n */\nexport function computeWordCloudLayout(\n items: WordCloudItem[],\n config: WordCloudLayoutConfig\n): { Items: WordCloudLayoutItem[]; ViewBox: string } {\n if (!items || items.length === 0) {\n return { Items: [], ViewBox: '0 0 100 100' };\n }\n\n // Take top N items by weight\n const sorted = [...items]\n .sort((a, b) => b.Weight - a.Weight)\n .slice(0, config.MaxItems);\n\n const placedBoxes: BoundingBox[] = [];\n const layoutItems: WordCloudLayoutItem[] = [];\n const maxAttempts = 500;\n\n const centerX = 0;\n const centerY = 0;\n\n for (let i = 0; i < sorted.length; i++) {\n const item = sorted[i];\n const fontSize = weightToFontSize(item.Weight, config.MinFontSize, config.MaxFontSize);\n const rotation = shouldRotate(i) ? 90 : 0;\n\n const positionGenerator =\n config.Layout === 'rectangular'\n ? rectangularPositions(centerX, centerY, maxAttempts)\n : spiralPositions(centerX, centerY, maxAttempts);\n\n let placed = false;\n\n for (const pos of positionGenerator) {\n const box = createBoundingBox(pos.X, pos.Y, item.Text, fontSize, rotation);\n\n if (!hasCollision(box, placedBoxes)) {\n placedBoxes.push(box);\n layoutItems.push({\n ...item,\n FontSize: fontSize,\n X: pos.X,\n Y: pos.Y,\n Rotation: rotation,\n Color: '', // assigned by the component based on ColorMode\n Opacity: 1,\n Index: layoutItems.length,\n });\n placed = true;\n break;\n }\n }\n\n // If no position found after maxAttempts, skip the word\n if (!placed) {\n continue;\n }\n }\n\n // Compute the overall bounding box for the viewBox\n const viewBox = computeViewBox(placedBoxes);\n\n return { Items: layoutItems, ViewBox: viewBox };\n}\n\n/**\n * Computes an SVG viewBox string that fits all placed bounding boxes\n * with a small amount of padding.\n */\nfunction computeViewBox(boxes: BoundingBox[]): string {\n if (boxes.length === 0) {\n return '0 0 100 100';\n }\n\n let minX = Infinity;\n let minY = Infinity;\n let maxX = -Infinity;\n let maxY = -Infinity;\n\n for (const box of boxes) {\n if (box.Left < minX) minX = box.Left;\n if (box.Top < minY) minY = box.Top;\n if (box.Right > maxX) maxX = box.Right;\n if (box.Bottom > maxY) maxY = box.Bottom;\n }\n\n const padding = 20;\n const x = minX - padding;\n const y = minY - padding;\n const width = maxX - minX + padding * 2;\n const height = maxY - minY + padding * 2;\n\n return `${x} ${y} ${width} ${height}`;\n}\n"]}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A single item to display in the word cloud.
|
|
3
|
+
*/
|
|
4
|
+
export interface WordCloudItem {
|
|
5
|
+
/** Display text */
|
|
6
|
+
Text: string;
|
|
7
|
+
/** Weight / importance (0.0 - 1.0) - determines font size */
|
|
8
|
+
Weight: number;
|
|
9
|
+
/** Optional category for color grouping in 'categorical' color mode */
|
|
10
|
+
Category?: string;
|
|
11
|
+
/** Optional arbitrary metadata passed through click/hover events */
|
|
12
|
+
Metadata?: Record<string, unknown>;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Event payload emitted when the user interacts with a word cloud item.
|
|
16
|
+
*/
|
|
17
|
+
export interface WordCloudItemEvent {
|
|
18
|
+
/** The item that was interacted with */
|
|
19
|
+
Item: WordCloudItem;
|
|
20
|
+
/** The original DOM event */
|
|
21
|
+
Event: MouseEvent;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Internal representation of a word cloud item after layout computation.
|
|
25
|
+
*/
|
|
26
|
+
export interface WordCloudLayoutItem extends WordCloudItem {
|
|
27
|
+
/** Computed font size in pixels */
|
|
28
|
+
FontSize: number;
|
|
29
|
+
/** Computed X position (center of the text) */
|
|
30
|
+
X: number;
|
|
31
|
+
/** Computed Y position (center of the text) */
|
|
32
|
+
Y: number;
|
|
33
|
+
/** Rotation in degrees (0 or 90) */
|
|
34
|
+
Rotation: number;
|
|
35
|
+
/** Computed CSS color string */
|
|
36
|
+
Color: string;
|
|
37
|
+
/** Computed opacity (used in weight-gradient mode) */
|
|
38
|
+
Opacity: number;
|
|
39
|
+
/** Index for animation staggering */
|
|
40
|
+
Index: number;
|
|
41
|
+
}
|
|
42
|
+
//# sourceMappingURL=word-cloud.types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"word-cloud.types.d.ts","sourceRoot":"","sources":["../../src/lib/word-cloud.types.ts"],"names":[],"mappings":"AAAA;;GAEG;AACH,MAAM,WAAW,aAAa;IAC5B,mBAAmB;IACnB,IAAI,EAAE,MAAM,CAAC;IACb,6DAA6D;IAC7D,MAAM,EAAE,MAAM,CAAC;IACf,uEAAuE;IACvE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,oEAAoE;IACpE,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACpC;AAED;;GAEG;AACH,MAAM,WAAW,kBAAkB;IACjC,wCAAwC;IACxC,IAAI,EAAE,aAAa,CAAC;IACpB,6BAA6B;IAC7B,KAAK,EAAE,UAAU,CAAC;CACnB;AAED;;GAEG;AACH,MAAM,WAAW,mBAAoB,SAAQ,aAAa;IACxD,mCAAmC;IACnC,QAAQ,EAAE,MAAM,CAAC;IACjB,+CAA+C;IAC/C,CAAC,EAAE,MAAM,CAAC;IACV,+CAA+C;IAC/C,CAAC,EAAE,MAAM,CAAC;IACV,oCAAoC;IACpC,QAAQ,EAAE,MAAM,CAAC;IACjB,gCAAgC;IAChC,KAAK,EAAE,MAAM,CAAC;IACd,sDAAsD;IACtD,OAAO,EAAE,MAAM,CAAC;IAChB,qCAAqC;IACrC,KAAK,EAAE,MAAM,CAAC;CACf"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"word-cloud.types.js","sourceRoot":"","sources":["../../src/lib/word-cloud.types.ts"],"names":[],"mappings":"","sourcesContent":["/**\n * A single item to display in the word cloud.\n */\nexport interface WordCloudItem {\n /** Display text */\n Text: string;\n /** Weight / importance (0.0 - 1.0) - determines font size */\n Weight: number;\n /** Optional category for color grouping in 'categorical' color mode */\n Category?: string;\n /** Optional arbitrary metadata passed through click/hover events */\n Metadata?: Record<string, unknown>;\n}\n\n/**\n * Event payload emitted when the user interacts with a word cloud item.\n */\nexport interface WordCloudItemEvent {\n /** The item that was interacted with */\n Item: WordCloudItem;\n /** The original DOM event */\n Event: MouseEvent;\n}\n\n/**\n * Internal representation of a word cloud item after layout computation.\n */\nexport interface WordCloudLayoutItem extends WordCloudItem {\n /** Computed font size in pixels */\n FontSize: number;\n /** Computed X position (center of the text) */\n X: number;\n /** Computed Y position (center of the text) */\n Y: number;\n /** Rotation in degrees (0 or 90) */\n Rotation: number;\n /** Computed CSS color string */\n Color: string;\n /** Computed opacity (used in weight-gradient mode) */\n Opacity: number;\n /** Index for animation staggering */\n Index: number;\n}\n"]}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export * from './lib/word-cloud.types';
|
|
2
|
+
export * from './lib/word-cloud.layout';
|
|
3
|
+
export * from './lib/word-cloud.component';
|
|
4
|
+
/**
|
|
5
|
+
* Tree-shaking prevention function. Call this from consuming modules
|
|
6
|
+
* to ensure the component registration is not removed by bundlers.
|
|
7
|
+
*/
|
|
8
|
+
export declare function LoadWordCloud(): void;
|
|
9
|
+
//# sourceMappingURL=public-api.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"public-api.d.ts","sourceRoot":"","sources":["../src/public-api.ts"],"names":[],"mappings":"AAIA,cAAc,wBAAwB,CAAC;AACvC,cAAc,yBAAyB,CAAC;AACxC,cAAc,4BAA4B,CAAC;AAE3C;;;GAGG;AACH,wBAAgB,aAAa,SAE5B"}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Public API Surface for @memberjunction/ng-word-cloud
|
|
3
|
+
*/
|
|
4
|
+
export * from './lib/word-cloud.types';
|
|
5
|
+
export * from './lib/word-cloud.layout';
|
|
6
|
+
export * from './lib/word-cloud.component';
|
|
7
|
+
/**
|
|
8
|
+
* Tree-shaking prevention function. Call this from consuming modules
|
|
9
|
+
* to ensure the component registration is not removed by bundlers.
|
|
10
|
+
*/
|
|
11
|
+
export function LoadWordCloud() {
|
|
12
|
+
/* prevent tree-shaking */
|
|
13
|
+
}
|
|
14
|
+
//# sourceMappingURL=public-api.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"public-api.js","sourceRoot":"","sources":["../src/public-api.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,cAAc,wBAAwB,CAAC;AACvC,cAAc,yBAAyB,CAAC;AACxC,cAAc,4BAA4B,CAAC;AAE3C;;;GAGG;AACH,MAAM,UAAU,aAAa;IAC3B,0BAA0B;AAC5B,CAAC","sourcesContent":["/*\n * Public API Surface for @memberjunction/ng-word-cloud\n */\n\nexport * from './lib/word-cloud.types';\nexport * from './lib/word-cloud.layout';\nexport * from './lib/word-cloud.component';\n\n/**\n * Tree-shaking prevention function. Call this from consuming modules\n * to ensure the component registration is not removed by bundlers.\n */\nexport function LoadWordCloud() {\n /* prevent tree-shaking */\n}\n"]}
|
package/package.json
CHANGED
|
@@ -1,10 +1,40 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@memberjunction/ng-word-cloud",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "5.24.0",
|
|
4
|
+
"description": "MemberJunction: Reusable SVG-based word cloud component for Angular.",
|
|
5
|
+
"main": "./dist/public-api.js",
|
|
6
|
+
"typings": "./dist/public-api.d.ts",
|
|
7
|
+
"files": [
|
|
8
|
+
"/dist"
|
|
9
|
+
],
|
|
10
|
+
"scripts": {
|
|
11
|
+
"build": "npm run clean && ngc",
|
|
12
|
+
"clean": "rimraf dist",
|
|
13
|
+
"test": "vitest run",
|
|
14
|
+
"test:watch": "vitest"
|
|
15
|
+
},
|
|
5
16
|
"keywords": [
|
|
6
|
-
"
|
|
7
|
-
"
|
|
8
|
-
"
|
|
9
|
-
]
|
|
17
|
+
"MemberJunction",
|
|
18
|
+
"Angular",
|
|
19
|
+
"word-cloud"
|
|
20
|
+
],
|
|
21
|
+
"author": "",
|
|
22
|
+
"license": "ISC",
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"@angular/compiler": "21.1.3",
|
|
25
|
+
"@angular/compiler-cli": "21.1.3",
|
|
26
|
+
"vitest": "^4.0.18"
|
|
27
|
+
},
|
|
28
|
+
"peerDependencies": {
|
|
29
|
+
"@angular/common": "21.1.3",
|
|
30
|
+
"@angular/core": "21.1.3"
|
|
31
|
+
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"tslib": "^2.8.1"
|
|
34
|
+
},
|
|
35
|
+
"sideEffects": false,
|
|
36
|
+
"repository": {
|
|
37
|
+
"type": "git",
|
|
38
|
+
"url": "https://github.com/MemberJunction/MJ"
|
|
39
|
+
}
|
|
10
40
|
}
|