espial-0.0.41: 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,
setExpanded,
type TagCloud,
type TagCloudModeBrowse,
type TagCloudModeF,
tagCloudModeFromF,
type TagCloudModeRelated,
} from '../types';
import { encodeTag, fromNullableStr } from '../util';
/** Binds click handlers on the server-rendered tag cloud header (mode/filter controls) to the tag cloud store. */
export function bindTagCloudHeader(renderElSelector: string) {
return () => {
const container = document.querySelector<HTMLElement>(renderElSelector);
if (!container) return;
container.addEventListener('click', (e) => {
const target = (e.target as HTMLElement).closest<HTMLElement>('[data-tcm-action]');
if (!target || !container.contains(target)) return;
const parsed = readTagCloudHeaderAction(target);
if (!parsed) return;
e.preventDefault();
const mode = useTagCloudStore.getState().mode;
if (parsed.action === 'toggleExpand') {
void applyTagCloudMode(setExpanded(mode, !isExpanded(mode)));
return;
}
if (parsed.action === 'top' || parsed.action === 'lowerBound') {
const next: TagCloudModeBrowse =
parsed.action === 'top'
? { kind: 'top', expanded: isExpanded(mode) }
: { kind: 'lowerBound', expanded: isExpanded(mode), value: parsed.value };
const isActive =
(next.kind === 'top' && mode.kind === 'top') ||
(next.kind === 'lowerBound' && mode.kind === 'lowerBound' && mode.value === next.value);
void applyTagCloudMode(
isActive ? setExpanded(mode, !isExpanded(mode)) : setExpanded(next, true),
);
return;
}
const relatedTags = currentRelatedTags(mode);
const next: TagCloudModeRelated =
parsed.action === 'related'
? { kind: 'related', expanded: isExpanded(mode), value: relatedTags }
: {
kind: 'relatedLowerBound',
expanded: isExpanded(mode),
value: relatedTags,
lowerBound: parsed.lowerBound,
};
const isActive =
(next.kind === 'related' && mode.kind === 'related') ||
(next.kind === 'relatedLowerBound' &&
mode.kind === 'relatedLowerBound' &&
mode.lowerBound === next.lowerBound);
void applyTagCloudMode(
isActive ? setExpanded(mode, !isExpanded(mode)) : setExpanded(next, true),
);
});
function updateHeaderUI(mode: TagCloudModeF) {
if (!container) return;
if (mode.kind === 'none') return;
container.querySelectorAll<HTMLElement>('[data-tcm-action]').forEach((btn) => {
const parsed = readTagCloudHeaderAction(btn);
if (!parsed) return;
switch (parsed.action) {
case 'top':
btn.classList.toggle('b', mode.kind === 'top');
return;
case 'lowerBound':
btn.classList.toggle('b', mode.kind === 'lowerBound' && mode.value === parsed.value);
return;
case 'related':
btn.classList.toggle('b', mode.kind === 'related');
return;
case 'relatedLowerBound':
btn.classList.toggle(
'b',
mode.kind === 'relatedLowerBound' && mode.lowerBound === parsed.lowerBound,
);
return;
case 'toggleExpand': {
const label = isExpanded(mode) ? btn.dataset.hideLabel : btn.dataset.showLabel;
if (label !== undefined) btn.textContent = label;
return;
}
}
});
}
useTagCloudStore.subscribe((state) => {
updateHeaderUI(state.mode);
});
};
}
type TagCloudHeaderAction =
| { action: 'top' }
| { action: 'lowerBound'; value: number }
| { action: 'related' }
| { action: 'relatedLowerBound'; lowerBound: number }
| { action: 'toggleExpand' };
function readTagCloudHeaderAction(el: HTMLElement): TagCloudHeaderAction | null {
switch (el.dataset.tcmAction) {
case 'top':
return { action: 'top' };
case 'lowerBound':
return { action: 'lowerBound', value: Number(el.dataset.tcmValue) };
case 'related':
return { action: 'related' };
case 'relatedLowerBound':
return { action: 'relatedLowerBound', lowerBound: Number(el.dataset.tcmLb) };
case 'toggleExpand':
return { action: 'toggleExpand' };
default:
return null;
}
}
async function applyTagCloudMode(next: TagCloudModeF) {
useTagCloudStore.getState().setMode(next);
if (app().dat.isowner) {
await updateTagCloudMode(tagCloudModeFromF(next));
}
}
function currentRelatedTags(mode: TagCloudModeF): string[] {
return mode.kind === 'related' || mode.kind === 'relatedLowerBound' ? mode.value : [];
}
/** Displays the tag cloud body (the list of tag links). The header (mode/filter controls) is server-rendered; see bindTagCloudHeader. */
export function TagCloudBody({ 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' || !isExpanded(mode)) return null;
const curtags = mode.kind === 'related' || mode.kind === 'relatedLowerBound' ? mode.value : [];
return <div className="tag_cloud_body">{renderLinks(curtags, tagcloud)}</div>;
}
function renderLinks(curtags: string[], tagcloud: TagCloud) {
const a = app();
const cur = curtags.map((tag) => tag.toLowerCase());
const linkToFilterTag = (rest: string) =>
`${fromNullableStr(a.userR)}${rest === '' ? '' : `/t:${rest}`}`;
const tagCounts = Object.values(tagcloud);
const cMin = tagCounts.length ? Math.min(...tagCounts) : 1;
const cMax = tagCounts.length ? Math.max(...tagCounts) : 1;
return Object.entries(tagcloud)
.sort((x, y) => x[0].toLowerCase().localeCompare(y[0].toLowerCase()))
.map(([tag, c]) => {
const kLower = tag.toLowerCase();
const fontsize = rescale((x) => Math.log(1.0 + x), c, cMin, cMax, 100.0, 133.0);
const opacity = rescale((x) => Math.log(1.0 + x), c, cMin, cMax, 0.6, 1.0);
const style: React.CSSProperties = { fontSize: `${fontsize.toString()}%`, opacity };
const includeExcludeLink =
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>
);
return (
<React.Fragment key={tag}>
<a href={linkToFilterTag(encodeTag(tag))} className="link tag mr1" style={style}>
{tag}
</a>
{includeExcludeLink}
</React.Fragment>
);
});
}
/** Rescales a value v in the range [n, m] to a value in the range [l, h] using a function f.
* @param f - The function to apply to the value.
* @param v - The value to rescale.
* @param n - The minimum of the input range.
* @param m - The maximum of the input range.
* @param l - The minimum of the output range.
* @param h - The maximum of the output range.
* @returns The rescaled value.
*/
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;
}