mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-29 01:01:34 -07:00
feat(stats): persist trend title visibility and add per-chart title limit
Hidden-title selections in the Trends tab now survive relaunches via localStorage, and a 'Top N per chart' selector (All/3/5/7/10, also persisted) restores the old top-N declutter behavior on demand.
This commit is contained in:
@@ -22,6 +22,19 @@ test('buildLineData keeps every title as a series instead of capping at the top
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('buildLineData caps series at maxSeries when set, keeping the top titles', () => {
|
||||||
|
const { points, seriesKeys } = buildLineData(makePoints(17), 7);
|
||||||
|
|
||||||
|
assert.equal(seriesKeys.length, 7);
|
||||||
|
assert.deepEqual(
|
||||||
|
seriesKeys,
|
||||||
|
Array.from({ length: 7 }, (_, i) => `Title ${i}`),
|
||||||
|
);
|
||||||
|
for (const row of points) {
|
||||||
|
assert.ok(!('Title 16' in row));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('buildLineData orders series by total value descending', () => {
|
test('buildLineData orders series by total value descending', () => {
|
||||||
const { seriesKeys } = buildLineData([
|
const { seriesKeys } = buildLineData([
|
||||||
{ epochDay: 20_000, animeTitle: 'Small', value: 1 },
|
{ epochDay: 20_000, animeTitle: 'Small', value: 1 },
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ interface StackedTrendChartProps {
|
|||||||
title: string;
|
title: string;
|
||||||
data: PerAnimeDataPoint[];
|
data: PerAnimeDataPoint[];
|
||||||
colorPalette?: string[];
|
colorPalette?: string[];
|
||||||
|
maxSeries?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_LINE_COLORS = [
|
const DEFAULT_LINE_COLORS = [
|
||||||
@@ -33,18 +34,23 @@ const DEFAULT_LINE_COLORS = [
|
|||||||
'#f4dbd6',
|
'#f4dbd6',
|
||||||
];
|
];
|
||||||
|
|
||||||
export function buildLineData(raw: PerAnimeDataPoint[]) {
|
export function buildLineData(raw: PerAnimeDataPoint[], maxSeries?: number | null) {
|
||||||
const totalByAnime = new Map<string, number>();
|
const totalByAnime = new Map<string, number>();
|
||||||
for (const entry of raw) {
|
for (const entry of raw) {
|
||||||
totalByAnime.set(entry.animeTitle, (totalByAnime.get(entry.animeTitle) ?? 0) + entry.value);
|
totalByAnime.set(entry.animeTitle, (totalByAnime.get(entry.animeTitle) ?? 0) + entry.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
const seriesKeys = [...totalByAnime.entries()]
|
let seriesKeys = [...totalByAnime.entries()]
|
||||||
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
||||||
.map(([title]) => title);
|
.map(([title]) => title);
|
||||||
|
if (typeof maxSeries === 'number' && maxSeries > 0) {
|
||||||
|
seriesKeys = seriesKeys.slice(0, maxSeries);
|
||||||
|
}
|
||||||
|
const seriesSet = new Set(seriesKeys);
|
||||||
|
|
||||||
const byDay = new Map<number, Record<string, number>>();
|
const byDay = new Map<number, Record<string, number>>();
|
||||||
for (const entry of raw) {
|
for (const entry of raw) {
|
||||||
|
if (!seriesSet.has(entry.animeTitle)) continue;
|
||||||
const row = byDay.get(entry.epochDay) ?? {};
|
const row = byDay.get(entry.epochDay) ?? {};
|
||||||
row[entry.animeTitle] = (row[entry.animeTitle] ?? 0) + Math.round(entry.value * 10) / 10;
|
row[entry.animeTitle] = (row[entry.animeTitle] ?? 0) + Math.round(entry.value * 10) / 10;
|
||||||
byDay.set(entry.epochDay, row);
|
byDay.set(entry.epochDay, row);
|
||||||
@@ -68,8 +74,13 @@ export function buildLineData(raw: PerAnimeDataPoint[]) {
|
|||||||
return { points, seriesKeys };
|
return { points, seriesKeys };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StackedTrendChart({ title, data, colorPalette }: StackedTrendChartProps) {
|
export function StackedTrendChart({
|
||||||
const { points, seriesKeys } = buildLineData(data);
|
title,
|
||||||
|
data,
|
||||||
|
colorPalette,
|
||||||
|
maxSeries,
|
||||||
|
}: StackedTrendChartProps) {
|
||||||
|
const { points, seriesKeys } = buildLineData(data, maxSeries);
|
||||||
const colors = colorPalette ?? DEFAULT_LINE_COLORS;
|
const colors = colorPalette ?? DEFAULT_LINE_COLORS;
|
||||||
|
|
||||||
if (points.length === 0) {
|
if (points.length === 0) {
|
||||||
|
|||||||
@@ -8,9 +8,11 @@ test('AnimeVisibilityFilter uses title visibility wording', () => {
|
|||||||
<AnimeVisibilityFilter
|
<AnimeVisibilityFilter
|
||||||
animeTitles={['KonoSuba']}
|
animeTitles={['KonoSuba']}
|
||||||
hiddenAnime={new Set()}
|
hiddenAnime={new Set()}
|
||||||
|
maxTitles={null}
|
||||||
onShowAll={() => {}}
|
onShowAll={() => {}}
|
||||||
onHideAll={() => {}}
|
onHideAll={() => {}}
|
||||||
onToggleAnime={() => {}}
|
onToggleAnime={() => {}}
|
||||||
|
onMaxTitlesChange={() => {}}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -18,6 +20,24 @@ test('AnimeVisibilityFilter uses title visibility wording', () => {
|
|||||||
assert.doesNotMatch(markup, /Anime Visibility/);
|
assert.doesNotMatch(markup, /Anime Visibility/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('AnimeVisibilityFilter offers a per-chart title limit selector', () => {
|
||||||
|
const markup = renderToStaticMarkup(
|
||||||
|
<AnimeVisibilityFilter
|
||||||
|
animeTitles={['KonoSuba']}
|
||||||
|
hiddenAnime={new Set()}
|
||||||
|
maxTitles={7}
|
||||||
|
onShowAll={() => {}}
|
||||||
|
onHideAll={() => {}}
|
||||||
|
onToggleAnime={() => {}}
|
||||||
|
onMaxTitlesChange={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.match(markup, /per chart/);
|
||||||
|
assert.match(markup, /<option value="all">All<\/option>/);
|
||||||
|
assert.match(markup, /<option value="7" selected="">/);
|
||||||
|
});
|
||||||
|
|
||||||
test('TrendsTab source labels words per minute without reading speed wording', async () => {
|
test('TrendsTab source labels words per minute without reading speed wording', async () => {
|
||||||
const source = await Bun.file(new URL('./TrendsTab.tsx', import.meta.url)).text();
|
const source = await Bun.file(new URL('./TrendsTab.tsx', import.meta.url)).text();
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,14 @@ import { DateRangeSelector } from './DateRangeSelector';
|
|||||||
import { TrendChart } from './TrendChart';
|
import { TrendChart } from './TrendChart';
|
||||||
import { StackedTrendChart } from './StackedTrendChart';
|
import { StackedTrendChart } from './StackedTrendChart';
|
||||||
import {
|
import {
|
||||||
|
MAX_TITLES_OPTIONS,
|
||||||
buildAnimeVisibilityOptions,
|
buildAnimeVisibilityOptions,
|
||||||
filterHiddenAnimeData,
|
filterHiddenAnimeData,
|
||||||
|
loadHiddenTitles,
|
||||||
|
loadMaxTitles,
|
||||||
pruneHiddenAnime,
|
pruneHiddenAnime,
|
||||||
|
saveHiddenTitles,
|
||||||
|
saveMaxTitles,
|
||||||
} from './anime-visibility';
|
} from './anime-visibility';
|
||||||
import { LibrarySummarySection } from './LibrarySummarySection';
|
import { LibrarySummarySection } from './LibrarySummarySection';
|
||||||
|
|
||||||
@@ -24,17 +29,21 @@ function SectionHeader({ children }: { children: React.ReactNode }) {
|
|||||||
interface AnimeVisibilityFilterProps {
|
interface AnimeVisibilityFilterProps {
|
||||||
animeTitles: string[];
|
animeTitles: string[];
|
||||||
hiddenAnime: ReadonlySet<string>;
|
hiddenAnime: ReadonlySet<string>;
|
||||||
|
maxTitles: number | null;
|
||||||
onShowAll: () => void;
|
onShowAll: () => void;
|
||||||
onHideAll: () => void;
|
onHideAll: () => void;
|
||||||
onToggleAnime: (title: string) => void;
|
onToggleAnime: (title: string) => void;
|
||||||
|
onMaxTitlesChange: (value: number | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AnimeVisibilityFilter({
|
export function AnimeVisibilityFilter({
|
||||||
animeTitles,
|
animeTitles,
|
||||||
hiddenAnime,
|
hiddenAnime,
|
||||||
|
maxTitles,
|
||||||
onShowAll,
|
onShowAll,
|
||||||
onHideAll,
|
onHideAll,
|
||||||
onToggleAnime,
|
onToggleAnime,
|
||||||
|
onMaxTitlesChange,
|
||||||
}: AnimeVisibilityFilterProps) {
|
}: AnimeVisibilityFilterProps) {
|
||||||
if (animeTitles.length === 0) {
|
if (animeTitles.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
@@ -48,10 +57,28 @@ export function AnimeVisibilityFilter({
|
|||||||
Title Visibility
|
Title Visibility
|
||||||
</h4>
|
</h4>
|
||||||
<p className="mt-1 text-xs text-ctp-overlay1">
|
<p className="mt-1 text-xs text-ctp-overlay1">
|
||||||
Shared across all anime trend charts. Default: show everything.
|
Shared across all anime trend charts. Default: show everything. Selections are saved.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
<label className="flex items-center gap-1 text-[11px] text-ctp-subtext0">
|
||||||
|
Top
|
||||||
|
<select
|
||||||
|
className="rounded-md border border-ctp-surface2 bg-ctp-surface0 px-1.5 py-1 text-[11px] font-medium text-ctp-text transition hover:border-ctp-blue"
|
||||||
|
value={maxTitles === null ? 'all' : String(maxTitles)}
|
||||||
|
onChange={(event) =>
|
||||||
|
onMaxTitlesChange(event.target.value === 'all' ? null : Number(event.target.value))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="all">All</option>
|
||||||
|
{MAX_TITLES_OPTIONS.map((count) => (
|
||||||
|
<option key={count} value={count}>
|
||||||
|
{count}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
per chart
|
||||||
|
</label>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="rounded-md border border-ctp-surface2 px-2 py-1 text-[11px] font-medium text-ctp-text transition hover:border-ctp-blue hover:text-ctp-blue"
|
className="rounded-md border border-ctp-surface2 px-2 py-1 text-[11px] font-medium text-ctp-text transition hover:border-ctp-blue hover:text-ctp-blue"
|
||||||
@@ -96,8 +123,18 @@ export function AnimeVisibilityFilter({
|
|||||||
export function TrendsTab() {
|
export function TrendsTab() {
|
||||||
const [range, setRange] = useState<TimeRange>('30d');
|
const [range, setRange] = useState<TimeRange>('30d');
|
||||||
const [groupBy, setGroupBy] = useState<GroupBy>('day');
|
const [groupBy, setGroupBy] = useState<GroupBy>('day');
|
||||||
const [hiddenAnime, setHiddenAnime] = useState<Set<string>>(() => new Set());
|
const [hiddenAnime, setHiddenAnime] = useState<Set<string>>(() => loadHiddenTitles());
|
||||||
|
const [maxTitles, setMaxTitles] = useState<number | null>(() => loadMaxTitles());
|
||||||
const { data, loading, error } = useTrends(range, groupBy);
|
const { data, loading, error } = useTrends(range, groupBy);
|
||||||
|
|
||||||
|
const updateHiddenAnime = (next: Set<string>) => {
|
||||||
|
setHiddenAnime(next);
|
||||||
|
saveHiddenTitles(next);
|
||||||
|
};
|
||||||
|
const updateMaxTitles = (value: number | null) => {
|
||||||
|
setMaxTitles(value);
|
||||||
|
saveMaxTitles(value);
|
||||||
|
};
|
||||||
const cardsMinedColor = 'var(--color-ctp-cards-mined)';
|
const cardsMinedColor = 'var(--color-ctp-cards-mined)';
|
||||||
const cardsMinedStackedColors = [
|
const cardsMinedStackedColors = [
|
||||||
cardsMinedColor,
|
cardsMinedColor,
|
||||||
@@ -246,28 +283,41 @@ export function TrendsTab() {
|
|||||||
<AnimeVisibilityFilter
|
<AnimeVisibilityFilter
|
||||||
animeTitles={animeTitles}
|
animeTitles={animeTitles}
|
||||||
hiddenAnime={activeHiddenAnime}
|
hiddenAnime={activeHiddenAnime}
|
||||||
onShowAll={() => setHiddenAnime(new Set())}
|
maxTitles={maxTitles}
|
||||||
onHideAll={() => setHiddenAnime(new Set(animeTitles))}
|
onShowAll={() => updateHiddenAnime(new Set())}
|
||||||
onToggleAnime={(title) =>
|
onHideAll={() => updateHiddenAnime(new Set(animeTitles))}
|
||||||
setHiddenAnime((current) => {
|
onToggleAnime={(title) => {
|
||||||
const next = new Set(current);
|
const next = new Set(hiddenAnime);
|
||||||
if (next.has(title)) {
|
if (next.has(title)) {
|
||||||
next.delete(title);
|
next.delete(title);
|
||||||
} else {
|
} else {
|
||||||
next.add(title);
|
next.add(title);
|
||||||
}
|
}
|
||||||
return next;
|
updateHiddenAnime(next);
|
||||||
})
|
}}
|
||||||
}
|
onMaxTitlesChange={updateMaxTitles}
|
||||||
|
/>
|
||||||
|
<StackedTrendChart
|
||||||
|
title="Watch Time Progress (min)"
|
||||||
|
data={filteredWatchTimeProgress}
|
||||||
|
maxSeries={maxTitles}
|
||||||
|
/>
|
||||||
|
<StackedTrendChart
|
||||||
|
title="Episodes Progress"
|
||||||
|
data={filteredAnimeProgress}
|
||||||
|
maxSeries={maxTitles}
|
||||||
/>
|
/>
|
||||||
<StackedTrendChart title="Watch Time Progress (min)" data={filteredWatchTimeProgress} />
|
|
||||||
<StackedTrendChart title="Episodes Progress" data={filteredAnimeProgress} />
|
|
||||||
<StackedTrendChart
|
<StackedTrendChart
|
||||||
title="Cards Mined Progress"
|
title="Cards Mined Progress"
|
||||||
data={filteredCardsProgress}
|
data={filteredCardsProgress}
|
||||||
colorPalette={cardsMinedStackedColors}
|
colorPalette={cardsMinedStackedColors}
|
||||||
|
maxSeries={maxTitles}
|
||||||
|
/>
|
||||||
|
<StackedTrendChart
|
||||||
|
title="Words Seen Progress"
|
||||||
|
data={filteredWordsProgress}
|
||||||
|
maxSeries={maxTitles}
|
||||||
/>
|
/>
|
||||||
<StackedTrendChart title="Words Seen Progress" data={filteredWordsProgress} />
|
|
||||||
|
|
||||||
<SectionHeader>Library — Summary</SectionHeader>
|
<SectionHeader>Library — Summary</SectionHeader>
|
||||||
<LibrarySummarySection rows={data.librarySummary} hiddenTitles={activeHiddenAnime} />
|
<LibrarySummarySection rows={data.librarySummary} hiddenTitles={activeHiddenAnime} />
|
||||||
|
|||||||
@@ -5,9 +5,36 @@ import type { PerAnimeDataPoint } from './StackedTrendChart';
|
|||||||
import {
|
import {
|
||||||
buildAnimeVisibilityOptions,
|
buildAnimeVisibilityOptions,
|
||||||
filterHiddenAnimeData,
|
filterHiddenAnimeData,
|
||||||
|
loadHiddenTitles,
|
||||||
|
loadMaxTitles,
|
||||||
pruneHiddenAnime,
|
pruneHiddenAnime,
|
||||||
|
saveHiddenTitles,
|
||||||
|
saveMaxTitles,
|
||||||
} from './anime-visibility';
|
} from './anime-visibility';
|
||||||
|
|
||||||
|
function installLocalStorage(initial: Record<string, string> = {}) {
|
||||||
|
const previous = Object.getOwnPropertyDescriptor(globalThis, 'localStorage');
|
||||||
|
const values = new Map(Object.entries(initial));
|
||||||
|
Object.defineProperty(globalThis, 'localStorage', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
getItem: (key: string) => values.get(key) ?? null,
|
||||||
|
setItem: (key: string, value: string) => values.set(key, value),
|
||||||
|
removeItem: (key: string) => values.delete(key),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
values,
|
||||||
|
restore: () => {
|
||||||
|
if (previous) {
|
||||||
|
Object.defineProperty(globalThis, 'localStorage', previous);
|
||||||
|
} else {
|
||||||
|
delete (globalThis as { localStorage?: unknown }).localStorage;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const SAMPLE_POINTS: PerAnimeDataPoint[] = [
|
const SAMPLE_POINTS: PerAnimeDataPoint[] = [
|
||||||
{ epochDay: 1, animeTitle: 'KonoSuba', value: 5 },
|
{ epochDay: 1, animeTitle: 'KonoSuba', value: 5 },
|
||||||
{ epochDay: 2, animeTitle: 'KonoSuba', value: 10 },
|
{ epochDay: 2, animeTitle: 'KonoSuba', value: 10 },
|
||||||
@@ -45,3 +72,46 @@ test('pruneHiddenAnime drops titles that are no longer available', () => {
|
|||||||
|
|
||||||
assert.deepEqual([...hidden], ['KonoSuba']);
|
assert.deepEqual([...hidden], ['KonoSuba']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('hidden titles round-trip through localStorage', () => {
|
||||||
|
const { restore } = installLocalStorage();
|
||||||
|
try {
|
||||||
|
saveHiddenTitles(new Set(['KonoSuba', 'One Piece']));
|
||||||
|
assert.deepEqual([...loadHiddenTitles()], ['KonoSuba', 'One Piece']);
|
||||||
|
} finally {
|
||||||
|
restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('loadHiddenTitles tolerates missing or malformed stored values', () => {
|
||||||
|
const { restore } = installLocalStorage({
|
||||||
|
'subminer-stats-trends-hidden-titles': '{"not":"an array"}',
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
assert.deepEqual([...loadHiddenTitles()], []);
|
||||||
|
} finally {
|
||||||
|
restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('max titles preference round-trips and clears when set to null', () => {
|
||||||
|
const { values, restore } = installLocalStorage();
|
||||||
|
try {
|
||||||
|
saveMaxTitles(7);
|
||||||
|
assert.equal(loadMaxTitles(), 7);
|
||||||
|
saveMaxTitles(null);
|
||||||
|
assert.equal(loadMaxTitles(), null);
|
||||||
|
assert.equal(values.has('subminer-stats-trends-max-titles'), false);
|
||||||
|
} finally {
|
||||||
|
restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('loadMaxTitles rejects non-positive or non-numeric stored values', () => {
|
||||||
|
const { restore } = installLocalStorage({ 'subminer-stats-trends-max-titles': 'banana' });
|
||||||
|
try {
|
||||||
|
assert.equal(loadMaxTitles(), null);
|
||||||
|
} finally {
|
||||||
|
restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,5 +1,62 @@
|
|||||||
import type { PerAnimeDataPoint } from './StackedTrendChart';
|
import type { PerAnimeDataPoint } from './StackedTrendChart';
|
||||||
|
|
||||||
|
const HIDDEN_TITLES_KEY = 'subminer-stats-trends-hidden-titles';
|
||||||
|
const MAX_TITLES_KEY = 'subminer-stats-trends-max-titles';
|
||||||
|
|
||||||
|
export const MAX_TITLES_OPTIONS = [3, 5, 7, 10] as const;
|
||||||
|
|
||||||
|
function getStorage(): Storage | null {
|
||||||
|
try {
|
||||||
|
return globalThis.localStorage ?? null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadHiddenTitles(): Set<string> {
|
||||||
|
try {
|
||||||
|
const raw = getStorage()?.getItem(HIDDEN_TITLES_KEY);
|
||||||
|
const parsed: unknown = raw ? JSON.parse(raw) : [];
|
||||||
|
if (!Array.isArray(parsed)) return new Set();
|
||||||
|
return new Set(parsed.filter((value): value is string => typeof value === 'string'));
|
||||||
|
} catch {
|
||||||
|
return new Set();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveHiddenTitles(hidden: ReadonlySet<string>): void {
|
||||||
|
try {
|
||||||
|
getStorage()?.setItem(HIDDEN_TITLES_KEY, JSON.stringify([...hidden]));
|
||||||
|
} catch {
|
||||||
|
// Storage can be blocked in private/restricted contexts; keep the in-memory choice.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadMaxTitles(): number | null {
|
||||||
|
try {
|
||||||
|
const raw = getStorage()?.getItem(MAX_TITLES_KEY);
|
||||||
|
if (raw === null || raw === undefined) return null;
|
||||||
|
const value = Number(raw);
|
||||||
|
return Number.isInteger(value) && value > 0 ? value : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveMaxTitles(value: number | null): void {
|
||||||
|
try {
|
||||||
|
const storage = getStorage();
|
||||||
|
if (!storage) return;
|
||||||
|
if (value === null) {
|
||||||
|
storage.removeItem(MAX_TITLES_KEY);
|
||||||
|
} else {
|
||||||
|
storage.setItem(MAX_TITLES_KEY, String(value));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Storage can be blocked in private/restricted contexts; keep the in-memory choice.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function buildAnimeVisibilityOptions(datasets: PerAnimeDataPoint[][]): string[] {
|
export function buildAnimeVisibilityOptions(datasets: PerAnimeDataPoint[][]): string[] {
|
||||||
const totals = new Map<string, number>();
|
const totals = new Map<string, number>();
|
||||||
for (const dataset of datasets) {
|
for (const dataset of datasets) {
|
||||||
|
|||||||
Reference in New Issue
Block a user