diff --git a/src/content/profiles.json b/src/content/profiles.json index dc552c9..082c520 100644 --- a/src/content/profiles.json +++ b/src/content/profiles.json @@ -21,10 +21,11 @@ "image": "https://cdn.discordapp.com/avatars/378704069726044170/415dcb2ef8d1ef635e35e1d04d523cba.webp", "class": "outline-amber-500", "coordinates": [568, 594], - "size": 120 + "size": 120, + "tag": "le_mod" }, { - "image": "https://cdn.discordapp.com/avatars/623781003382751243/95435efc86709ac7e347ca205bda665e.webp", + "image": "https://cdn.discordapp.com/avatars/623781003382751243/0705cf015336ae06cefcdcb2800a49e9.webp", "class": "outline-orange-500", "coordinates": [525, 764], "size": 80 @@ -120,13 +121,6 @@ "coordinates": [263, 653], "size": 65 }, - { - "image": "https://cdn.discordapp.com/avatars/273110996938260481/5d2ea7a0ad5a29e0de5b3918bfa82ab2.webp", - "class": "outline-yellow-500 bg-black ", - "coordinates": [893, 622], - "size": 80, - "quote": "\"piss blob\"" - }, { "image": "https://cdn.discordapp.com/avatars/231040215085481984/7f378337240110b76e6e9baa31f83670.webp", "class": "outline-amber-500", @@ -159,7 +153,7 @@ "size": 35 }, { - "image": "https://cdn.discordapp.com/avatars/390226958241366016/513b77487a9fa01b1256c4dc8e1b0c55.webp", + "image": "https://cdn.discordapp.com/avatars/390226958241366016/4c149bb6d2ba4ed1265a36c2c9c4418a.webp", "class": "outline-blue-500 ", "coordinates": [858, 707], "size": 45 @@ -177,7 +171,7 @@ "size": 47 }, { - "image": "https://cdn.discordapp.com/avatars/194584980922433536/83b3e21452d96c9a3443717dbcddb4bf.webp", + "image": "https://cdn.discordapp.com/avatars/194584980922433536/7e8f880edc7b213bbb4ad02a50c9fb36.webp", "class": "outline-blue-500 bg-black ", "coordinates": [69, 561], "size": 54 @@ -201,7 +195,7 @@ "size": 49 }, { - "image": "https://cdn.discordapp.com/avatars/317785409763541002/8e8d743abd3f8aafc87ebd8484a57f26.webp", + "image": "https://cdn.discordapp.com/avatars/317785409763541002/4c6f2c001a577909b0904678c3309522.webp", "class": "outline-blue-500 bg-black ", "coordinates": [119, 202], "size": 49 @@ -213,7 +207,7 @@ "size": 69 }, { - "image": "https://cdn.discordapp.com/avatars/706672850907430942/1e2a38423ce667a69e05c4ad8712ccab.webp", + "image": "https://cdn.discordapp.com/avatars/706672850907430942/5d5f32f579d9061c5392377fd334c1b3.webp", "class": "outline-stone-500 bg-black ", "coordinates": [771, 818], "size": 49 diff --git a/src/lib/Helper.mjs b/src/lib/Helper.mjs index 0e4343b..2a36f1e 100755 --- a/src/lib/Helper.mjs +++ b/src/lib/Helper.mjs @@ -1,7 +1,22 @@ /* eslint-disable no-useless-escape */ +import { + interval, + map, + of, + scan, + startWith, + switchMap, + merge, + timeInterval, + filter, + take, + pipe as rxpipe, + Observable +} from 'rxjs' + import { inview } from 'svelte-inview' import { pick } from 'remeda' -import { Observable, debounceTime, share, startWith, throttleTime } from 'rxjs' +import { writable } from 'svelte/store' /** * Fade: The initial opacity from 0 to 1. @@ -84,7 +99,7 @@ export function getIsMobile() { /(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows ce|xda|xiino/i.test( a ) || - /1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s\-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|\-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw\-(n|u)|c55\/|capi|ccwa|cdm\-|cell|chtm|cldc|cmd\-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc\-s|devi|dica|dmob|do(c|p)o|ds(12|\-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(\-|_)|g1 u|g560|gene|gf\-5|g\-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd\-(m|p|t)|hei\-|hi(pt|ta)|hp( i|ip)|hs\-c|ht(c(\-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i\-(20|go|ma)|i230|iac( |\-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc\-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|\-[a-w])|libw|lynx|m1\-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m\-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(\-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)\-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|\-([1-8]|c))|phil|pire|pl(ay|uc)|pn\-2|po(ck|rt|se)|prox|psio|pt\-g|qa\-a|qc(07|12|21|32|60|\-[2-7]|i\-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h\-|oo|p\-)|sdk\/|se(c(\-|0|1)|47|mc|nd|ri)|sgh\-|shar|sie(\-|m)|sk\-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h\-|v\-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl\-|tdg\-|tel(i|m)|tim\-|t\-mo|to(pl|sh)|ts(70|m\-|m3|m5)|tx\-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|\-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(\-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas\-|your|zeto|zte\-/i.test( + /1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|\-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw\-(n|u)|c55\/|capi|ccwa|cdm\-|cell|chtm|cldc|cmd\-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc\-s|devi|dica|dmob|do(c|p)o|ds(12|\-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(\-|_)|g1 u|g560|gene|gf\-5|g\-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd\-(m|p|t)|hei\-|hi(pt|ta)|hp( i|ip)|hs\-c|ht(c(\-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i\-(20|go|ma)|i230|iac( |\-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc\-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|\-[a-w])|libw|lynx|m1\-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m\-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(\-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)\-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|\-([1-8]|c))|phil|pire|pl(ay|uc)|pn\-2|po(ck|rt|se)|prox|psio|pt\-g|qa\-a|qc(07|12|21|32|60|\-[2-7]|i\-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h\-|oo|p\-)|sdk\/|se(c(\-|0|1)|47|mc|nd|ri)|sgh\-|shar|sie(\-|m)|sk\-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h\-|v\-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl\-|tdg\-|tel(i|m)|tim\-|t\-mo|to(pl|sh)|ts(70|m\-|m3|m5)|tx\-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|\-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(\-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas\-|your|zeto|zte\-/i.test( a.substr(0, 4) ) ) @@ -131,3 +146,93 @@ export function trimText(text, maxLenght) { export function getFileNameWithoutExtension(filePath) { return filePath.split('/').at(-1).replace(/\..*$/, '') } + +/** + * A custom operator which maps inputs to their amount (level), + * and completes once a target level has been reached. + * + * On inactivity the level decreases (fallof). + * + * Used here to do fancy stuff with clicks + */ +export function createThresholdStream({ clicksTarget = 69, clicksEachMs = 400, fallof = 20 }) { + const FALLOF = -clicksTarget / fallof + + return rxpipe( + timeInterval(), + filter(({ interval }) => interval < clicksEachMs), + map(() => 1), + switchMap((value) => + merge( + of(value), + + /** If no new value comes in, start decreasing the progress */ + interval(clicksEachMs + 100).pipe( + take(clicksTarget), // Prevent this interval from running forever + map(() => FALLOF) + ) + ) + ), + scan((level, value) => Math.min(clicksTarget, Math.max(level + value, 0))), + startWith(0) + ) +} + +/** + * Tell the browser to preload an image + * + * @param {string} src + */ +export function preloadImage(src) { + return new Promise((resolve, reject) => { + const image = new Image() + image.src = src + image.onload = resolve + image.onerror = reject + }) +} + +/** + * A writable store but as as an observable. + * Observables are much nicher than regular stores. + * @template T + * @param {T} init + * @returns {Observable & { update: (updater: (state: T) => T) => void}} + */ +export function writableObservable(init) { + const { update, subscribe } = writable(init) + const observable = new Observable((subscriber) => { + const unsubscribe = subscribe((value) => subscriber.next(value)) + + return unsubscribe + }) + observable.update = update + + return observable +} + +/** + * Convert a store to an observable + * @template T + * @param {import('svelte/store').Readable} store + * @returns {Observable} + */ +export function convertStoreToObservable(store) { + return new Observable((subscriber) => { + return store.subscribe((value) => subscriber.next(value)) + }) +} + +/** + * Checks if two rectangles are intersecting + * @param {{size: number, coordinates: [x: number, y: number]}} rect1 + * @param {{size: number, coordinates: [x: number, y: number]} rect2 + */ +export function isIntersecting(rect1, rect2) { + return !( + rect1.coordinates[0] + rect1.size < rect2.coordinates[0] || + rect2.coordinates[0] + rect2.size < rect1.coordinates[0] || + rect1.coordinates[1] + rect1.size < rect2.coordinates[1] || + rect2.coordinates[1] + rect2.size < rect1.coordinates[1] + ) +} diff --git a/src/lib/Types.ts b/src/lib/Types.ts new file mode 100644 index 0000000..54368cb --- /dev/null +++ b/src/lib/Types.ts @@ -0,0 +1,38 @@ +import type { Observable } from 'rxjs' + +export interface CommunityProfile { + image: string + coordinates: [number, number] + containerClass: string[] + size: number + quote?: string + onDragStart?: (event: DragEvent) => void + onDragEnd?: (event: DragEvent) => void + onHover?: (event: MouseEvent) => void +} + +export interface CommunityContext { + /** The size of the biggest profile picture. Non reactive for now */ + biggestSize: number + /** The size of the smallest profile picture. Non reactive for now */ + smallestSize: number + /** Get the HTML wrapper element / drag bounds element */ + getSectionElement: () => HTMLElement + /** State for community profile stuff like interactions */ + profilesState$: Observable & { + update: (updater: (state: ProfilesState) => ProfilesState) => void + } +} + +interface ProfilesState { + /** + * Which profiles intersect with each other + * Alphabetically sorted, like `a-b`, not `b-a`, to prevent duplicates. + * No tuples, so that a set can look them up and delete them easily. + */ + intersections: `${string}-${string}`[] + /** All tagged profiles */ + profiles: Record + /** Current active events. Currently not used, but maybe in the future */ + events: string[] +} diff --git a/src/lib/components/DiscordProfilePicture.svelte b/src/lib/components/DiscordProfilePicture.svelte index a7197d3..a713970 100644 --- a/src/lib/components/DiscordProfilePicture.svelte +++ b/src/lib/components/DiscordProfilePicture.svelte @@ -3,8 +3,9 @@ import { createEventDispatcher, getContext, onDestroy, onMount } from 'svelte' import { spring } from 'svelte/motion' import { contextId as ctxId } from '../../routes/home-slices/CommunitySlice.svelte' - import { lerp } from '$lib/Helper.mjs' + import { convertStoreToObservable, isIntersecting, lerp } from '$lib/Helper.mjs' import { inview } from 'svelte-inview' + import { Subject, distinctUntilChanged, throttle, throttleTime } from 'rxjs' /** @type {string} */ export let image @@ -14,6 +15,8 @@ export let size /** @type {[number, number]} */ export let coordinates + /** @type {string | undefined} User description */ + export let tag = undefined /** @type {string | undefined} */ export let quote = undefined @@ -28,12 +31,17 @@ export let imageWrapper /** @type {HTMLImageElement}*/ export let imageElement + /** @type {string|undefined}*/ + export let style = undefined + export let hasDelay = true + export let spawnInstanly = false - const { biggestSize, getSectionElement } = getContext(contextId) + /** @type {import('$lib/Types.ts').CommunityContext} **/ + const { biggestSize, getSectionElement, profilesState$ } = getContext(contextId) const dispatch = createEventDispatcher() const relativeSize = size / biggestSize - const delay = Math.pow(1 - size / biggestSize, 4) * 4654 + const delay = hasDelay ? Math.pow(1 - size / biggestSize, 4) * 4654 : 0 const dragCoordinates = spring([0, 0], { damping: lerp(0.2, 0.03, relativeSize), stiffness: lerp(0.2, 0.01, relativeSize), @@ -50,7 +58,21 @@ function onViewEnter() { if (imageElement.__error) return - setTimeout(() => (hasEnteredView = true), 550) + setTimeout( + () => { + hasEnteredView = true + if (tag && profilesState$) { + profilesState$.update((state) => { + // No drag yet, so we can just use the normal coordinates + state.profiles[tag] = { size, coordinates } + return state + }) + } + + dispatch('enteredView', { dragCoordinates, imageElement, element, delay }) + }, + spawnInstanly ? 0 : 550 + ) // Only load the library if the element entered the view, to improve performance import('interactjs').then(({ default: interact }) => { @@ -82,12 +104,51 @@ }) } + const draggedSubscription = convertStoreToObservable(dragCoordinates) + .pipe(throttleTime(80)) + .subscribe((drag) => { + const displayedPosition = getDisplayedPosition(coordinates, drag) + dispatch('dragged', displayedPosition) + + if (!tag) return + + // This whole things looks so slow, but if it works. Feel free to PR nicer way though + profilesState$.update((state) => { + state.profiles[tag] = { coordinates: displayedPosition, size } + + const otherIntersections = state.intersections.filter((pair) => !pair.includes(tag)) + const thisIntersections = Object.entries(state.profiles) + .filter( + ([otherTag, rectangle]) => + otherTag !== tag && + isIntersecting(rectangle, { size, coordinates: displayedPosition }) + ) + .map(([otherTag]) => [otherTag, tag].sort().join('-')) + const allIntersections = [...otherIntersections, ...thisIntersections] + + state.intersections = allIntersections + + return state + }) + }) + onMount(() => { // Nesecarry as the load image event might not get fired when its already loaded ( for example after a page reload ) hasImageLoaded = hasImageLoaded || imageElement.complete }) - onDestroy(() => interactionjs?.off()) + onDestroy(() => { + draggedSubscription.unsubscribe() + interactionjs?.off() + }) + + /** + * @param {[x: number, y: number]}origin + * @param {[x: number, y: number]}dragCoordinates + */ + function getDisplayedPosition(origin, dragCoordinates) { + return [origin.at(0) + dragCoordinates.at(0), origin.at(1) + dragCoordinates.at(1)] + }
xy + 'px').join(' ')} - style="width: {size}px; height: {size}px;--delay: {delay}ms;" + style="width: {size}px; height: {size}px;--delay: {delay}ms; " aria-hidden="true" bind:this={element} > -
(hasImageLoaded = true)} src={image} @@ -127,6 +189,7 @@ width={size} height={size} onerror="this.__error = true" + {style} />
@@ -136,7 +199,7 @@ {quote}
{/if} -
+