Perf fixes + 3d posts viz

This commit is contained in:
Lim Chee Aun 2024-03-02 21:25:54 +08:00
parent fcb0074f49
commit afb1f6d520
2 changed files with 204 additions and 98 deletions

View file

@ -168,14 +168,14 @@
border-radius: 3px; border-radius: 3px;
border: 1px solid var(--bg-color); border: 1px solid var(--bg-color);
display: flex; display: flex;
gap: 1px; gap: var(--hairline-width);
pointer-events: none; pointer-events: none;
justify-content: stretch; justify-content: stretch;
height: 3px; height: 3px;
&:has(.post-dot:nth-child(320)) { /* &:has(.post-dot:nth-child(320)) {
gap: 0; gap: 0;
} } */
.post-dot { .post-dot {
display: block; display: block;
@ -198,6 +198,37 @@
} }
} }
.catchup-posts-viz-time-bar {
margin: 0 16px;
padding: 1px;
display: flex;
gap: var(--hairline-width);
pointer-events: none;
justify-content: stretch;
background-image: linear-gradient(to bottom, transparent, var(--bg-color));
.posts-bin {
display: flex;
gap: var(--hairline-width);
flex-direction: column-reverse;
width: 100%;
.post-dot {
display: block;
width: 100%;
height: 2px;
opacity: 0.2;
background-color: var(--link-color);
transition: 0.25s ease-in-out;
transition-property: opacity, transform;
&.post-dot-highlight {
opacity: 1;
}
}
}
}
.catchup-filters { .catchup-filters {
padding: 8px 16px; padding: 8px 16px;
display: flex; display: flex;

View file

@ -5,7 +5,14 @@ import autoAnimate from '@formkit/auto-animate';
import { getBlurHashAverageColor } from 'fast-blurhash'; import { getBlurHashAverageColor } from 'fast-blurhash';
import { Fragment } from 'preact'; import { Fragment } from 'preact';
import { memo } from 'preact/compat'; import { memo } from 'preact/compat';
import { useEffect, useMemo, useReducer, useRef, useState } from 'preact/hooks'; import {
useCallback,
useEffect,
useMemo,
useReducer,
useRef,
useState,
} from 'preact/hooks';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import { uid } from 'uid/single'; import { uid } from 'uid/single';
@ -36,6 +43,47 @@ import useTitle from '../utils/useTitle';
const FILTER_CONTEXT = 'home'; const FILTER_CONTEXT = 'home';
const RANGES = [
{ label: 'last 1 hour', value: 1 },
{ label: 'last 2 hours', value: 2 },
{ label: 'last 3 hours', value: 3 },
{ label: 'last 4 hours', value: 4 },
{ label: 'last 5 hours', value: 5 },
{ label: 'last 6 hours', value: 6 },
{ label: 'last 7 hours', value: 7 },
{ label: 'last 8 hours', value: 8 },
{ label: 'last 9 hours', value: 9 },
{ label: 'last 10 hours', value: 10 },
{ label: 'last 11 hours', value: 11 },
{ label: 'last 12 hours', value: 12 },
{ label: 'beyond 12 hours', value: 13 },
];
const FILTER_VALUES = {
Filtered: 'filtered',
Groups: 'group',
Boosts: 'boost',
Replies: 'reply',
'Followed tags': 'followedTags',
Original: 'original',
};
const FILTER_CATEGORY_TEXT = {
Filtered: 'filtered posts',
Groups: 'group posts',
Boosts: 'boosts',
Replies: 'replies',
'Followed tags': 'followed-tag posts',
Original: 'original posts',
};
const SORT_BY_TEXT = {
// asc, desc
createdAt: ['oldest', 'latest'],
repliesCount: ['fewest replies', 'most replies'],
favouritesCount: ['fewest likes', 'most likes'],
reblogsCount: ['fewest boosts', 'most boosts'],
density: ['least dense', 'most dense'],
};
function Catchup() { function Catchup() {
useTitle('Catch-up', '/catchup'); useTitle('Catch-up', '/catchup');
const { masto, instance } = api(); const { masto, instance } = api();
@ -125,15 +173,15 @@ function Catchup() {
const [posts, setPosts] = useState([]); const [posts, setPosts] = useState([]);
const catchupRangeRef = useRef(); const catchupRangeRef = useRef();
async function handleCatchupClick({ duration } = {}) { const NS = useMemo(() => getCurrentAccountNS(), []);
const handleCatchupClick = useCallback(async ({ duration } = {}) => {
const now = Date.now(); const now = Date.now();
const maxCreatedAt = duration ? now - duration : null; const maxCreatedAt = duration ? now - duration : null;
setUIState('loading'); setUIState('loading');
const results = await fetchHome({ maxCreatedAt }); const results = await fetchHome({ maxCreatedAt });
// Namespaced by account ID // Namespaced by account ID
// Possible conflict if ID matches between different accounts from different instances // Possible conflict if ID matches between different accounts from different instances
const ns = getCurrentAccountNS(); const catchupID = `${NS}-${uid()}`;
const catchupID = `${ns}-${uid()}`;
try { try {
await db.catchup.set(catchupID, { await db.catchup.set(catchupID, {
id: catchupID, id: catchupID,
@ -145,17 +193,15 @@ function Catchup() {
setSearchParams({ id: catchupID }); setSearchParams({ id: catchupID });
} catch (e) { } catch (e) {
console.error(e, results); console.error(e, results);
// setUIState('error');
} }
// setPosts(results); }, []);
// setUIState('results');
}
useEffect(() => { useEffect(() => {
if (id) { if (id) {
(async () => { (async () => {
const catchup = await db.catchup.get(id); const catchup = await db.catchup.get(id);
if (catchup) { if (catchup) {
catchup.posts.sort((a, b) => (a.createdAt > b.createdAt ? 1 : -1));
setPosts(catchup.posts); setPosts(catchup.posts);
setUIState('results'); setUIState('results');
} }
@ -340,65 +386,48 @@ function Catchup() {
const [selectedAuthor, setSelectedAuthor] = useState(null); const [selectedAuthor, setSelectedAuthor] = useState(null);
const [range, setRange] = useState(1); const [range, setRange] = useState(1);
const ranges = [
{ label: 'last 1 hour', value: 1 },
{ label: 'last 2 hours', value: 2 },
{ label: 'last 3 hours', value: 3 },
{ label: 'last 4 hours', value: 4 },
{ label: 'last 5 hours', value: 5 },
{ label: 'last 6 hours', value: 6 },
{ label: 'last 7 hours', value: 7 },
{ label: 'last 8 hours', value: 8 },
{ label: 'last 9 hours', value: 9 },
{ label: 'last 10 hours', value: 10 },
{ label: 'last 11 hours', value: 11 },
{ label: 'last 12 hours', value: 12 },
{ label: 'beyond 12 hours', value: 13 },
];
const [sortBy, setSortBy] = useState('createdAt'); const [sortBy, setSortBy] = useState('createdAt');
const [sortOrder, setSortOrder] = useState('asc'); const [sortOrder, setSortOrder] = useState('asc');
const [groupBy, setGroupBy] = useState(null); const [groupBy, setGroupBy] = useState(null);
const [filteredPosts, authors, authorCounts] = useMemo(() => { const [filteredPosts, authors, authorCounts] = useMemo(() => {
let authors = []; const authorsHash = {};
const authorCounts = {}; const authorCountsMap = new Map();
let filteredPosts = posts.filter((post) => { let filteredPosts = posts.filter((post) => {
return ( const postFilterMatches =
selectedFilterCategory === 'All' || selectedFilterCategory === 'All' ||
post.__FILTER === post.__FILTER === FILTER_VALUES[selectedFilterCategory];
{
Filtered: 'filtered',
Groups: 'group',
Boosts: 'boost',
Replies: 'reply',
'Followed tags': 'followedTags',
Original: 'original',
}[selectedFilterCategory]
);
});
filteredPosts.forEach((post) => { if (postFilterMatches) {
if (!authors.find((a) => a.id === post.account.id)) { authorsHash[post.account.id] = post.account;
authors.push(post.account); authorCountsMap.set(
post.account.id,
(authorCountsMap.get(post.account.id) || 0) + 1,
);
} }
authorCounts[post.account.id] = (authorCounts[post.account.id] || 0) + 1;
return postFilterMatches;
}); });
if (selectedAuthor && authorCounts[selectedAuthor]) { if (selectedAuthor && authorCountsMap.has(selectedAuthor)) {
filteredPosts = filteredPosts.filter( filteredPosts = filteredPosts.filter(
(post) => post.account.id === selectedAuthor, (post) => post.account.id === selectedAuthor,
); );
} }
const authorsHash = {}; return [filteredPosts, authorsHash, Object.fromEntries(authorCountsMap)];
for (const author of authors) {
authorsHash[author.id] = author;
}
return [filteredPosts, authorsHash, authorCounts];
}, [selectedFilterCategory, selectedAuthor, posts]); }, [selectedFilterCategory, selectedAuthor, posts]);
const filteredPostsMap = useMemo(() => {
const map = {};
filteredPosts.forEach((post) => {
map[post.id] = post;
});
return map;
}, [filteredPosts]);
const authorCountsList = useMemo( const authorCountsList = useMemo(
() => () =>
Object.keys(authorCounts).sort( Object.keys(authorCounts).sort(
@ -450,26 +479,55 @@ function Catchup() {
const prevGroup = useRef(null); const prevGroup = useRef(null);
const authorsListParent = useRef(null); const authorsListParent = useRef(null);
const autoAnimated = useRef(false);
useEffect(() => { useEffect(() => {
if (authorsListParent.current && authorCountsList.length < 30) { if (posts.length > 100 || autoAnimated.current) return;
if (authorsListParent.current) {
autoAnimate(authorsListParent.current, { autoAnimate(authorsListParent.current, {
duration: 200, duration: 200,
}); });
autoAnimated.current = true;
} }
}, [selectedFilterCategory, authorCountsList, authorsListParent]); }, [posts, authorsListParent]);
const postsBarType = posts.length > 160 ? '3d' : '2d';
const postsBar = useMemo(() => { const postsBar = useMemo(() => {
if (postsBarType !== '2d') return null;
return posts.map((post) => { return posts.map((post) => {
// If part of filteredPosts // If part of filteredPosts
const isFiltered = filteredPosts.find((p) => p.id === post.id); const isFiltered = filteredPostsMap[post.id];
return ( return (
<span <span
data-id={post.id}
key={post.id} key={post.id}
class={`post-dot ${isFiltered ? 'post-dot-highlight' : ''}`} class={`post-dot ${isFiltered ? 'post-dot-highlight' : ''}`}
/> />
); );
}); });
}, [posts, filteredPosts]); }, [filteredPostsMap]);
const postsBins = useMemo(() => {
if (postsBarType !== '3d') return null;
if (!posts?.length) return null;
const bins = binByTime(posts, 'createdAt', 320);
return bins.map((posts, i) => {
return (
<div class="posts-bin" key={i}>
{posts.map((post) => {
const isFiltered = filteredPostsMap[post.id];
return (
<span
data-id={post.id}
key={post.id}
class={`post-dot ${isFiltered ? 'post-dot-highlight' : ''}`}
/>
);
})}
</div>
);
});
}, [filteredPostsMap]);
const scrollableRef = useRef(null); const scrollableRef = useRef(null);
@ -482,36 +540,20 @@ function Catchup() {
useEffect(() => { useEffect(() => {
if (uiState !== 'results') return; if (uiState !== 'results') return;
const filterCategoryText = {
Filtered: 'filtered posts',
Groups: 'group posts',
Boosts: 'boosts',
Replies: 'replies',
'Followed tags': 'followed-tag posts',
Original: 'original posts',
};
const authorUsername = const authorUsername =
selectedAuthor && authors[selectedAuthor] selectedAuthor && authors[selectedAuthor]
? authors[selectedAuthor].username ? authors[selectedAuthor].username
: ''; : '';
const sortOrderIndex = sortOrder === 'asc' ? 0 : 1; const sortOrderIndex = sortOrder === 'asc' ? 0 : 1;
const sortByText = {
// asc, desc
createdAt: ['oldest', 'latest'],
repliesCount: ['fewest replies', 'most replies'],
favouritesCount: ['fewest likes', 'most likes'],
reblogsCount: ['fewest boosts', 'most boosts'],
density: ['least dense', 'most dense'],
};
const groupByText = { const groupByText = {
account: 'authors', account: 'authors',
}; };
let toast = showToast({ let toast = showToast({
duration: 5_000, // 5 seconds duration: 5_000, // 5 seconds
text: `Showing ${ text: `Showing ${
filterCategoryText[selectedFilterCategory] || 'all posts' FILTER_CATEGORY_TEXT[selectedFilterCategory] || 'all posts'
}${authorUsername ? ` by @${authorUsername}` : ''}, ${ }${authorUsername ? ` by @${authorUsername}` : ''}, ${
sortByText[sortBy][sortOrderIndex] SORT_BY_TEXT[sortBy][sortOrderIndex]
} first${ } first${
!!groupBy !!groupBy
? `, grouped by ${groupBy === 'account' ? groupByText[groupBy] : ''}` ? `, grouped by ${groupBy === 'account' ? groupByText[groupBy] : ''}`
@ -533,11 +575,11 @@ function Catchup() {
const prevSelectedAuthorMissing = useRef(false); const prevSelectedAuthorMissing = useRef(false);
useEffect(() => { useEffect(() => {
console.log({ // console.log({
prevSelectedAuthorMissing, // prevSelectedAuthorMissing,
selectedAuthor, // selectedAuthor,
authors, // authors,
}); // });
let timer; let timer;
if (selectedAuthor) { if (selectedAuthor) {
if (authors[selectedAuthor]) { if (authors[selectedAuthor]) {
@ -649,8 +691,8 @@ function Catchup() {
ref={catchupRangeRef} ref={catchupRangeRef}
type="range" type="range"
value={range} value={range}
min={ranges[0].value} min={RANGES[0].value}
max={ranges[ranges.length - 1].value} max={RANGES[RANGES.length - 1].value}
step="1" step="1"
list="catchup-ranges" list="catchup-ranges"
onChange={(e) => setRange(+e.target.value)} onChange={(e) => setRange(+e.target.value)}
@ -660,10 +702,10 @@ function Catchup() {
width: '8em', width: '8em',
}} }}
> >
{ranges[range - 1].label} {RANGES[range - 1].label}
<br /> <br />
<small class="insignificant"> <small class="insignificant">
{range == ranges[ranges.length - 1].value {range == RANGES[RANGES.length - 1].value
? 'until the max' ? 'until the max'
: niceDateTime( : niceDateTime(
new Date(Date.now() - range * 60 * 60 * 1000), new Date(Date.now() - range * 60 * 60 * 1000),
@ -671,14 +713,14 @@ function Catchup() {
</small> </small>
</span> </span>
<datalist id="catchup-ranges"> <datalist id="catchup-ranges">
{ranges.map(({ label, value }) => ( {RANGES.map(({ label, value }) => (
<option value={value} label={label} /> <option value={value} label={label} />
))} ))}
</datalist>{' '} </datalist>{' '}
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
if (range < ranges[ranges.length - 1].value) { if (range < RANGES[RANGES.length - 1].value) {
const duration = range * 60 * 60 * 1000; const duration = range * 60 * 60 * 1000;
handleCatchupClick({ duration }); handleCatchupClick({ duration });
} else { } else {
@ -926,9 +968,12 @@ function Catchup() {
</div> </div>
</div> </div>
</div> </div>
{posts.length >= 5 && ( {posts.length >= 5 &&
<div class="catchup-posts-viz-bar">{postsBar}</div> (postsBarType === '3d' ? (
)} <div class="catchup-posts-viz-time-bar">{postsBins}</div>
) : (
<div class="catchup-posts-viz-bar">{postsBar}</div>
))}
{posts.length >= 2 && ( {posts.length >= 2 && (
<div class="catchup-filters"> <div class="catchup-filters">
<label class="filter-cat"> <label class="filter-cat">
@ -1139,14 +1184,11 @@ function Catchup() {
return ( return (
<Fragment key={`${post.id}-${showSeparator}`}> <Fragment key={`${post.id}-${showSeparator}`}>
{showSeparator && <li class="separator" />} {showSeparator && <li class="separator" />}
<li> <IntersectionPostLineItem
<Link to={`/${instance}/s/${id}`}> to={`/${instance}/s/${id}`}
<IntersectionPostLine post={post}
post={post} root={scrollableRef.current}
root={scrollableRef.current} />
/>
</Link>
</li>
</Fragment> </Fragment>
); );
})} })}
@ -1251,7 +1293,7 @@ const PostLine = memo(
}, },
); );
const IntersectionPostLine = ({ root, ...props }) => { const IntersectionPostLineItem = ({ root, to, ...props }) => {
const ref = useRef(); const ref = useRef();
const [show, setShow] = useState(false); const [show, setShow] = useState(false);
useEffect(() => { useEffect(() => {
@ -1275,9 +1317,13 @@ const IntersectionPostLine = ({ root, ...props }) => {
}, []); }, []);
return show ? ( return show ? (
<PostLine {...props} /> <li>
<Link to={to}>
<PostLine {...props} />
</Link>
</li>
) : ( ) : (
<div ref={ref} style={{ height: '4em' }} /> <li ref={ref} style={{ height: '4em' }} />
); );
}; };
@ -1507,4 +1553,33 @@ function formatRange(startDate, endDate) {
return dtf.formatRange(startDate, endDate); return dtf.formatRange(startDate, endDate);
} }
function binByTime(data, key, numBins) {
// Extract dates from data objects
const dates = data.map((item) => new Date(item[key]));
// Find minimum and maximum dates directly (avoiding Math.min/max)
const minDate = dates.reduce(
(acc, date) => (date < acc ? date : acc),
dates[0],
);
const maxDate = dates.reduce(
(acc, date) => (date > acc ? date : acc),
dates[0],
);
// Calculate the time span in milliseconds
const range = maxDate.getTime() - minDate.getTime();
// Create empty bins and loop through data
const bins = Array.from({ length: numBins }, () => []);
data.forEach((item) => {
const date = new Date(item[key]);
const normalized = (date.getTime() - minDate.getTime()) / range;
const binIndex = Math.floor(normalized * (numBins - 1));
bins[binIndex].push(item);
});
return bins;
}
export default Catchup; export default Catchup;