mirror of
https://github.com/cheeaun/phanpy.git
synced 2025-03-13 09:28:50 +01:00
Perf fixes + 3d posts viz
This commit is contained in:
parent
fcb0074f49
commit
afb1f6d520
2 changed files with 204 additions and 98 deletions
|
@ -168,14 +168,14 @@
|
|||
border-radius: 3px;
|
||||
border: 1px solid var(--bg-color);
|
||||
display: flex;
|
||||
gap: 1px;
|
||||
gap: var(--hairline-width);
|
||||
pointer-events: none;
|
||||
justify-content: stretch;
|
||||
height: 3px;
|
||||
|
||||
&:has(.post-dot:nth-child(320)) {
|
||||
/* &:has(.post-dot:nth-child(320)) {
|
||||
gap: 0;
|
||||
}
|
||||
} */
|
||||
|
||||
.post-dot {
|
||||
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 {
|
||||
padding: 8px 16px;
|
||||
display: flex;
|
||||
|
|
|
@ -5,7 +5,14 @@ import autoAnimate from '@formkit/auto-animate';
|
|||
import { getBlurHashAverageColor } from 'fast-blurhash';
|
||||
import { Fragment } from 'preact';
|
||||
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 { uid } from 'uid/single';
|
||||
|
||||
|
@ -36,6 +43,47 @@ import useTitle from '../utils/useTitle';
|
|||
|
||||
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() {
|
||||
useTitle('Catch-up', '/catchup');
|
||||
const { masto, instance } = api();
|
||||
|
@ -125,15 +173,15 @@ function Catchup() {
|
|||
|
||||
const [posts, setPosts] = useState([]);
|
||||
const catchupRangeRef = useRef();
|
||||
async function handleCatchupClick({ duration } = {}) {
|
||||
const NS = useMemo(() => getCurrentAccountNS(), []);
|
||||
const handleCatchupClick = useCallback(async ({ duration } = {}) => {
|
||||
const now = Date.now();
|
||||
const maxCreatedAt = duration ? now - duration : null;
|
||||
setUIState('loading');
|
||||
const results = await fetchHome({ maxCreatedAt });
|
||||
// Namespaced by account ID
|
||||
// Possible conflict if ID matches between different accounts from different instances
|
||||
const ns = getCurrentAccountNS();
|
||||
const catchupID = `${ns}-${uid()}`;
|
||||
const catchupID = `${NS}-${uid()}`;
|
||||
try {
|
||||
await db.catchup.set(catchupID, {
|
||||
id: catchupID,
|
||||
|
@ -145,17 +193,15 @@ function Catchup() {
|
|||
setSearchParams({ id: catchupID });
|
||||
} catch (e) {
|
||||
console.error(e, results);
|
||||
// setUIState('error');
|
||||
}
|
||||
// setPosts(results);
|
||||
// setUIState('results');
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
(async () => {
|
||||
const catchup = await db.catchup.get(id);
|
||||
if (catchup) {
|
||||
catchup.posts.sort((a, b) => (a.createdAt > b.createdAt ? 1 : -1));
|
||||
setPosts(catchup.posts);
|
||||
setUIState('results');
|
||||
}
|
||||
|
@ -340,65 +386,48 @@ function Catchup() {
|
|||
const [selectedAuthor, setSelectedAuthor] = useState(null);
|
||||
|
||||
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 [sortOrder, setSortOrder] = useState('asc');
|
||||
const [groupBy, setGroupBy] = useState(null);
|
||||
|
||||
const [filteredPosts, authors, authorCounts] = useMemo(() => {
|
||||
let authors = [];
|
||||
const authorCounts = {};
|
||||
const authorsHash = {};
|
||||
const authorCountsMap = new Map();
|
||||
|
||||
let filteredPosts = posts.filter((post) => {
|
||||
return (
|
||||
const postFilterMatches =
|
||||
selectedFilterCategory === 'All' ||
|
||||
post.__FILTER ===
|
||||
{
|
||||
Filtered: 'filtered',
|
||||
Groups: 'group',
|
||||
Boosts: 'boost',
|
||||
Replies: 'reply',
|
||||
'Followed tags': 'followedTags',
|
||||
Original: 'original',
|
||||
}[selectedFilterCategory]
|
||||
post.__FILTER === FILTER_VALUES[selectedFilterCategory];
|
||||
|
||||
if (postFilterMatches) {
|
||||
authorsHash[post.account.id] = post.account;
|
||||
authorCountsMap.set(
|
||||
post.account.id,
|
||||
(authorCountsMap.get(post.account.id) || 0) + 1,
|
||||
);
|
||||
});
|
||||
|
||||
filteredPosts.forEach((post) => {
|
||||
if (!authors.find((a) => a.id === post.account.id)) {
|
||||
authors.push(post.account);
|
||||
}
|
||||
authorCounts[post.account.id] = (authorCounts[post.account.id] || 0) + 1;
|
||||
|
||||
return postFilterMatches;
|
||||
});
|
||||
|
||||
if (selectedAuthor && authorCounts[selectedAuthor]) {
|
||||
if (selectedAuthor && authorCountsMap.has(selectedAuthor)) {
|
||||
filteredPosts = filteredPosts.filter(
|
||||
(post) => post.account.id === selectedAuthor,
|
||||
);
|
||||
}
|
||||
|
||||
const authorsHash = {};
|
||||
for (const author of authors) {
|
||||
authorsHash[author.id] = author;
|
||||
}
|
||||
|
||||
return [filteredPosts, authorsHash, authorCounts];
|
||||
return [filteredPosts, authorsHash, Object.fromEntries(authorCountsMap)];
|
||||
}, [selectedFilterCategory, selectedAuthor, posts]);
|
||||
|
||||
const filteredPostsMap = useMemo(() => {
|
||||
const map = {};
|
||||
filteredPosts.forEach((post) => {
|
||||
map[post.id] = post;
|
||||
});
|
||||
return map;
|
||||
}, [filteredPosts]);
|
||||
|
||||
const authorCountsList = useMemo(
|
||||
() =>
|
||||
Object.keys(authorCounts).sort(
|
||||
|
@ -450,26 +479,55 @@ function Catchup() {
|
|||
const prevGroup = useRef(null);
|
||||
|
||||
const authorsListParent = useRef(null);
|
||||
const autoAnimated = useRef(false);
|
||||
useEffect(() => {
|
||||
if (authorsListParent.current && authorCountsList.length < 30) {
|
||||
if (posts.length > 100 || autoAnimated.current) return;
|
||||
if (authorsListParent.current) {
|
||||
autoAnimate(authorsListParent.current, {
|
||||
duration: 200,
|
||||
});
|
||||
autoAnimated.current = true;
|
||||
}
|
||||
}, [selectedFilterCategory, authorCountsList, authorsListParent]);
|
||||
}, [posts, authorsListParent]);
|
||||
|
||||
const postsBarType = posts.length > 160 ? '3d' : '2d';
|
||||
|
||||
const postsBar = useMemo(() => {
|
||||
if (postsBarType !== '2d') return null;
|
||||
return posts.map((post) => {
|
||||
// If part of filteredPosts
|
||||
const isFiltered = filteredPosts.find((p) => p.id === post.id);
|
||||
const isFiltered = filteredPostsMap[post.id];
|
||||
return (
|
||||
<span
|
||||
data-id={post.id}
|
||||
key={post.id}
|
||||
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);
|
||||
|
||||
|
@ -482,36 +540,20 @@ function Catchup() {
|
|||
|
||||
useEffect(() => {
|
||||
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 =
|
||||
selectedAuthor && authors[selectedAuthor]
|
||||
? authors[selectedAuthor].username
|
||||
: '';
|
||||
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 = {
|
||||
account: 'authors',
|
||||
};
|
||||
let toast = showToast({
|
||||
duration: 5_000, // 5 seconds
|
||||
text: `Showing ${
|
||||
filterCategoryText[selectedFilterCategory] || 'all posts'
|
||||
FILTER_CATEGORY_TEXT[selectedFilterCategory] || 'all posts'
|
||||
}${authorUsername ? ` by @${authorUsername}` : ''}, ${
|
||||
sortByText[sortBy][sortOrderIndex]
|
||||
SORT_BY_TEXT[sortBy][sortOrderIndex]
|
||||
} first${
|
||||
!!groupBy
|
||||
? `, grouped by ${groupBy === 'account' ? groupByText[groupBy] : ''}`
|
||||
|
@ -533,11 +575,11 @@ function Catchup() {
|
|||
|
||||
const prevSelectedAuthorMissing = useRef(false);
|
||||
useEffect(() => {
|
||||
console.log({
|
||||
prevSelectedAuthorMissing,
|
||||
selectedAuthor,
|
||||
authors,
|
||||
});
|
||||
// console.log({
|
||||
// prevSelectedAuthorMissing,
|
||||
// selectedAuthor,
|
||||
// authors,
|
||||
// });
|
||||
let timer;
|
||||
if (selectedAuthor) {
|
||||
if (authors[selectedAuthor]) {
|
||||
|
@ -649,8 +691,8 @@ function Catchup() {
|
|||
ref={catchupRangeRef}
|
||||
type="range"
|
||||
value={range}
|
||||
min={ranges[0].value}
|
||||
max={ranges[ranges.length - 1].value}
|
||||
min={RANGES[0].value}
|
||||
max={RANGES[RANGES.length - 1].value}
|
||||
step="1"
|
||||
list="catchup-ranges"
|
||||
onChange={(e) => setRange(+e.target.value)}
|
||||
|
@ -660,10 +702,10 @@ function Catchup() {
|
|||
width: '8em',
|
||||
}}
|
||||
>
|
||||
{ranges[range - 1].label}
|
||||
{RANGES[range - 1].label}
|
||||
<br />
|
||||
<small class="insignificant">
|
||||
{range == ranges[ranges.length - 1].value
|
||||
{range == RANGES[RANGES.length - 1].value
|
||||
? 'until the max'
|
||||
: niceDateTime(
|
||||
new Date(Date.now() - range * 60 * 60 * 1000),
|
||||
|
@ -671,14 +713,14 @@ function Catchup() {
|
|||
</small>
|
||||
</span>
|
||||
<datalist id="catchup-ranges">
|
||||
{ranges.map(({ label, value }) => (
|
||||
{RANGES.map(({ label, value }) => (
|
||||
<option value={value} label={label} />
|
||||
))}
|
||||
</datalist>{' '}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (range < ranges[ranges.length - 1].value) {
|
||||
if (range < RANGES[RANGES.length - 1].value) {
|
||||
const duration = range * 60 * 60 * 1000;
|
||||
handleCatchupClick({ duration });
|
||||
} else {
|
||||
|
@ -926,9 +968,12 @@ function Catchup() {
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{posts.length >= 5 && (
|
||||
{posts.length >= 5 &&
|
||||
(postsBarType === '3d' ? (
|
||||
<div class="catchup-posts-viz-time-bar">{postsBins}</div>
|
||||
) : (
|
||||
<div class="catchup-posts-viz-bar">{postsBar}</div>
|
||||
)}
|
||||
))}
|
||||
{posts.length >= 2 && (
|
||||
<div class="catchup-filters">
|
||||
<label class="filter-cat">
|
||||
|
@ -1139,14 +1184,11 @@ function Catchup() {
|
|||
return (
|
||||
<Fragment key={`${post.id}-${showSeparator}`}>
|
||||
{showSeparator && <li class="separator" />}
|
||||
<li>
|
||||
<Link to={`/${instance}/s/${id}`}>
|
||||
<IntersectionPostLine
|
||||
<IntersectionPostLineItem
|
||||
to={`/${instance}/s/${id}`}
|
||||
post={post}
|
||||
root={scrollableRef.current}
|
||||
/>
|
||||
</Link>
|
||||
</li>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
|
@ -1251,7 +1293,7 @@ const PostLine = memo(
|
|||
},
|
||||
);
|
||||
|
||||
const IntersectionPostLine = ({ root, ...props }) => {
|
||||
const IntersectionPostLineItem = ({ root, to, ...props }) => {
|
||||
const ref = useRef();
|
||||
const [show, setShow] = useState(false);
|
||||
useEffect(() => {
|
||||
|
@ -1275,9 +1317,13 @@ const IntersectionPostLine = ({ root, ...props }) => {
|
|||
}, []);
|
||||
|
||||
return show ? (
|
||||
<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);
|
||||
}
|
||||
|
||||
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;
|
||||
|
|
Loading…
Reference in a new issue