packages feed

espial-0.0.37: frontend/src/components/TagCloud.tsx

import React from 'react';
import { useTranslation } from 'react-i18next';

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';

/** Displays the tag cloud, with controls to filter by top tags, frequency, or related tags. */
export function TagCloud({ initialMode }: { initialMode: TagCloudModeF }) {
  const { t } = useTranslation();
  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 makeLbMode = (n: number): TagCloudModeBrowse => ({
    kind: 'lowerBound',
    expanded: isExpanded(mode),
    value: n,
  });

  const relatedTags =
    mode.kind === 'related' || mode.kind === 'relatedLowerBound' ? mode.value : [];
  const makeRelatedMode = (): TagCloudModeRelated => ({
    kind: 'related',
    expanded: isExpanded(mode),
    value: relatedTags,
  });
  const makeRelatedLbMode = (lb: number): TagCloudModeRelated => ({
    kind: 'relatedLowerBound',
    expanded: isExpanded(mode),
    value: relatedTags,
    lowerBound: lb,
  });

  async function onExpanded(expanded: boolean) {
    const next = setExpanded(mode, expanded);
    setMode(next);
    if (app().dat.isowner) {
      await updateTagCloudMode(tagCloudModeFromF(next));
    }
  }

  async function onChangeBrowse(next: TagCloudModeBrowse) {
    const isActive =
      (next.kind === 'top' && mode.kind === 'top') ||
      (next.kind === 'lowerBound' && mode.kind === 'lowerBound' && mode.value === next.value);
    if (isActive) {
      await onExpanded(!isExpanded(mode));
    } else {
      setMode(setExpanded(next, true));
    }
  }

  function onChangeRelated(next: TagCloudModeRelated) {
    const isActive =
      (next.kind === 'related' && mode.kind === 'related') ||
      (next.kind === 'relatedLowerBound' &&
        mode.kind === 'relatedLowerBound' &&
        mode.lowerBound === next.lowerBound);
    if (isActive) {
      void onExpanded(!isExpanded(mode));
    } else {
      setMode(setExpanded(next, true));
    }
  }

  const isTagCloudModeRelated = mode.kind === 'related' || mode.kind === 'relatedLowerBound';
  const activeLb = mode.kind === 'relatedLowerBound' ? mode.lowerBound : 0;

  return (
    <div className="tag_cloud mv3">
      <div className="tag_cloud_header mb2">
        {isTagCloudModeRelated ? (
          <>
            <button
              type="button"
              className={`pa1 f7 link thm-hover-link-color mr1${mode.kind === 'related' ? ' b' : ''}`}
              title={t('tagCloud.topTagsTitle')}
              onClick={() => {
                onChangeRelated(makeRelatedMode());
              }}
            >
              {t('tagCloud.relatedTags')}
            </button>
            {(
              [
                [1, 'tagCloud.allTitle'],
                [2, 'tagCloud.min2Title'],
                [5, 'tagCloud.min5Title'],
                [10, 'tagCloud.min10Title'],
                [20, 'tagCloud.min20Title'],
              ] as const
            ).map(([lb, titleKey]) => (
              <React.Fragment key={lb}>
                {lb !== 1 && '‧'}
                <button
                  type="button"
                  className={`pa1 f7 link thm-hover-link-color${lb === 1 ? ' ml2' : ''}${activeLb === lb ? ' b' : ''}`}
                  title={t(titleKey)}
                  onClick={() => {
                    onChangeRelated(makeRelatedLbMode(lb));
                  }}
                >
                  {lb === 1 ? t('filter.all') : lb}
                </button>
              </React.Fragment>
            ))}
          </>
        ) : (
          <>
            <button
              type="button"
              className={`pa1 f7 link thm-hover-link-color mr1${mode.kind === 'top' ? ' b' : ''}`}
              title={t('tagCloud.topTagsTitle')}
              onClick={() => void onChangeBrowse({ kind: 'top', expanded: isExpanded(mode) })}
            >
              {t('tagCloud.topTags')}
            </button>
            {(
              [
                [1, 'tagCloud.allTitle'],
                [2, 'tagCloud.min2Title'],
                [5, 'tagCloud.min5Title'],
                [10, 'tagCloud.min10Title'],
                [20, 'tagCloud.min20Title'],
              ] as const
            ).map(([n, titleKey]) => (
              <React.Fragment key={n}>
                {n !== 1 && '‧'}
                <button
                  type="button"
                  className={`pa1 f7 link thm-hover-link-color${n === 1 ? ' ml2' : ''}${mode.kind === 'lowerBound' && mode.value === n ? ' b' : ''}`}
                  title={t(titleKey)}
                  onClick={() => void onChangeBrowse(makeLbMode(n))}
                >
                  {n === 1 ? t('filter.all') : n}
                </button>
              </React.Fragment>
            ))}
          </>
        )}
        <button
          type="button"
          className="pa1 ml2 f7 link thm-text-faded thm-hover-link-color"
          onClick={() => void onExpanded(!isExpanded(mode))}
        >
          {isExpanded(mode) ? t('tagCloud.hide') : t('tagCloud.show')}
        </button>
      </div>

      {isExpanded(mode) ? (
        <div className="tag_cloud_body">
          {renderLinks(isTagCloudModeRelated ? mode.value : [], tagcloud)}
        </div>
      ) : null}
    </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;
}