espial-0.0.34: frontend/src/components/TagCloud.tsx
import React from 'react';
import { getTagCloud, updateTagCloudMode } from '../api';
import { app } from '../globals';
import { useTagCloudStore } from '../stores/tagCloudStore';
import {
isExpanded,
isRelated,
setExpanded,
type TagCloud as TagCloudT,
type TagCloudModeF,
tagCloudModeFromF,
} from '../types';
import { encodeTag, fromNullableStr } from '../util';
function rescale(
f: (x: number) => number,
v: number,
n: number,
m: number,
l: number,
h: number,
): number {
const denom = f(m - n);
const ratio = m - n < 0.01 || denom === 0 ? 1.0 : f(v - n) / denom;
return ratio * (h - l) + l;
}
function toArray(curtags: string[], n: number, m: number, tagcloud: TagCloudT) {
const a = app();
const cur = curtags.map((t) => t.toLowerCase());
const entries: Array<[string, number]> = Object.entries(tagcloud).sort((x, y) =>
x[0].toLowerCase().localeCompare(y[0].toLowerCase()),
);
const linkToFilterTag = (rest: string) =>
`${fromNullableStr(a.userR)}${rest === '' ? '' : `/t:${rest}`}`;
const out: Array<React.ReactNode> = [];
for (const [k, v] of entries) {
const kLower = k.toLowerCase();
const fontsize = rescale((x) => x, v, n, m, 100.0, 150.0);
const opacity = rescale((x) => Math.log(1.0 + x), v, n, m, 0.6, 1.0);
const style: React.CSSProperties = { fontSize: `${fontsize.toString()}%`, opacity };
const includeExclude =
cur.length === 0 ? null : !cur.includes(kLower) ? (
<a
href={linkToFilterTag(cur.concat([kLower]).map(encodeTag).join('+'))}
className="link mr2 tag-include"
>
⊕
</a>
) : (
<a
href={linkToFilterTag(
cur
.filter((t) => t !== kLower)
.map(encodeTag)
.join('+'),
)}
className="link mr2 tag-exclude"
>
⊖
</a>
);
out.push(
<a
key={`${k}-tag`}
href={linkToFilterTag(encodeTag(k))}
className="link tag mr1"
style={style}
>
{k}
</a>,
);
if (includeExclude) out.push(<React.Fragment key={`${k}-ie`}>{includeExclude}</React.Fragment>);
}
return out;
}
/** Displays the tag cloud, with controls to filter by top tags, frequency, or related tags. */
export function TagCloud({ initialMode }: { initialMode: TagCloudModeF }) {
const mode = useTagCloudStore((s) => s.mode);
const tagcloud = useTagCloudStore((s) => s.tagcloud);
const setMode = useTagCloudStore((s) => s.setMode);
const setTagCloud = useTagCloudStore((s) => s.setTagCloud);
React.useEffect(() => {
setMode(initialMode);
}, [initialMode]);
React.useEffect(() => {
async function fetchTagCloud(m: TagCloudModeF) {
if (m.kind === 'none') return;
const tc = await getTagCloud(tagCloudModeFromF(m));
setTagCloud(tc ?? {});
}
void fetchTagCloud(mode);
}, [mode, setTagCloud]);
if (mode.kind === 'none') return <div className="tag_cloud" />;
const modetop: TagCloudModeF = { kind: 'top', expanded: isExpanded(mode), value: 200 };
const modelb1: TagCloudModeF = { kind: 'lowerBound', expanded: isExpanded(mode), value: 1 };
const modelb2: TagCloudModeF = { kind: 'lowerBound', expanded: isExpanded(mode), value: 2 };
const modelb5: TagCloudModeF = { kind: 'lowerBound', expanded: isExpanded(mode), value: 5 };
const modelb10: TagCloudModeF = { kind: 'lowerBound', expanded: isExpanded(mode), value: 10 };
const modelb20: TagCloudModeF = { kind: 'lowerBound', expanded: isExpanded(mode), value: 20 };
const values = Object.values(tagcloud);
const n = values.length ? Math.min(...values) : 1;
const m = values.length ? Math.max(...values) : 1;
async function onExpanded(expanded: boolean) {
const next = setExpanded(mode, expanded);
setMode(next);
await updateTagCloudMode(tagCloudModeFromF(next));
}
async function onChangeMode(next: TagCloudModeF) {
if (JSON.stringify(mode) === JSON.stringify(next)) {
await onExpanded(!isExpanded(mode));
} else {
setMode(setExpanded(next, true));
}
}
return (
<div className="tag_cloud mv3">
<div className="tag_cloud_header mb2">
{isRelated(mode) ? (
<button
type="button"
className="pa1 f7 link thm-hover-link-color mr1 b"
onClick={() => void onExpanded(!isExpanded(mode))}
>
Related Tags
</button>
) : (
<>
<button
type="button"
className={`pa1 f7 link thm-hover-link-color mr1${JSON.stringify(mode) === JSON.stringify(modetop) ? ' b' : ''}`}
title="show a cloud of your most-used tags"
onClick={() => void onChangeMode(modetop)}
>
Top Tags
</button>
<button
type="button"
className={`pa1 f7 link thm-hover-link-color ml2 ${JSON.stringify(mode) === JSON.stringify(modelb1) ? ' b' : ''}`}
title="show all tags"
onClick={() => void onChangeMode(modelb1)}
>
all
</button>
{'‧'}
<button
type="button"
className={`pa1 f7 link thm-hover-link-color${JSON.stringify(mode) === JSON.stringify(modelb2) ? ' b' : ''}`}
title="show tags with at least 2 bookmarks"
onClick={() => void onChangeMode(modelb2)}
>
2
</button>
{'‧'}
<button
type="button"
className={`pa1 f7 link thm-hover-link-color${JSON.stringify(mode) === JSON.stringify(modelb5) ? ' b' : ''}`}
title="show tags with at least 5 bookmarks"
onClick={() => void onChangeMode(modelb5)}
>
5
</button>
{'‧'}
<button
type="button"
className={`pa1 f7 link thm-hover-link-color${JSON.stringify(mode) === JSON.stringify(modelb10) ? ' b' : ''}`}
title="show tags with at least 10 bookmarks"
onClick={() => void onChangeMode(modelb10)}
>
10
</button>
{'‧'}
<button
type="button"
className={`pa1 f7 link thm-hover-link-color${JSON.stringify(mode) === JSON.stringify(modelb20) ? ' b' : ''}`}
title="show tags with at least 20 bookmarks"
onClick={() => void onChangeMode(modelb20)}
>
20
</button>
</>
)}
<button
type="button"
className="pa1 ml2 f7 link thm-text-faded thm-hover-link-color"
onClick={() => void onExpanded(!isExpanded(mode))}
>
{isExpanded(mode) ? 'hide' : 'show'}
</button>
</div>
{isExpanded(mode) ? (
<div className="tag_cloud_body">
{mode.kind === 'related'
? toArray(mode.value, n, m, tagcloud)
: toArray([], n, m, tagcloud)}
</div>
) : null}
</div>
);
}