Skip to content

Commit

Permalink
Adds TagCloud component.
Browse files Browse the repository at this point in the history
  • Loading branch information
Andrew McKendrick authored and Andrew McKendrick committed Dec 2, 2020
1 parent 49d73e2 commit 2737a58
Show file tree
Hide file tree
Showing 13 changed files with 310 additions and 11 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ These are the components available in the latest version of the library:
| [Emphasized Heading](docs/EMPHASIZED-HEADING.md) | ![Emphasized Heading](docs/emphasized-heading.png) |
| [Embossed Heading](docs/EMBOSSED-HEADING.md) | <img alt="Embossed Heading" src="docs/embossed-heading.png" width="100" /> |
| [Estimated Read Time](docs/ESTIMATED-READ-TIME.md) | ![Estimated Read Time](docs/estimated-read-time-template.png) |
| [Tag Cloud](docs/TAG-CLOUD.md) | ![Tag Cloud Tag](docs/tag-cloud.png) |
| [Tag Cloud Tag](docs/TAG-CLOUD-TAG.md) | ![Tag Cloud Tag](docs/tag-cloud-tag.png) |
| [Zoom Image](docs/ZOOM-IMAGE.md) | N/A |

Expand Down
34 changes: 34 additions & 0 deletions docs/TAG-CLOUD.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Tag Cloud

Displays a cloud of tags (aka tag cloud).

## Examples

![Tag Cloud](tag-cloud.png)

``` html
<TagCloud tags={[{ tag: 'dogs', value: 10 }, { tag: 'cats', value: 20 }, { tag: 'birds', value: 4 }]} className="tenrec" />
```

## API

| Name | Type | Default | Description |
|---|---|---|---|
| tags | Array | | Required. An array containing the tags that appear within the cloud. See below for more details. |
| className | String | null | Optional. The name of an additional class to apply to the component. |

### Structure of Tags

The tags attribute is an array of zero or more tag elements. Each tag has the following structure:

``` json
{
tag: 'name goes here',
value: 10
}
```

The tag property is the name of the tag that will appear within the tag cloud.
The value is the number of that tag used to size the tag in relation to other
tags. For example, if the tag is "Dog" and there are 10 dogs in the house, then
this number would be 10.
Binary file added docs/tag-cloud.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "tenrec-ui",
"version": "1.0.59",
"version": "1.0.60",
"description": "A set of reusable React UI components.",
"private": false,
"main": "dist/index.js",
Expand Down
6 changes: 2 additions & 4 deletions src/index.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,11 @@
/* eslint-disable react/jsx-filename-extension */
import React from 'react';
import ReactDOM from 'react-dom';
import TagCloudTag from './tag-cloud-tag/tag-cloud-tag';
import TagCloud from './tag-cloud/tag-cloud';

ReactDOM.render(
<React.StrictMode>
<TagCloudTag tag="Toronto" size={3} />
<TagCloudTag tag="Edmonton" size={2} />
<TagCloudTag tag="New York" size={5} />
<TagCloud tags={[{ tag: 'Toronto', value: 3 }, { tag: 'Edmonton', value: 100 }]} />
</React.StrictMode>,
document.getElementById('root'),
);
4 changes: 2 additions & 2 deletions src/tag-cloud-tag/tag-cloud-tag.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,9 @@ function TagCloudTag(props) {
const { className, tag } = props;
let { size } = props;

// Limit the size is between 1 and 6.
// Limit the size is between 1 and 5.
size = Math.max(size, 1);
size = Math.min(size, 6);
size = Math.min(size, 5);

return (
<div className={classnames('tag-cloud-tag', `tag-cloud-tag-size${size}`, className)}>{tag}</div>
Expand Down
6 changes: 3 additions & 3 deletions src/tag-cloud-tag/tag-cloud-tag.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,11 @@ it('uses a size of 1 when the size attribute is less than 1.', () => {
expect(container.querySelector('div:first-child').getAttribute('class')).toBe('tag-cloud-tag tag-cloud-tag-size1');
});

it('uses a size of 6 when the size attribute is greater than 6.', () => {
it('uses a size of 6 when the size attribute is greater than 5.', () => {
act(() => {
render(<TagCloudTag tag="Toronto" size={7} />, container);
render(<TagCloudTag tag="Toronto" size={6} />, container);
});
expect(container.querySelector('div:first-child').getAttribute('class')).toBe('tag-cloud-tag tag-cloud-tag-size6');
expect(container.querySelector('div:first-child').getAttribute('class')).toBe('tag-cloud-tag tag-cloud-tag-size5');
});

it('displays the text specified by the tag attribute.', () => {
Expand Down
71 changes: 71 additions & 0 deletions src/tag-cloud/tag-cloud-helper.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/**
* Contains static methods for working with tags and tag clouds.
*/
class TagCloudHelper {
/**
* Calculates the total value of all tags.
*
* @param {Array} tags An array of tags.
* @example
* // Returns 34
* calculateTotal([{tag: "dogs", value: 10}, {tag: "cats", value: 20},
* {tag: "birds", value: 4}]);
* @returns {number} The total value of all tags.
*/
static calculateTotal(tags) {
if (!tags || !Array.isArray(tags) || !tags.length) {
return 0;
}

const reducer = (accumulator, currentValue) => (
Number.isFinite(accumulator) ? accumulator : this.getTagValue(accumulator))
+ this.getTagValue(currentValue);

return tags.reduce(reducer);
}

/**
* Calculates the tag size based on the value of the specified tag and the
* total of all tags.
*
* @param {number} total The total value of all tags.
* @param {number} value The value of the current tag.
* @example
* @returns {number} A value from 1 to 5 indicating the size of the tag in
* relation to other tags.
*/
static getTagSize(total, value) {
if (!Number.isFinite(total) || !Number.isFinite(value)) {
return 1;
}

if (value < 0) {
return 1;
}

if (value > total) {
return 5;
}

return Math.min(Math.trunc(Math.trunc((value / total) * 100) / 20) + 1, 5);
}

/**
* Gets the value of the specified tag.
*
* @param {object} tag The tag.
* @example
* // Returns 10
* getTagValue({ tag: "dog", value: 10 })
* @returns {number} The value of the tag.
*/
static getTagValue(tag) {
if (!tag || !Number.isFinite(tag.value)) {
return 0;
}

return tag.value;
}
}

export default TagCloudHelper;
93 changes: 93 additions & 0 deletions src/tag-cloud/tag-cloud-helper.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import TagCloudHelper from './tag-cloud-helper';

describe('calculateTotal', () => {
it('returns zero if the tags argument is undefined or null or not an array.', () => {
expect(TagCloudHelper.calculateTotal(undefined)).toBe(0);
expect(TagCloudHelper.calculateTotal(null)).toBe(0);
expect(TagCloudHelper.calculateTotal('Not an array.')).toBe(0);
});

it('returns zero if the tags argument is an empty array.', () => {
expect(TagCloudHelper.calculateTotal([])).toBe(0);
});

it('calculates the total of all tag values.', () => {
expect(TagCloudHelper.calculateTotal([
{ tag: 'dogs', value: 10 },
{ tag: 'cats', value: 20 },
{ tag: 'birds', value: 4 },
])).toBe(34);
});

it('does not include an element that does not have a value in the result.', () => {
expect(TagCloudHelper.calculateTotal([
{ tag: 'dogs', value: 10 },
{ tag: 'cats' },
{ name: 'invalid' },
{ tag: 'birds', other: 4 },
])).toBe(10);
});
});

describe('getTagSize', () => {
it('returns 1 if the total is null, undefined, or not a number.', () => {
expect(TagCloudHelper.getTagSize(null, 0)).toBe(1);
expect(TagCloudHelper.getTagSize(undefined, 1)).toBe(1);
expect(TagCloudHelper.getTagSize('1', 19)).toBe(1);
});

it('returns 1 if the value is null, undefined, or not a number.', () => {
expect(TagCloudHelper.getTagSize(100, null)).toBe(1);
expect(TagCloudHelper.getTagSize(100, undefined)).toBe(1);
expect(TagCloudHelper.getTagSize(100, '19')).toBe(1);
});

it('returns 1 if the the value is between 0 and 19, percentage wise.', () => {
expect(TagCloudHelper.getTagSize(100, 0)).toBe(1);
expect(TagCloudHelper.getTagSize(100, 1)).toBe(1);
expect(TagCloudHelper.getTagSize(100, 19)).toBe(1);
});

it('returns 2 if the the value is between 20 and 39, percentage wise.', () => {
expect(TagCloudHelper.getTagSize(100, 20)).toBe(2);
expect(TagCloudHelper.getTagSize(100, 39)).toBe(2);
});

it('returns 3 if the the value is between 40 and 59, percentage wise.', () => {
expect(TagCloudHelper.getTagSize(100, 40)).toBe(3);
expect(TagCloudHelper.getTagSize(100, 59)).toBe(3);
});

it('returns 4 if the the value is between 60 and 79, percentage wise.', () => {
expect(TagCloudHelper.getTagSize(100, 60)).toBe(4);
expect(TagCloudHelper.getTagSize(100, 79)).toBe(4);
});

it('returns 5 if the the value is between 80 and 100, percentage wise.', () => {
expect(TagCloudHelper.getTagSize(100, 80)).toBe(5);
expect(TagCloudHelper.getTagSize(100, 100)).toBe(5);
});

it('returns 5 if the the value is greater than the total.', () => {
expect(TagCloudHelper.getTagSize(100, 101)).toBe(5);
});

it('returns 1 if the the value is less than zero.', () => {
expect(TagCloudHelper.getTagSize(100, -1)).toBe(1);
});
});

describe('getTagValue', () => {
it('returns zero if the tag argument is undefined or null.', () => {
expect(TagCloudHelper.getTagValue(undefined)).toBe(0);
expect(TagCloudHelper.getTagValue(null)).toBe(0);
});

it('return zero if there is no value property on the specified tag.', () => {
expect(TagCloudHelper.getTagValue({ tag: 'Cat' })).toBe(0);
});

it('return the value of the value property on the specified tag.', () => {
expect(TagCloudHelper.getTagValue({ tag: 'Cat', value: 10 })).toBe(10);
});
});
Empty file added src/tag-cloud/tag-cloud.css
Empty file.
56 changes: 56 additions & 0 deletions src/tag-cloud/tag-cloud.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import React from 'react';
import PropTypes from 'prop-types';
import classnames from 'classnames';
import TagCloudTag from '../tag-cloud-tag/tag-cloud-tag';
import './tag-cloud.css';
import TagCloudHelper from './tag-cloud-helper';

/**
* Represents a tag cloud or a group of tags that are sized based on how common
* those tags are in relation to other tags. For example, a tag cloud that is
* based on the type of pets may have 10 dogs, 20 cats, and 4 birds. Three tags
* will be displayed, one for Dogs, one for Cats, and one for Birds. The
* font-size of the Dogs tag will appear smaller than Cats, but larger than
* Birds.
*
* @param {*} props The properties of the component.
* @example
* // Displays the component.
* <TagCloud tags={[{tag: "dogs", value: 10}, {tag: "cats", value: 20},
* {tag: "birds", value: 4}]} />
* @returns {HTMLElement} An HTML element representing the component.
*/
function TagCloud(props) {
const { className, tags } = props;

// Calculate the total of all tag values. This will be used to calculate the
// percentage of tag in relation to other tags.
const total = TagCloudHelper.calculateTotal(tags);

const elements = tags.map((tag) => {
const size = TagCloudHelper.getTagSize(total, tag.value);

return <TagCloudTag key={tag.tag} size={size} tag={tag.tag} />;
});

return (
<div className={classnames('tag-cloud', className)}>
{elements}
</div>
);
}

export default TagCloud;

TagCloud.propTypes = {
/** Specifies an array of JSON key/value pairs, one pair for each tag with the
* name of the tag and popularity of the tag. For example: { tag: 'birds', value: 4 } */
tags: PropTypes.arrayOf(PropTypes.object).isRequired,

/** The class to apply to the resulting element. */
className: PropTypes.string,
};

TagCloud.defaultProps = {
className: null,
};
46 changes: 46 additions & 0 deletions src/tag-cloud/tag-cloud.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import React from 'react';
import { render, unmountComponentAtNode } from 'react-dom';
import { act } from 'react-dom/test-utils';
import TagCloud from './tag-cloud';

let container = null;
beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
});

afterEach(() => {
unmountComponentAtNode(container);
container.remove();
container = null;
});

it('does not display any additional class name when no className attribute is specified.', () => {
act(() => {
render(<TagCloud tags={[{ tag: 'dogs', value: 10 }, { tag: 'cats', value: 20 }, { tag: 'birds', value: 4 }]} />, container);
});
expect(container.querySelector('div:first-child').getAttribute('class')).toBe('tag-cloud');
});

it('includes the additional class name when a className attribute is specified.', () => {
act(() => {
render(<TagCloud tags={[{ tag: 'dogs', value: 10 }, { tag: 'cats', value: 20 }, { tag: 'birds', value: 4 }]} className="example" />, container);
});
expect(container.querySelector('div:first-child').getAttribute('class')).toBe('tag-cloud example');
});

it('displays the same number of tags that are passed in.', () => {
act(() => {
render(<TagCloud tags={[{ tag: 'dogs', value: 10 }, { tag: 'cats', value: 20 }, { tag: 'birds', value: 4 }]} />, container);
});
expect(container.querySelectorAll('div[class*="tag-cloud-tag"]').length).toBe(3);
});

it('displays correct size of each tag.', () => {
act(() => {
render(<TagCloud tags={[{ tag: 'dogs', value: 10 }, { tag: 'cats', value: 20 }, { tag: 'birds', value: 4 }]} />, container);
});
expect(container.querySelectorAll('div[class*="tag-cloud-tag"')[0].getAttribute('class')).toContain('2');
expect(container.querySelectorAll('div[class*="tag-cloud-tag"')[1].getAttribute('class')).toContain('3');
expect(container.querySelectorAll('div[class*="tag-cloud-tag"')[2].getAttribute('class')).toContain('1');
});
2 changes: 1 addition & 1 deletion src/text-helper.js
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ class TextHelper {
}

let words = this.getWords(text);
if (words.length < index) {
if (words.length - 1 < index) {
return '';
}

Expand Down

0 comments on commit 2737a58

Please sign in to comment.