diff --git a/package-lock.json b/package-lock.json index 3686338c..0e098213 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,7 @@ "idb-keyval": "^6.2.0", "lit": "^3.3.2", "marked": "^17.0.1", + "three": "^0.184.0", "urlpattern-polyfill": "^10.1.0", "web-router": "^0.5.0" }, @@ -39,6 +40,7 @@ "@playwright/test": "^1.50.0", "@rollup/plugin-typescript": "^12.3.0", "@types/dom-navigation": "^1.0.6", + "@types/three": "^0.184.0", "@typescript-eslint/eslint-plugin": "^8.48.1", "@typescript-eslint/parser": "^8.48.1", "@vitest/browser": "^4.0.16", @@ -265,6 +267,13 @@ "@capacitor/core": ">=8.0.0" } }, + "node_modules/@dimforge/rapier3d-compat": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz", + "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/@emnapi/runtime": { "version": "1.7.1", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.7.1.tgz", @@ -2627,6 +2636,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@tweenjs/tween.js": { + "version": "23.1.3", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", + "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/accepts": { "version": "1.3.7", "resolved": "https://registry.npmjs.org/@types/accepts/-/accepts-1.3.7.tgz", @@ -2969,6 +2985,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/stats.js": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz", + "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/statuses": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/statuses/-/statuses-2.0.6.tgz", @@ -2976,12 +2999,34 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/three": { + "version": "0.184.0", + "resolved": "https://registry.npmjs.org/@types/three/-/three-0.184.0.tgz", + "integrity": "sha512-4mY2tZAu0y0B0567w7013BBXSpsP0+Z48NJvmNo4Y/Pf76yCyz6Jw4P3tUVs10WuYNXXZ+wmHyGWpCek3amJxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@dimforge/rapier3d-compat": "~0.12.0", + "@tweenjs/tween.js": "~23.1.3", + "@types/stats.js": "*", + "@types/webxr": ">=0.5.17", + "fflate": "~0.8.2", + "meshoptimizer": "~1.1.1" + } + }, "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", "license": "MIT" }, + "node_modules/@types/webxr": { + "version": "0.5.24", + "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", + "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/ws": { "version": "7.4.7", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-7.4.7.tgz", @@ -5115,6 +5160,13 @@ "node": "^12.20 || >= 14.13" } }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "dev": true, + "license": "MIT" + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -6685,6 +6737,13 @@ "node": ">= 8" } }, + "node_modules/meshoptimizer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.1.1.tgz", + "integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==", + "dev": true, + "license": "MIT" + }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -8795,6 +8854,12 @@ "dev": true, "license": "MIT" }, + "node_modules/three": { + "version": "0.184.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.184.0.tgz", + "integrity": "sha512-wtTRjG92pM5eUg/KuUnHsqSAlPM296brTOcLgMRqEeylYTh/CdtvKUvCyyCQTzFuStieWxvZb8mVTMvdPyUpxg==", + "license": "MIT" + }, "node_modules/through2": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/through2/-/through2-4.0.2.tgz", diff --git a/package.json b/package.json index 370ed6eb..d4b05ed7 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ "idb-keyval": "^6.2.0", "lit": "^3.3.2", "marked": "^17.0.1", + "three": "^0.184.0", "urlpattern-polyfill": "^10.1.0", "web-router": "^0.5.0" }, @@ -73,6 +74,7 @@ "@playwright/test": "^1.50.0", "@rollup/plugin-typescript": "^12.3.0", "@types/dom-navigation": "^1.0.6", + "@types/three": "^0.184.0", "@typescript-eslint/eslint-plugin": "^8.48.1", "@typescript-eslint/parser": "^8.48.1", "@vitest/browser": "^4.0.16", diff --git a/src/components/settings-drawer-content.ts b/src/components/settings-drawer-content.ts index 8dd073b3..fb076937 100644 --- a/src/components/settings-drawer-content.ts +++ b/src/components/settings-drawer-content.ts @@ -39,6 +39,7 @@ export class SettingsDrawerContent extends LitElement { @property({ type: Boolean }) wellnessMode = false; @property({ type: Boolean }) dataSaverMode = false; @property({ type: Boolean }) hapticsEnabled = true; + @property({ type: Boolean }) experimental3dTimelineEnabled = false; @property({ type: Boolean }) appThemeLoaded = false; @state() private isAndroid = false; @@ -58,6 +59,10 @@ export class SettingsDrawerContent extends LitElement { margin-top: 16px; } + #warp-timeline-button { + margin-top: 16px; + } + md-card md-divider { margin-top: 20px; margin-bottom: 20px; @@ -358,6 +363,10 @@ export class SettingsDrawerContent extends LitElement { router.navigate('/announcements'); } + private _openWarpTimeline() { + router.navigate('/warp'); + } + private _formatAnnouncementDate(iso: string): string { try { return new Intl.DateTimeFormat(undefined, { @@ -400,6 +409,17 @@ export class SettingsDrawerContent extends LitElement { ); } + private handleExperimental3dTimelineToggle(e: Event) { + const checked = (e.target as HTMLInputElement).checked; + this.dispatchEvent( + new CustomEvent('experimental-3d-timeline-change', { + detail: { checked }, + bubbles: true, + composed: true, + }) + ); + } + render() { return html`
@@ -519,6 +539,31 @@ export class SettingsDrawerContent extends LitElement { ${msg('Vibrate on actions like likes, boosts, and publishing.')}

+ + +
+

${msg('Experimental 3D Timeline')}

+ +
+

+ ${msg('Try an experitment in exploring posts in 3D.')} +

+ + ${this.experimental3dTimelineEnabled + ? html` + + ${msg('Open Warp Timeline')} + + ` + : nothing} ${this.isAndroid ? html` diff --git a/src/generated/locales/de.ts b/src/generated/locales/de.ts index bef137b5..ea597117 100644 --- a/src/generated/locales/de.ts +++ b/src/generated/locales/de.ts @@ -9,11 +9,9 @@ export const templates = { s06e80274b5700572: `Option entfernen`, s075ba6594b3909dc: `Direkt`, s079a052719914d71: `Kategorie`, - s07a00d850d958329: `Essen`, s07c165a4537f8c77: `Noch keine Erwähnungen`, s07e6118e838b2e23: `Navigation`, s0843b2fcef33fcc8: `Veröffentlichen`, - s08ce549fa4d15dfd: `Wissenschaft`, s0a3a833266cb4585: `Beitrag bearbeiten`, s0a5df04b5c6d2f9f: `Deine Umfrage`, s0a75f9328a2fb427: `Scrolle und tippe auf "Zum Home-Bildschirm"`, @@ -36,7 +34,6 @@ export const templates = { s17fb3f4805f4f4b9: `Feld entfernen`, s181de3190f4abb2c: `Teilen`, s191badfb60321854: `Füge Coho zu deinem Startbildschirm hinzu für das beste Erlebnis`, - s19617036a468a75f: `Mode`, s1967faf9ed8d3504: `Avatar`, s1bb6c905f66815eb: `App installieren`, s1dc17af39243deee: `Für Mastodon-Konto registrieren`, @@ -47,7 +44,6 @@ export const templates = { s20a500234e838dda: `Favoriten`, s216407f266691656: `Geteiltes Bild konnte nicht geladen werden. Bitte versuche es erneut.`, s21f68e65dbf5b5a4: `Verarbeite...`, - s233ae776268cd44b: `Wirtschaft`, s251903053fa7a51e: `Folge...`, s26c4bbcf4df6673b: `Thread erweitern`, s275772c501fb3b2e: `Nicht jetzt`, @@ -148,7 +144,6 @@ export const templates = { s8946af4df69fe739: `Du folgst`, s897531ca8a270ee9: `Vorgeschlagene Änderung`, s8997c16cd0e93271: `Wellness-Modus`, - s8a127bd255333eda: `Sport`, s8af61807443f32a4: `Aktionen`, s8bf481a0b5d8a689: `Generiere...`, s8cdcbe65c5e46f7a: `Erneut versuchen`, @@ -163,7 +158,6 @@ export const templates = { s9a46ff9baa276602: `zurück`, s9ac7f1d0c67c0118: `Bild bearbeiten`, s9c1fe7b413b74ce5: `Benachrichtigungen öffnen`, - s9ca372a6096ddd54: `Musik`, s9d3248be9803bbe0: `Antworte auf diesen Beitrag...`, s9d8b8aa2b404c2c8: `Einstellungen`, s9dce1fb8226d0cdd: `Beitrag öffnen`, @@ -180,7 +174,6 @@ export const templates = { saccb9cc846d8abd3: `hat einen Beitrag bearbeitet`, sadf9c2063e1497b7: `Dein moderner Mastodon-Client`, sae77551833d7117b: `Der Wellness-Modus blendet Likes und Boosts aus.`, - sb0a26af3eb6ab88e: `Politik`, sb16c59bda3cb5c45: `Filter anwenden...`, sb23eed07fcaf3095: `Abstimmen`, sb32cad250448883a: `Aufnahme stoppen`, @@ -190,20 +183,15 @@ export const templates = { sb60bbe250aad3fa8: `Spam`, sb72a4b091e8795b3: `Suche öffnen`, sb78a66c8b3d23215: `Prüfe...`, - sb8f030e19d93bb79: `Unterhaltung`, sb8f855b49234b81b: `Anwenden`, - sbaf0a7c417e388af: `Gesundheit`, sbb15471af4d8dc52: `Erkenne...`, - sbb87af74ab3706b9: `Reisen`, sbba6a0f5864827d4: `Verfassen`, sbd1a5d538201dae8: `Vom Dock oder Startbildschirm starten`, sbd20a2111e25f4ea: `Verstanden`, sbdeedc1c60306b35: `Nachrichten`, - sbec848a6b81917c0: `Lifestyle`, sc0894569c67f140e: `Kein Spoiler-Text angegeben`, sc15ed31aec49dd18: `Trends`, sc2b1e7582843b682: `Mehrfachauswahl erlauben`, - sc565503c7584ed60: `Interessen`, sc685d7705585c76f: `Zeitleiste aktualisieren`, sc7e5b0355146889c: `Zu Nachrichten`, sc8da3cc71de63832: `Anmelden`, @@ -229,7 +217,6 @@ export const templates = { sdec709236764aa73: `Vorschau schließen`, sdee2dd4540cbfa57: str`${0} Beiträge heute`, se06934af5f7f49eb: `Wähle eine oder mehrere`, - se088dfba6684a16f: `Technologie`, se09b308c799d5a44: `0 Follower`, se13b1d97003596bc: `Beiträge`, se1ec1972bbf775cb: `Du surfst als Gast. Melde dich an, um mit Beiträgen zu interagieren und alle Funktionen zu nutzen.`, @@ -258,7 +245,6 @@ export const templates = { sf886a1d8bd2479a9: `Spracheingabe`, sf993bb199fefbe04: `Alle`, sf9ca5cc93c622eb7: `Nicht gelistet`, - sf9f8d719a044edca: `Kunst`, sfa3341f85163e600: `Medien anhängen`, sfa847f22cbd9bff5: `Übersetzen`, sfb6d64d0e61d8dba: `An entfernten Server weiterleiten`, @@ -531,6 +517,9 @@ export const templates = { s2b5047d39b9baf3d: `Preferences`, s399b2152de758495: `Haptic Feedback`, s1210a6c6ad65d97b: `Vibrate on actions like likes, boosts, and publishing.`, + s89fa731572319ad5: `Experimental 3D Timeline`, + s540ee2367481ff18: `Try an experitment in exploring posts in 3D.`, + s889f72a85fa15194: `Open Warp Timeline`, s5ddb4ae6884197c4: `Sync to Watch`, s36c13bb456523146: `Synced ✓`, s9cd4984f1a746b40: `Syncing…`, @@ -562,6 +551,23 @@ export const templates = { s2bcebb0b6a6517b6: `Search for your server to connect another Mastodon account.`, s626c98f49c83a793: `Search for a Mastodon server`, s6d83773bf37278f8: `Starting OAuth...`, + s1714a4b01daaca2b: `3D timeline prototype`, + s122c4e6c8ff0ed45: `Exit`, + sd655df7903434847: `Warp timeline is off`, + s12b5b398412ee0ae: `Enable the experimental 3D timeline from Settings to try this prototype.`, + sc16e00a7a8b2fde2: `Back`, + s9078ee819c18df6e: `Enable`, + sd808756d0ff4e16f: `Motion preference respected`, + s94cb988cb2673ab2: `The 3D timeline is paused because reduced motion is enabled on this device.`, + s2f79bb23775ba5c8: `This may be demanding`, + sfeabaa95f8aaf253: `This prototype uses WebGL and may be less smooth on this device.`, + s81ecf2d4386b8e84: `Continue`, + sd94680e71442a0d0: `Could not load the warp`, + s983be4a3a60636b1: `No posts to warp through`, + s06e3d2ab959d506f: `Your home timeline did not return any posts for this prototype run.`, + s690d3047465f4117: `End of warp`, + sd65c657d6e6ac4c8: `That is the end of the loaded prototype timeline.`, + s14bec4678c4e60cc: `Restart`, se54e9d4d0fde357e: str`Followed by`, se6f79719051ff286: str`and`, s540f97efd95106d7: str`and ${0} others`, diff --git a/src/generated/locales/es.ts b/src/generated/locales/es.ts index 1b811db0..c0f34233 100644 --- a/src/generated/locales/es.ts +++ b/src/generated/locales/es.ts @@ -9,11 +9,9 @@ export const templates = { s06e80274b5700572: `Eliminar opción`, s075ba6594b3909dc: `Directo`, s079a052719914d71: `Categoría`, - s07a00d850d958329: `Comida`, s07c165a4537f8c77: `Aún no hay menciones`, s07e6118e838b2e23: `Navegación`, s0843b2fcef33fcc8: `Publicar`, - s08ce549fa4d15dfd: `Ciencia`, s0a3a833266cb4585: `Editar Publicación`, s0a5df04b5c6d2f9f: `Tu encuesta`, s0a75f9328a2fb427: `Desplázate y toca "Agregar a Inicio"`, @@ -36,7 +34,6 @@ export const templates = { s17fb3f4805f4f4b9: `Eliminar campo`, s181de3190f4abb2c: `Impulsar`, s191badfb60321854: `Agrega Coho a tu pantalla de inicio para la mejor experiencia`, - s19617036a468a75f: `Moda`, s1967faf9ed8d3504: `Avatar`, s1bb6c905f66815eb: `Instalar App`, s1dc17af39243deee: `Crear Cuenta de Mastodon`, @@ -47,7 +44,6 @@ export const templates = { s20a500234e838dda: `Favoritos`, s216407f266691656: `No se pudo cargar la imagen compartida. Por favor, intenta compartir de nuevo.`, s21f68e65dbf5b5a4: `Procesando...`, - s233ae776268cd44b: `Negocios`, s251903053fa7a51e: `Siguiendo...`, s26c4bbcf4df6673b: `Expandir hilo`, s275772c501fb3b2e: `Ahora no`, @@ -148,7 +144,6 @@ export const templates = { s8946af4df69fe739: `Estás Siguiendo`, s897531ca8a270ee9: `Revisión sugerida`, s8997c16cd0e93271: `Modo Bienestar`, - s8a127bd255333eda: `Deportes`, s8af61807443f32a4: `Acciones`, s8bf481a0b5d8a689: `Generando...`, s8cdcbe65c5e46f7a: `Intentar de nuevo`, @@ -163,7 +158,6 @@ export const templates = { s9a46ff9baa276602: `atrás`, s9ac7f1d0c67c0118: `Editar Imagen`, s9c1fe7b413b74ce5: `Abrir Notificaciones`, - s9ca372a6096ddd54: `Música`, s9d3248be9803bbe0: `Responder a esta publicación...`, s9d8b8aa2b404c2c8: `Configuración`, s9dce1fb8226d0cdd: `Abrir publicación`, @@ -180,7 +174,6 @@ export const templates = { saccb9cc846d8abd3: `editó una publicación`, sadf9c2063e1497b7: `Tu cliente moderno de Mastodon`, sae77551833d7117b: `El Modo Bienestar oculta los me gusta e impulsos.`, - sb0a26af3eb6ab88e: `Política`, sb16c59bda3cb5c45: `Aplicando filtro...`, sb23eed07fcaf3095: `Votar`, sb32cad250448883a: `Detener grabación`, @@ -190,20 +183,15 @@ export const templates = { sb60bbe250aad3fa8: `Spam`, sb72a4b091e8795b3: `Abrir Búsqueda`, sb78a66c8b3d23215: `Comprobando...`, - sb8f030e19d93bb79: `Entretenimiento`, sb8f855b49234b81b: `Aplicar`, - sbaf0a7c417e388af: `Salud`, sbb15471af4d8dc52: `Reconociendo...`, - sbb87af74ab3706b9: `Viajes`, sbba6a0f5864827d4: `Redacción`, sbd1a5d538201dae8: `Iniciar desde tu dock o pantalla de inicio`, sbd20a2111e25f4ea: `Entendido`, sbdeedc1c60306b35: `Mensajes`, - sbec848a6b81917c0: `Estilo de Vida`, sc0894569c67f140e: `Sin texto de spoiler`, sc15ed31aec49dd18: `Tendencias`, sc2b1e7582843b682: `Permitir múltiples opciones`, - sc565503c7584ed60: `Intereses`, sc685d7705585c76f: `Actualizar línea de tiempo`, sc7e5b0355146889c: `Ir a Mensajes`, sc8da3cc71de63832: `Iniciar Sesión`, @@ -229,7 +217,6 @@ export const templates = { sdec709236764aa73: `Cerrar vista previa`, sdee2dd4540cbfa57: str`${0} publicaciones hoy`, se06934af5f7f49eb: `Selecciona uno o más`, - se088dfba6684a16f: `Tecnología`, se09b308c799d5a44: `0 seguidores`, se13b1d97003596bc: `Publicaciones`, se1ec1972bbf775cb: `Estás navegando como invitado. Inicia sesión para interactuar con publicaciones y acceder a todas las funciones.`, @@ -258,7 +245,6 @@ export const templates = { sf886a1d8bd2479a9: `Entrada de voz`, sf993bb199fefbe04: `Todo`, sf9ca5cc93c622eb7: `No listado`, - sf9f8d719a044edca: `Arte`, sfa3341f85163e600: `Adjuntar Multimedia`, sfa847f22cbd9bff5: `Traducir`, sfb6d64d0e61d8dba: `Reenviar al servidor remoto`, @@ -531,6 +517,9 @@ export const templates = { s2b5047d39b9baf3d: `Preferences`, s399b2152de758495: `Haptic Feedback`, s1210a6c6ad65d97b: `Vibrate on actions like likes, boosts, and publishing.`, + s89fa731572319ad5: `Experimental 3D Timeline`, + s540ee2367481ff18: `Try an experitment in exploring posts in 3D.`, + s889f72a85fa15194: `Open Warp Timeline`, s5ddb4ae6884197c4: `Sync to Watch`, s36c13bb456523146: `Synced ✓`, s9cd4984f1a746b40: `Syncing…`, @@ -562,6 +551,23 @@ export const templates = { s2bcebb0b6a6517b6: `Search for your server to connect another Mastodon account.`, s626c98f49c83a793: `Search for a Mastodon server`, s6d83773bf37278f8: `Starting OAuth...`, + s1714a4b01daaca2b: `3D timeline prototype`, + s122c4e6c8ff0ed45: `Exit`, + sd655df7903434847: `Warp timeline is off`, + s12b5b398412ee0ae: `Enable the experimental 3D timeline from Settings to try this prototype.`, + sc16e00a7a8b2fde2: `Back`, + s9078ee819c18df6e: `Enable`, + sd808756d0ff4e16f: `Motion preference respected`, + s94cb988cb2673ab2: `The 3D timeline is paused because reduced motion is enabled on this device.`, + s2f79bb23775ba5c8: `This may be demanding`, + sfeabaa95f8aaf253: `This prototype uses WebGL and may be less smooth on this device.`, + s81ecf2d4386b8e84: `Continue`, + sd94680e71442a0d0: `Could not load the warp`, + s983be4a3a60636b1: `No posts to warp through`, + s06e3d2ab959d506f: `Your home timeline did not return any posts for this prototype run.`, + s690d3047465f4117: `End of warp`, + sd65c657d6e6ac4c8: `That is the end of the loaded prototype timeline.`, + s14bec4678c4e60cc: `Restart`, se54e9d4d0fde357e: str`Followed by`, se6f79719051ff286: str`and`, s540f97efd95106d7: str`and ${0} others`, diff --git a/src/pages/app-home.ts b/src/pages/app-home.ts index 6bd9510b..6bde4051 100644 --- a/src/pages/app-home.ts +++ b/src/pages/app-home.ts @@ -65,6 +65,7 @@ export class AppHome extends LitElement { @state() wellnessMode: boolean = false; @state() dataSaverMode: boolean = false; @state() hapticsEnabled: boolean = true; + @state() experimental3dTimelineEnabled: boolean = false; @state() summary: string = ''; @@ -265,6 +266,10 @@ export class AppHome extends LitElement { this.handleDataSaverMode(settings.data_saver || false); this.handleHapticsMode(settings.haptics !== false); + + this.handleExperimental3dTimelineMode( + settings.experimental_3d_timeline || false + ); } // Only check notifications for authenticated users @@ -568,6 +573,13 @@ export class AppHome extends LitElement { setSettings({ haptics: enabled }); } + async handleExperimental3dTimelineMode(enabled: boolean) { + this.experimental3dTimelineEnabled = enabled; + + const { setSettings } = await import('../services/settings'); + setSettings({ experimental_3d_timeline: enabled }); + } + async handleTabChange(event: TabChangeEvent) { // Determine the load callback: // If we were passed a panel, we might need simple loading @@ -1149,6 +1161,8 @@ export class AppHome extends LitElement { .wellnessMode="${this.wellnessMode}" .dataSaverMode="${this.dataSaverMode}" .hapticsEnabled="${this.hapticsEnabled}" + .experimental3dTimelineEnabled="${this + .experimental3dTimelineEnabled}" .appThemeLoaded="${this.appThemeLoaded}" @wellness-change="${(e: CustomEvent<{ checked: boolean }>) => this.handleWellnessMode(e.detail.checked)}" @@ -1156,6 +1170,9 @@ export class AppHome extends LitElement { this.handleDataSaverMode(e.detail.checked)}" @haptics-change="${(e: CustomEvent<{ checked: boolean }>) => this.handleHapticsMode(e.detail.checked)}" + @experimental-3d-timeline-change="${( + e: CustomEvent<{ checked: boolean }> + ) => this.handleExperimental3dTimelineMode(e.detail.checked)}" @open-filters="${() => this.openFiltersDialog()}" @open-scheduled-statuses="${() => this.openScheduledStatusesDialog()}" diff --git a/src/pages/app-warp.ts b/src/pages/app-warp.ts new file mode 100644 index 00000000..601095dd --- /dev/null +++ b/src/pages/app-warp.ts @@ -0,0 +1,972 @@ +import { LitElement, css, html, nothing } from 'lit'; +import { customElement, query, state } from 'lit/decorators.js'; +import { localized, msg } from '@lit/localize'; + +import '../components/md/md-button'; +import '../components/md/md-skeleton'; + +import { router } from '../router/routes'; +import type { Post } from '../interfaces/Post'; +import type { WarpCardPool } from '../utils/warp/card-pool'; +import type { WarpScene } from '../utils/warp/scene'; +import type { WarpTheme } from '../utils/warp/post-card-texture'; + +@localized() +@customElement('app-warp') +export class AppWarp extends LitElement { + @query('canvas') private canvas?: HTMLCanvasElement; + + @state() private posts: Post[] = []; + @state() private loading = true; + @state() private error = ''; + @state() private featureEnabled = false; + @state() private reducedMotion = false; + @state() private lowPowerWarning = false; + @state() private sceneReady = false; + @state() private reachedEnd = false; + @state() private loadingMore = false; + + private scene: WarpScene | null = null; + private cardPool: WarpCardPool | null = null; + private animationFrame = 0; + private lastFrameTime = 0; + private progress = 0; + private travelVelocity = 0; + private inputDirection = 0; + private lookYaw = 0; + private lookPitch = 0; + private targetLookYaw = 0; + private targetLookPitch = 0; + private pointerLocked = false; + private lastInputAt = 0; + private lastLookAt = 0; + private readonly pressedKeys = new Set(); + private readonly keyboardTravelSpeed = 4.8; + private readonly travelAcceleration = 10; + private readonly travelFriction = 4.6; + private readonly maxTravelVelocity = 10; + private readonly wheelImpulse = 0.018; + private readonly mouseLookSensitivity = 0.0018; + private readonly maxLookYaw = 0.86; + private readonly maxLookPitch = 0.42; + private touchStartY = 0; + private touchStartX = 0; + private pointerStartX = 0; + private pointerStartY = 0; + private pointerStartAt = 0; + private pendingStart = false; + private hasMorePosts = true; + + static styles = css` + :host { + display: block; + min-height: 100vh; + background: #090a0f; + color: #f7f2ff; + } + + .warp-shell { + position: fixed; + inset: 0; + overflow: hidden; + background: #090a0f; + touch-action: none; + } + + canvas { + width: 100%; + height: 100%; + display: block; + } + + .top-bar, + .status-panel { + position: fixed; + z-index: 2; + display: flex; + gap: 8px; + } + + .top-bar { + top: max(18px, env(safe-area-inset-top)); + left: max(18px, env(safe-area-inset-left)); + right: max(18px, env(safe-area-inset-right)); + justify-content: space-between; + align-items: center; + pointer-events: none; + } + + .top-bar md-button { + pointer-events: auto; + } + + .status-panel { + left: 50%; + top: 50%; + width: min(420px, calc(100vw - 32px)); + transform: translate(-50%, -50%); + flex-direction: column; + align-items: stretch; + padding: 20px; + border-radius: 8px; + background: var(--md-sys-color-surface-container-high, #25252b); + color: var(--md-sys-color-on-surface, #f5f5f5); + box-shadow: 0 18px 48px rgba(0, 0, 0, 0.24); + } + + .status-panel h2, + .status-panel p { + margin: 0; + } + + .status-panel h2 { + font-size: var(--md-sys-typescale-title-large-font-size, 1.375rem); + } + + .status-panel p { + color: var(--md-sys-color-on-surface-variant, #c8c5cc); + line-height: 1.45; + } + + .actions { + display: flex; + gap: 8px; + justify-content: flex-end; + margin-top: 8px; + } + + .loader { + position: fixed; + left: 50%; + bottom: max(26px, env(safe-area-inset-bottom)); + z-index: 2; + width: min(300px, calc(100vw - 48px)); + transform: translateX(-50%); + } + + .reticle { + position: fixed; + left: 50%; + top: 50%; + z-index: 1; + width: 18px; + height: 18px; + transform: translate(-50%, -50%); + pointer-events: none; + opacity: 0.54; + } + + .reticle::before, + .reticle::after { + position: absolute; + content: ''; + background: rgba(255, 255, 255, 0.72); + border-radius: 999px; + } + + .reticle::before { + left: 8px; + top: 1px; + width: 2px; + height: 16px; + } + + .reticle::after { + left: 1px; + top: 8px; + width: 16px; + height: 2px; + } + `; + + connectedCallback() { + super.connectedCallback(); + window.addEventListener('keydown', this.handleKeyDown); + window.addEventListener('keyup', this.handleKeyUp); + document.addEventListener('visibilitychange', this.handleVisibilityChange); + document.addEventListener( + 'pointerlockchange', + this.handlePointerLockChange + ); + document.addEventListener('mousemove', this.handleMouseMove); + this.initialize(); + } + + disconnectedCallback() { + window.removeEventListener('keydown', this.handleKeyDown); + window.removeEventListener('keyup', this.handleKeyUp); + document.removeEventListener( + 'visibilitychange', + this.handleVisibilityChange + ); + document.removeEventListener( + 'pointerlockchange', + this.handlePointerLockChange + ); + document.removeEventListener('mousemove', this.handleMouseMove); + this.disposeScene(); + super.disconnectedCallback(); + } + + protected firstUpdated() { + if (this.pendingStart) { + this.pendingStart = false; + this.startScene(); + } + } + + private async initialize() { + try { + const { getSettings } = await import('../services/settings'); + const settings = await getSettings(); + this.featureEnabled = settings.experimental_3d_timeline === true; + this.reducedMotion = window.matchMedia( + '(prefers-reduced-motion: reduce)' + ).matches; + + if (!this.featureEnabled || this.reducedMotion) { + this.loading = false; + return; + } + + this.lowPowerWarning = this.shouldWarnLowPower(); + const { getPaginatedHomeTimeline } = await import('../services/timeline'); + this.posts = await getPaginatedHomeTimeline('home'); + this.loading = false; + + if (this.posts.length > 0 && !this.lowPowerWarning) { + this.pendingStart = true; + await this.updateComplete; + this.startScene(); + } + } catch (error) { + this.error = error instanceof Error ? error.message : String(error); + this.loading = false; + } + } + + private async startScene() { + if (!this.canvas || this.scene || this.posts.length === 0) { + return; + } + + const [{ WarpScene }, { WarpCardPool }] = await Promise.all([ + import('../utils/warp/scene'), + import('../utils/warp/card-pool'), + ]); + + const theme = this.readTheme(); + this.scene = new WarpScene(this.canvas, this, theme); + this.cardPool = new WarpCardPool( + this.posts, + this.scene.scene, + this.scene.curve, + theme + ); + this.cardPool.update(this.progress, this.scene.camera); + this.scene.render(); + this.sceneReady = true; + this.lastInputAt = performance.now(); + this.startLoop(); + } + + private readTheme(): WarpTheme { + const styles = getComputedStyle(this); + const value = (name: string, fallback: string) => + styles.getPropertyValue(name).trim() || fallback; + + return { + surface: value('--md-sys-color-surface-container-high', '#25252b'), + onSurface: value('--md-sys-color-on-surface', '#f5f5f5'), + onSurfaceVariant: value('--md-sys-color-on-surface-variant', '#c8c5cc'), + primary: value('--md-sys-color-primary', '#9ecaff'), + outline: value('--md-sys-color-outline', '#8f8d96'), + }; + } + + private shouldWarnLowPower(): boolean { + const hardwareConcurrency = navigator.hardwareConcurrency || 8; + const deviceMemory = (navigator as Navigator & { deviceMemory?: number }) + .deviceMemory; + + return ( + hardwareConcurrency < 4 || + (deviceMemory !== undefined && deviceMemory < 4) + ); + } + + private startLoop() { + if (this.animationFrame || !this.scene || !this.cardPool) { + return; + } + + this.lastFrameTime = performance.now(); + this.animationFrame = requestAnimationFrame(this.tick); + } + + private tick = (time: number) => { + this.animationFrame = 0; + + if (!this.scene || !this.cardPool || document.hidden) { + return; + } + + const delta = Math.min((time - this.lastFrameTime) / 1000, 0.05); + this.lastFrameTime = time; + + const maxProgress = Math.max(0, this.posts.length - 1); + const targetVelocity = this.inputDirection * this.keyboardTravelSpeed; + + if (this.inputDirection !== 0) { + const acceleration = Math.min(1, this.travelAcceleration * delta); + this.travelVelocity += + (targetVelocity - this.travelVelocity) * acceleration; + } else { + this.travelVelocity *= Math.max(0, 1 - this.travelFriction * delta); + } + + if (Math.abs(this.travelVelocity) < 0.01) { + this.travelVelocity = 0; + } + + if (!this.hasMorePosts && this.travelVelocity > 0) { + const distanceToEnd = maxProgress - this.progress; + if (distanceToEnd < 2.2) { + this.travelVelocity *= Math.max(0.08, distanceToEnd / 2.2); + } + } + + this.travelVelocity = clamp( + this.travelVelocity, + -this.maxTravelVelocity, + this.maxTravelVelocity + ); + this.progress += this.travelVelocity * delta; + if (this.progress < 0) { + this.progress = 0; + this.travelVelocity = Math.max(0, this.travelVelocity); + } + if (this.progress > maxProgress) { + this.progress = maxProgress; + this.travelVelocity = Math.min(0, this.travelVelocity); + } + + if (!this.pointerLocked && time - this.lastLookAt > 1200) { + const returnEase = Math.max(0, 1 - delta * 1.8); + this.targetLookYaw *= returnEase; + this.targetLookPitch *= returnEase; + } + + const lookEase = Math.min(1, delta * 9); + this.lookYaw += (this.targetLookYaw - this.lookYaw) * lookEase; + this.lookPitch += (this.targetLookPitch - this.lookPitch) * lookEase; + + this.loadMorePostsIfNeeded(); + this.reachedEnd = + !this.hasMorePosts && + !this.loadingMore && + this.progress >= maxProgress - 0.04 && + Math.abs(this.travelVelocity) < 0.04; + + this.scene.setCameraProgressWithLook( + this.progress, + this.lookYaw, + this.lookPitch + ); + this.cardPool.update(this.progress, this.scene.camera); + this.scene.render(); + + const isRecentlyActive = time - this.lastInputAt < 2000; + const isMoving = Math.abs(this.travelVelocity) > 0.01; + const isLooking = + Math.abs(this.targetLookYaw - this.lookYaw) > 0.001 || + Math.abs(this.targetLookPitch - this.lookPitch) > 0.001; + if ( + isMoving || + isLooking || + this.inputDirection !== 0 || + isRecentlyActive + ) { + this.animationFrame = requestAnimationFrame(this.tick); + } + }; + + private handleWheel(event: WheelEvent) { + event.preventDefault(); + this.travelVelocity = clamp( + this.travelVelocity + event.deltaY * this.wheelImpulse, + -this.maxTravelVelocity, + this.maxTravelVelocity + ); + + if (Math.abs(event.deltaX) > 0.5) { + this.targetLookYaw = clamp( + this.targetLookYaw + event.deltaX * 0.003, + -this.maxLookYaw, + this.maxLookYaw + ); + this.lastLookAt = performance.now(); + } + + this.lastInputAt = performance.now(); + this.startLoop(); + } + + private handleTouchStart(event: TouchEvent) { + this.touchStartY = event.touches[0]?.clientY ?? 0; + this.touchStartX = event.touches[0]?.clientX ?? 0; + } + + private handleTouchMove(event: TouchEvent) { + event.preventDefault(); + const currentY = event.touches[0]?.clientY ?? this.touchStartY; + const currentX = event.touches[0]?.clientX ?? this.touchStartX; + const deltaY = this.touchStartY - currentY; + const deltaX = currentX - this.touchStartX; + + this.travelVelocity = clamp( + this.travelVelocity + deltaY * 0.045, + -this.maxTravelVelocity, + this.maxTravelVelocity + ); + this.targetLookYaw = clamp( + this.targetLookYaw - deltaX * 0.004, + -this.maxLookYaw, + this.maxLookYaw + ); + + this.touchStartY = currentY; + this.touchStartX = currentX; + this.lastInputAt = performance.now(); + this.lastLookAt = performance.now(); + this.startLoop(); + } + + private handlePointerMove(event: PointerEvent) { + if ( + !this.canvas || + this.pointerLocked || + this.shouldIgnorePointerEvent(event) + ) { + return; + } + + const bounds = this.canvas.getBoundingClientRect(); + const normalizedX = ((event.clientX - bounds.left) / bounds.width) * 2 - 1; + const normalizedY = ((event.clientY - bounds.top) / bounds.height) * 2 - 1; + this.targetLookYaw = clamp( + normalizedX * this.maxLookYaw, + -this.maxLookYaw, + this.maxLookYaw + ); + this.targetLookPitch = clamp( + -normalizedY * this.maxLookPitch, + -this.maxLookPitch, + this.maxLookPitch + ); + this.lastInputAt = performance.now(); + this.lastLookAt = performance.now(); + this.startLoop(); + } + + private handlePointerLeave() { + if (this.pointerLocked) { + return; + } + + this.targetLookYaw = 0; + this.targetLookPitch = 0; + this.lastInputAt = performance.now(); + this.startLoop(); + } + + private handlePointerLockChange = () => { + this.pointerLocked = document.pointerLockElement !== null; + this.lastInputAt = performance.now(); + this.lastLookAt = performance.now(); + this.startLoop(); + }; + + private handleMouseMove = (event: MouseEvent) => { + if (document.pointerLockElement === null) { + return; + } + + this.pointerLocked = true; + this.targetLookYaw = clamp( + this.targetLookYaw + event.movementX * this.mouseLookSensitivity, + -this.maxLookYaw, + this.maxLookYaw + ); + this.targetLookPitch = clamp( + this.targetLookPitch - event.movementY * this.mouseLookSensitivity, + -this.maxLookPitch, + this.maxLookPitch + ); + this.lookYaw = this.targetLookYaw; + this.lookPitch = this.targetLookPitch; + + if (this.scene && this.cardPool) { + this.scene.setCameraProgressWithLook( + this.progress, + this.lookYaw, + this.lookPitch + ); + this.cardPool.update(this.progress, this.scene.camera); + this.scene.render(); + } + + this.lastInputAt = performance.now(); + this.lastLookAt = performance.now(); + this.startLoop(); + }; + + private requestCanvasPointerLock() { + this.canvas?.requestPointerLock?.(); + } + + private handlePointerDown(event: PointerEvent) { + if (this.shouldIgnorePointerEvent(event)) { + return; + } + + this.pointerStartX = event.clientX; + this.pointerStartY = event.clientY; + this.pointerStartAt = performance.now(); + } + + private handlePointerUp(event: PointerEvent) { + if (this.shouldIgnorePointerEvent(event)) { + return; + } + + const deltaX = event.clientX - this.pointerStartX; + const deltaY = event.clientY - this.pointerStartY; + const moved = !this.pointerLocked && Math.hypot(deltaX, deltaY) > 14; + const held = performance.now() - this.pointerStartAt > 650; + if (moved || held) { + return; + } + + if (!this.canvas || !this.scene || !this.cardPool) { + return; + } + + if (!this.pointerLocked) { + this.requestCanvasPointerLock(); + return; + } + + const bounds = this.canvas.getBoundingClientRect(); + const pickX = bounds.left + bounds.width / 2; + const pickY = bounds.top + bounds.height / 2; + const result = this.cardPool.pick(pickX, pickY, bounds, this.scene.camera); + if (!result) { + return; + } + + if (!result.action) { + this.travelVelocity *= 0.35; + this.lastInputAt = performance.now(); + this.startLoop(); + return; + } + + if (result.action === 'open' || result.action === 'reply') { + router.navigate(`/home/post/${result.post.id}`, { + state: { post: result.post }, + }); + return; + } + + this.handlePostAction(result.postIndex, result.action); + } + + private shouldIgnorePointerEvent(event: PointerEvent) { + return event + .composedPath() + .some( + (target) => + target instanceof HTMLElement && + (target.closest('.top-bar') || target.closest('.status-panel')) + ); + } + + private async handlePostAction(postIndex: number, action: 'boost' | 'like') { + const post = this.posts[postIndex]; + if (!post) { + return; + } + + const previousPost = { ...post }; + const nextPost = { ...post }; + const targetId = post.reblog?.id || post.id; + + if (action === 'like') { + nextPost.favourited = !post.favourited; + nextPost.favourites_count = Math.max( + 0, + post.favourites_count + (nextPost.favourited ? 1 : -1) + ); + } else { + nextPost.reblogged = !post.reblogged; + nextPost.reblogs_count = Math.max( + 0, + post.reblogs_count + (nextPost.reblogged ? 1 : -1) + ); + } + + this.replacePost(postIndex, nextPost); + + try { + const timeline = await import('../services/timeline'); + if (action === 'like') { + if (nextPost.favourited) { + await timeline.boostPost(targetId); + } else { + await timeline.unboostPost(targetId); + } + return; + } + + if (nextPost.reblogged) { + await timeline.reblogPost(targetId); + } else { + await timeline.unreblogPost(targetId); + } + } catch (error) { + console.error(`Failed to ${action} warp post`, error); + this.replacePost(postIndex, previousPost); + } + } + + private replacePost(postIndex: number, post: Post) { + this.posts = this.posts.map((candidate, index) => + index === postIndex ? post : candidate + ); + this.cardPool?.updatePost(postIndex, post); + this.lastInputAt = performance.now(); + this.startLoop(); + } + + private handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + router.navigate('/home'); + return; + } + + if (this.isTravelKey(event.key)) { + event.preventDefault(); + this.pressedKeys.add(event.key.toLowerCase()); + this.updateInputDirection(); + this.lastInputAt = performance.now(); + this.startLoop(); + } + }; + + private handleKeyUp = (event: KeyboardEvent) => { + if (this.isTravelKey(event.key)) { + event.preventDefault(); + this.pressedKeys.delete(event.key.toLowerCase()); + this.updateInputDirection(); + this.lastInputAt = performance.now(); + this.startLoop(); + } + }; + + private isTravelKey(key: string): boolean { + const normalizedKey = key.toLowerCase(); + return ( + normalizedKey === 'arrowup' || + normalizedKey === 'arrowdown' || + normalizedKey === 'w' || + normalizedKey === 's' || + normalizedKey === ' ' + ); + } + + private updateInputDirection() { + const forward = + this.pressedKeys.has('arrowup') || + this.pressedKeys.has('w') || + this.pressedKeys.has(' '); + const backward = + this.pressedKeys.has('arrowdown') || this.pressedKeys.has('s'); + + this.inputDirection = forward === backward ? 0 : forward ? 1 : -1; + } + + private handleVisibilityChange = () => { + if (document.hidden) { + if (this.animationFrame) { + cancelAnimationFrame(this.animationFrame); + this.animationFrame = 0; + } + return; + } + + this.lastInputAt = performance.now(); + this.startLoop(); + }; + + private async loadMorePostsIfNeeded(force = false) { + if (this.loadingMore || !this.hasMorePosts || this.posts.length === 0) { + return; + } + + const remainingPosts = this.posts.length - 1 - Math.floor(this.progress); + if (!force && remainingPosts > 8) { + return; + } + + this.loadingMore = true; + + try { + const { getPaginatedHomeTimeline } = await import('../services/timeline'); + const lastPost = this.posts[this.posts.length - 1]; + const nextPosts = await getPaginatedHomeTimeline('home', lastPost.id); + const existingIds = new Set(this.posts.map((post) => post.id)); + const uniqueNextPosts = nextPosts.filter( + (post) => !existingIds.has(post.id) + ); + + if (uniqueNextPosts.length === 0) { + this.hasMorePosts = false; + return; + } + + this.posts = [...this.posts, ...uniqueNextPosts]; + this.cardPool?.updatePosts(this.posts); + this.reachedEnd = false; + this.lastInputAt = performance.now(); + this.startLoop(); + } catch (error) { + console.error('Failed to load more warp timeline posts', error); + this.hasMorePosts = false; + } finally { + this.loadingMore = false; + } + } + + private disposeScene() { + if (this.animationFrame) { + cancelAnimationFrame(this.animationFrame); + this.animationFrame = 0; + } + this.cardPool?.dispose(); + this.cardPool = null; + this.scene?.dispose(); + this.scene = null; + } + + private async enableFeatureAndGoHome() { + const { setSettings } = await import('../services/settings'); + await setSettings({ experimental_3d_timeline: true }); + router.navigate('/home'); + } + + render() { + return html` +
+ + + +
+ + ${msg('Exit')} + +
+ + ${this.loading + ? html`
+ +
` + : nothing} + ${this.loadingMore + ? html`
+ +
` + : nothing} + ${this.renderStatusPanel()} +
+ `; + } + + private renderStatusPanel() { + if (this.loading || (this.sceneReady && !this.reachedEnd)) { + return nothing; + } + + if (!this.featureEnabled) { + return html` +
+

${msg('Warp timeline is off')}

+

+ ${msg( + 'Enable the experimental 3D timeline from Settings to try this prototype.' + )} +

+
+ + ${msg('Back')} + + + ${msg('Enable')} + +
+
+ `; + } + + if (this.reducedMotion) { + return html` +
+

${msg('Motion preference respected')}

+

+ ${msg( + 'The 3D timeline is paused because reduced motion is enabled on this device.' + )} +

+
+ + ${msg('Open Home')} + +
+
+ `; + } + + if (this.lowPowerWarning && !this.sceneReady) { + return html` +
+

${msg('This may be demanding')}

+

+ ${msg( + 'This prototype uses WebGL and may be less smooth on this device.' + )} +

+
+ + ${msg('Back')} + + + ${msg('Continue')} + +
+
+ `; + } + + if (this.error) { + return html` +
+

${msg('Could not load the warp')}

+

${this.error}

+
+ + ${msg('Back')} + +
+
+ `; + } + + if (this.posts.length === 0) { + return html` +
+

${msg('No posts to warp through')}

+

+ ${msg( + 'Your home timeline did not return any posts for this prototype run.' + )} +

+
+ + ${msg('Back')} + +
+
+ `; + } + + if (this.reachedEnd) { + return html` +
+

${msg('End of warp')}

+

${msg('That is the end of the loaded prototype timeline.')}

+
+ + ${msg('Restart')} + + + ${msg('Exit')} + +
+
+ `; + } + + return nothing; + } +} + +function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)); +} + +declare global { + interface HTMLElementTagNameMap { + 'app-warp': AppWarp; + } +} diff --git a/src/router/routes.ts b/src/router/routes.ts index da6f8813..76de1bce 100644 --- a/src/router/routes.ts +++ b/src/router/routes.ts @@ -42,6 +42,12 @@ const routes: Route[] = [ plugins: [lazy(() => import('../pages/app-home.js'))], render: () => html``, }, + { + path: '/warp', + title: 'warp', + plugins: [lazy(() => import('../pages/app-warp.js'))], + render: () => html``, + }, { path: '/search', title: 'search', diff --git a/src/services/settings.ts b/src/services/settings.ts index b07a3282..ea3b184b 100644 --- a/src/services/settings.ts +++ b/src/services/settings.ts @@ -6,6 +6,7 @@ export interface Settings { focus?: boolean; sensitive?: boolean; haptics?: boolean; + experimental_3d_timeline?: boolean; } export const SETTINGS_CHANGED_EVENT = 'coho-settings-changed'; @@ -18,6 +19,7 @@ const defaultSettings = { focus: false, sensitive: false, haptics: true, + experimental_3d_timeline: false, }; export async function getSettings(): Promise { @@ -48,6 +50,11 @@ export async function setSettings(settings: Settings) { haptics: Object.keys(settings).includes('haptics') ? settings.haptics : currentSettings.haptics, + experimental_3d_timeline: Object.keys(settings).includes( + 'experimental_3d_timeline' + ) + ? settings.experimental_3d_timeline + : currentSettings.experimental_3d_timeline, }; // Also store theme color in localStorage for instant access on page load diff --git a/src/utils/warp/card-pool.ts b/src/utils/warp/card-pool.ts new file mode 100644 index 00000000..6bad2665 --- /dev/null +++ b/src/utils/warp/card-pool.ts @@ -0,0 +1,339 @@ +import * as THREE from 'three'; +import type { Post } from '../../interfaces/Post'; +import { + createPlaceholderTexture, + createPostCardTexture, + WARP_CARD_TEXTURE_HEIGHT, + WARP_CARD_TEXTURE_WIDTH, + WARP_ACTION_HITBOXES, + type WarpPostAction, + type WarpTheme, +} from './post-card-texture'; +import { WARP_POST_LEAD, WARP_POST_SPACING } from './scene'; + +export interface WarpCardPickResult { + post: Post; + postIndex: number; + action: WarpPostAction | null; +} + +const poolSize = 20; +const fullTextureSlots = 10; +const nearTextureDistance = 24; +const focusedCardWidth = 3.35; +const maxConcurrentTextureBuilds = 3; +const postsBehindCamera = 4; + +interface WarpCard { + mesh: THREE.Mesh; + postIndex: number; + hasFullTexture: boolean; + loadingTexture: boolean; +} + +export class WarpCardPool { + private readonly cards: WarpCard[] = []; + private readonly textureCache = new Map(); + private readonly curveLength: number; + private readonly raycaster = new THREE.Raycaster(); + private readonly pointer = new THREE.Vector2(); + private textureBuildsInFlight = 0; + + constructor( + private posts: Post[], + private readonly scene: THREE.Scene, + private readonly curve: THREE.CatmullRomCurve3, + private readonly theme: WarpTheme + ) { + this.curveLength = curve.getLength(); + + const visibleCount = Math.min(poolSize, posts.length); + for (let index = 0; index < visibleCount; index += 1) { + const material = new THREE.MeshBasicMaterial({ + map: createPlaceholderTexture(theme), + transparent: true, + side: THREE.DoubleSide, + }); + const cardWidth = focusedCardWidth; + const cardHeight = + cardWidth * (WARP_CARD_TEXTURE_HEIGHT / WARP_CARD_TEXTURE_WIDTH); + const mesh = new THREE.Mesh( + new THREE.PlaneGeometry(cardWidth, cardHeight), + material + ); + mesh.renderOrder = 2; + this.scene.add(mesh); + this.cards.push({ + mesh, + postIndex: -1, + hasFullTexture: false, + loadingTexture: false, + }); + } + } + + updatePosts(posts: Post[]) { + this.posts = posts; + } + + update(progress: number, camera: THREE.PerspectiveCamera) { + const baseIndex = Math.max(0, Math.floor(progress) - postsBehindCamera); + const availableTextureBuilds = Math.max( + 0, + maxConcurrentTextureBuilds - this.textureBuildsInFlight + ); + let textureBuildsStarted = 0; + + this.cards.forEach((card, slotIndex) => { + const postIndex = baseIndex + slotIndex; + if (postIndex < 0 || postIndex >= this.posts.length) { + card.mesh.visible = false; + return; + } + + card.mesh.visible = true; + if (card.postIndex !== postIndex) { + this.assignPost(card, postIndex); + } + + const t = + ((postIndex + WARP_POST_LEAD) * WARP_POST_SPACING) / this.curveLength; + const curveT = Math.min(t, 0.985); + const position = this.curve.getPointAt(curveT); + const tangent = this.curve.getTangentAt(curveT).normalize(); + const viewportScale = getViewportCardScale(camera.aspect); + const lane = getAnchoredCardPlacement( + postIndex - progress, + postIndex, + tangent, + viewportScale + ); + card.mesh.position.set( + position.x + lane.x, + position.y + lane.y, + position.z + ); + card.mesh.scale.setScalar(lane.scale); + card.mesh.material.opacity = 1; + card.mesh.lookAt(camera.position); + + const distance = camera.position.distanceTo(card.mesh.position); + const distanceFromTravel = Math.abs(postIndex - progress); + card.mesh.material.opacity = THREE.MathUtils.clamp( + 1 - (distanceFromTravel - 9) * 0.12, + 0.42, + 1 + ); + if ( + (distanceFromTravel < fullTextureSlots || + distance < nearTextureDistance) && + !card.hasFullTexture && + !card.loadingTexture && + textureBuildsStarted < availableTextureBuilds + ) { + textureBuildsStarted += 1; + this.promoteTexture(card); + } + }); + } + + pick( + clientX: number, + clientY: number, + bounds: DOMRect, + camera: THREE.PerspectiveCamera + ): WarpCardPickResult | null { + this.pointer.x = ((clientX - bounds.left) / bounds.width) * 2 - 1; + this.pointer.y = -((clientY - bounds.top) / bounds.height) * 2 + 1; + this.raycaster.setFromCamera(this.pointer, camera); + + const intersections = this.raycaster.intersectObjects( + this.cards.filter((card) => card.mesh.visible).map((card) => card.mesh) + ); + const intersection = intersections[0]; + const mesh = intersection?.object; + const card = this.cards.find((candidate) => candidate.mesh === mesh); + if (!card) { + return null; + } + + return { + post: this.posts[card.postIndex], + postIndex: card.postIndex, + action: intersection?.uv ? getActionFromUv(intersection.uv) : null, + }; + } + + updatePost(postIndex: number, post: Post) { + this.posts = this.posts.map((candidate, index) => + index === postIndex ? post : candidate + ); + + const card = this.cards.find( + (candidate) => candidate.postIndex === postIndex + ); + if (!card) { + const cachedTexture = this.textureCache.get(postIndex); + cachedTexture?.dispose(); + this.textureCache.delete(postIndex); + return; + } + + this.refreshTexture(card, postIndex); + } + + dispose() { + this.cards.forEach((card) => { + this.scene.remove(card.mesh); + card.mesh.geometry.dispose(); + this.disposeTextureIfUncached(card.mesh.material.map); + card.mesh.material.dispose(); + }); + this.cards.length = 0; + this.textureCache.forEach((texture) => texture.dispose()); + this.textureCache.clear(); + } + + private assignPost(card: WarpCard, postIndex: number) { + card.postIndex = postIndex; + card.loadingTexture = false; + this.disposeTextureIfUncached(card.mesh.material.map); + + const cachedTexture = this.textureCache.get(postIndex); + card.hasFullTexture = cachedTexture !== undefined; + card.mesh.material.map = + cachedTexture ?? createPlaceholderTexture(this.theme); + card.mesh.material.needsUpdate = true; + } + + private async promoteTexture(card: WarpCard) { + card.loadingTexture = true; + this.textureBuildsInFlight += 1; + const post = this.posts[card.postIndex]; + const postIndex = card.postIndex; + + try { + const cachedTexture = this.textureCache.get(postIndex); + if (cachedTexture) { + this.disposeTextureIfUncached(card.mesh.material.map); + card.mesh.material.map = cachedTexture; + card.mesh.material.needsUpdate = true; + card.hasFullTexture = true; + return; + } + + const texture = await createPostCardTexture(post, this.theme); + if (card.postIndex !== postIndex) { + texture.dispose(); + return; + } + + this.textureCache.set(postIndex, texture); + this.disposeTextureIfUncached(card.mesh.material.map); + card.mesh.material.map = texture; + card.mesh.material.needsUpdate = true; + card.hasFullTexture = true; + } finally { + this.textureBuildsInFlight = Math.max(0, this.textureBuildsInFlight - 1); + card.loadingTexture = false; + } + } + + private async refreshTexture(card: WarpCard, postIndex: number) { + if (card.loadingTexture) { + return; + } + + card.loadingTexture = true; + const previousTexture = this.textureCache.get(postIndex); + + try { + const texture = await createPostCardTexture( + this.posts[postIndex], + this.theme + ); + if (card.postIndex !== postIndex) { + texture.dispose(); + return; + } + + this.textureCache.set(postIndex, texture); + card.mesh.material.map = texture; + card.mesh.material.needsUpdate = true; + card.hasFullTexture = true; + + if (previousTexture && previousTexture !== texture) { + previousTexture.dispose(); + } + } finally { + card.loadingTexture = false; + } + } + + private disposeTextureIfUncached(texture: THREE.Texture | null) { + if (!texture) { + return; + } + + if ( + ![...this.textureCache.values()].includes(texture as THREE.CanvasTexture) + ) { + texture.dispose(); + } + } +} + +function getActionFromUv(uv: THREE.Vector2): WarpPostAction | null { + const x = uv.x * WARP_CARD_TEXTURE_WIDTH; + const y = (1 - uv.y) * WARP_CARD_TEXTURE_HEIGHT; + const hitbox = WARP_ACTION_HITBOXES.find( + (candidate) => + x >= candidate.x && + x <= candidate.x + candidate.width && + y >= candidate.y && + y <= candidate.y + candidate.height + ); + + return hitbox?.action ?? null; +} + +function getAnchoredCardPlacement( + distanceFromFocus: number, + postIndex: number, + tangent: THREE.Vector3, + viewportScale: number +): { x: number; y: number; scale: number } { + const worldUp = new THREE.Vector3(0, 1, 0); + const right = new THREE.Vector3().crossVectors(tangent, worldUp).normalize(); + + if (right.lengthSq() < 0.001) { + right.set(1, 0, 0); + } + + const up = new THREE.Vector3().crossVectors(right, tangent).normalize(); + const side = postIndex % 2 === 0 ? -1 : 1; + const distance = Math.abs(distanceFromFocus); + const clampedDistance = Math.min(distance, 6); + const sideOffset = 1.45 * side * viewportScale; + const verticalPattern = + postIndex % 4 === 0 ? 0.22 : postIndex % 4 === 3 ? -0.16 : 0.04; + const scaleBoost = 1 + (1 - Math.min(clampedDistance, 1)) * 0.12; + const depthScale = Math.max(0.72, 1 - clampedDistance * 0.045); + const offset = right + .multiplyScalar(sideOffset) + .add(up.multiplyScalar(verticalPattern * viewportScale)); + + return { + x: offset.x, + y: offset.y, + scale: depthScale * scaleBoost * viewportScale, + }; +} + +function getViewportCardScale(aspect: number): number { + if (aspect >= 0.82) { + return 1; + } + + return THREE.MathUtils.clamp(aspect / 0.82, 0.58, 1); +} diff --git a/src/utils/warp/post-card-texture.ts b/src/utils/warp/post-card-texture.ts new file mode 100644 index 00000000..16f4d9e1 --- /dev/null +++ b/src/utils/warp/post-card-texture.ts @@ -0,0 +1,624 @@ +import * as THREE from 'three'; +import type { Post } from '../../interfaces/Post'; +import type { MediaAttachment } from '../../mastodon/types/media'; + +export interface WarpTheme { + surface: string; + onSurface: string; + onSurfaceVariant: string; + primary: string; + outline: string; +} + +export type WarpPostAction = 'reply' | 'boost' | 'like' | 'open'; + +export interface WarpActionHitbox { + action: WarpPostAction; + x: number; + y: number; + width: number; + height: number; +} + +export const WARP_CARD_TEXTURE_WIDTH = 512; +export const WARP_CARD_TEXTURE_HEIGHT = 390; + +export const WARP_ACTION_HITBOXES: WarpActionHitbox[] = [ + { action: 'reply', x: 32, y: 340, width: 88, height: 34 }, + { action: 'boost', x: 138, y: 340, width: 92, height: 34 }, + { action: 'like', x: 248, y: 340, width: 88, height: 34 }, + { action: 'open', x: 392, y: 340, width: 88, height: 34 }, +]; + +const maxCachedImages = 96; +const imageCache = new Map>(); + +export async function createPostCardTexture( + post: Post, + theme: WarpTheme +): Promise { + const canvas = document.createElement('canvas'); + canvas.width = WARP_CARD_TEXTURE_WIDTH; + canvas.height = WARP_CARD_TEXTURE_HEIGHT; + + const context = canvas.getContext('2d'); + if (!context) { + throw new Error('Unable to create post card canvas context'); + } + + drawCardBackground(context, theme); + await drawAvatar(context, post, theme); + drawText(context, post, theme); + await drawMediaGrid(context, post, theme); + drawActions(context, post, theme); + + const texture = new THREE.CanvasTexture(canvas); + texture.colorSpace = THREE.SRGBColorSpace; + texture.anisotropy = 2; + texture.needsUpdate = true; + return texture; +} + +export function createPlaceholderTexture( + theme: WarpTheme +): THREE.CanvasTexture { + const canvas = document.createElement('canvas'); + canvas.width = WARP_CARD_TEXTURE_WIDTH; + canvas.height = WARP_CARD_TEXTURE_HEIGHT; + + const context = canvas.getContext('2d'); + if (!context) { + throw new Error('Unable to create placeholder canvas context'); + } + + drawCardBackground(context, theme); + context.fillStyle = withAlpha(theme.primary, 0.18); + roundedRect(context, 32, 28, 50, 50, 25); + context.fill(); + context.fillStyle = withAlpha(theme.onSurfaceVariant, 0.18); + roundedRect(context, 98, 34, 210, 18, 9); + context.fill(); + roundedRect(context, 98, 62, 260, 14, 7); + context.fill(); + roundedRect(context, 32, 116, 448, 16, 8); + context.fill(); + roundedRect(context, 32, 146, 390, 16, 8); + context.fill(); + roundedRect(context, 32, 166, 448, 156, 18); + context.fill(); + + const texture = new THREE.CanvasTexture(canvas); + texture.colorSpace = THREE.SRGBColorSpace; + texture.needsUpdate = true; + return texture; +} + +function drawCardBackground( + context: CanvasRenderingContext2D, + theme: WarpTheme +) { + context.clearRect(0, 0, WARP_CARD_TEXTURE_WIDTH, WARP_CARD_TEXTURE_HEIGHT); + context.fillStyle = theme.surface; + roundedRect( + context, + 0, + 0, + WARP_CARD_TEXTURE_WIDTH, + WARP_CARD_TEXTURE_HEIGHT, + 18 + ); + context.fill(); + context.lineWidth = 1; + context.strokeStyle = withAlpha(theme.outline, 0.2); + context.stroke(); +} + +async function drawAvatar( + context: CanvasRenderingContext2D, + post: Post, + theme: WarpTheme +) { + const avatarX = 32; + const avatarY = 28; + const avatarSize = 50; + const avatarUrl = post.reblog?.account.avatar || post.account.avatar; + + context.save(); + context.beginPath(); + context.arc( + avatarX + avatarSize / 2, + avatarY + avatarSize / 2, + avatarSize / 2, + 0, + Math.PI * 2 + ); + context.clip(); + + const image = avatarUrl ? await loadImage(avatarUrl) : null; + if (image) { + context.drawImage(image, avatarX, avatarY, avatarSize, avatarSize); + } else { + context.fillStyle = withAlpha(theme.primary, 0.26); + context.fillRect(avatarX, avatarY, avatarSize, avatarSize); + context.fillStyle = theme.onSurface; + context.font = + '700 28px system-ui, -apple-system, BlinkMacSystemFont, sans-serif'; + context.textAlign = 'center'; + context.textBaseline = 'middle'; + context.fillText( + getDisplayName(post).slice(0, 1).toUpperCase() || '@', + avatarX + avatarSize / 2, + avatarY + avatarSize / 2 + 1 + ); + } + + context.restore(); +} + +function drawText( + context: CanvasRenderingContext2D, + post: Post, + theme: WarpTheme +) { + const displayName = getDisplayName(post); + const account = post.reblog?.account || post.account; + const handle = `@${account.acct || account.username}`; + const content = stripHtml(post.reblog?.content || post.content); + const hasMedia = getVisualMediaAttachments(post).length > 0; + + context.textAlign = 'left'; + context.textBaseline = 'alphabetic'; + + context.fillStyle = theme.onSurface; + context.font = + '700 22px system-ui, -apple-system, BlinkMacSystemFont, sans-serif'; + context.fillText(truncateText(context, displayName, 340), 98, 50); + + context.fillStyle = theme.onSurfaceVariant; + context.font = + '500 16px system-ui, -apple-system, BlinkMacSystemFont, sans-serif'; + context.fillText(truncateText(context, handle, 340), 98, 74); + + context.fillStyle = theme.onSurface; + context.font = + '500 20px system-ui, -apple-system, BlinkMacSystemFont, sans-serif'; + wrapText( + context, + content || 'No text content', + 32, + 116, + 448, + 25, + hasMedia ? 2 : 7 + ); +} + +async function drawMediaGrid( + context: CanvasRenderingContext2D, + post: Post, + theme: WarpTheme +) { + const mediaAttachments = getVisualMediaAttachments(post); + if (mediaAttachments.length === 0) { + return; + } + + const gridX = 32; + const gridY = 166; + const gridWidth = 448; + const gridHeight = 156; + const gap = 3; + const visibleMedia = mediaAttachments.slice(0, 4); + const cells = getMediaGridCells( + visibleMedia.length, + gridX, + gridY, + gridWidth, + gridHeight, + gap + ); + + context.save(); + roundedRect(context, gridX, gridY, gridWidth, gridHeight, 14); + context.clip(); + + for (const [index, media] of visibleMedia.entries()) { + const cell = cells[index]; + await drawMediaCell(context, media, cell, theme); + } + + context.restore(); + + context.lineWidth = 2; + context.strokeStyle = withAlpha(theme.outline, 0.26); + roundedRect(context, gridX, gridY, gridWidth, gridHeight, 14); + context.stroke(); + + if (mediaAttachments.length > visibleMedia.length) { + context.fillStyle = withAlpha(theme.surface, 0.82); + roundedRect( + context, + gridX + gridWidth - 54, + gridY + gridHeight - 34, + 38, + 22, + 11 + ); + context.fill(); + context.fillStyle = theme.onSurface; + context.font = + '700 12px system-ui, -apple-system, BlinkMacSystemFont, sans-serif'; + context.textAlign = 'center'; + context.textBaseline = 'middle'; + context.fillText( + `+${mediaAttachments.length - visibleMedia.length}`, + gridX + gridWidth - 35, + gridY + gridHeight - 23 + ); + } +} + +async function drawMediaCell( + context: CanvasRenderingContext2D, + media: MediaAttachment, + cell: MediaGridCell, + theme: WarpTheme +) { + const previewUrl = media.preview_url || media.url; + const image = previewUrl ? await loadImage(previewUrl) : null; + + context.fillStyle = withAlpha(theme.onSurfaceVariant, 0.14); + context.fillRect(cell.x, cell.y, cell.width, cell.height); + + if (image) { + drawImageContain(context, image, cell.x, cell.y, cell.width, cell.height); + } else { + context.fillStyle = withAlpha(theme.primary, 0.18); + context.fillRect(cell.x, cell.y, cell.width, cell.height); + context.fillStyle = theme.onSurfaceVariant; + context.font = + '700 15px system-ui, -apple-system, BlinkMacSystemFont, sans-serif'; + context.textAlign = 'center'; + context.textBaseline = 'middle'; + context.fillText( + 'Media', + cell.x + cell.width / 2, + cell.y + cell.height / 2 + ); + } + + if (media.type === 'video' || media.type === 'gifv') { + context.fillStyle = withAlpha(theme.surface, 0.82); + roundedRect(context, cell.x + 8, cell.y + 8, 42, 22, 11); + context.fill(); + context.fillStyle = theme.onSurface; + context.font = + '700 12px system-ui, -apple-system, BlinkMacSystemFont, sans-serif'; + context.textAlign = 'center'; + context.textBaseline = 'middle'; + context.fillText( + media.type === 'gifv' ? 'GIF' : 'Video', + cell.x + 29, + cell.y + 19 + ); + } +} + +function drawActions( + context: CanvasRenderingContext2D, + post: Post, + theme: WarpTheme +) { + drawActionButton( + context, + theme, + WARP_ACTION_HITBOXES[0], + formatActionLabel('Reply', post.replies_count), + false + ); + drawActionButton( + context, + theme, + WARP_ACTION_HITBOXES[1], + formatActionLabel( + post.reblogged ? 'Reposted' : 'Repost', + post.reblogs_count + ), + post.reblogged + ); + drawActionButton( + context, + theme, + WARP_ACTION_HITBOXES[2], + formatActionLabel( + post.favourited ? 'Liked' : 'Like', + post.favourites_count + ), + post.favourited + ); + drawActionButton(context, theme, WARP_ACTION_HITBOXES[3], 'Open', false); +} + +function drawActionButton( + context: CanvasRenderingContext2D, + theme: WarpTheme, + hitbox: WarpActionHitbox, + label: string, + active: boolean +) { + context.fillStyle = active ? withAlpha(theme.primary, 0.28) : 'transparent'; + roundedRect(context, hitbox.x, hitbox.y, hitbox.width, hitbox.height, 15); + context.fill(); + context.lineWidth = 2; + context.strokeStyle = active + ? withAlpha(theme.primary, 0.64) + : withAlpha(theme.outline, 0.34); + context.stroke(); + context.fillStyle = active ? theme.primary : theme.onSurfaceVariant; + context.font = + '700 14px system-ui, -apple-system, BlinkMacSystemFont, sans-serif'; + context.textAlign = 'center'; + context.textBaseline = 'middle'; + context.fillText( + truncateText(context, label, hitbox.width - 18), + hitbox.x + hitbox.width / 2, + hitbox.y + hitbox.height / 2 + 1 + ); +} + +function formatActionLabel(label: string, count: number): string { + return count > 0 ? `${label} ${count}` : label; +} + +function getDisplayName(post: Post): string { + const account = post.reblog?.account || post.account; + return stripHtml(account.display_name || account.username || account.acct); +} + +function getVisualMediaAttachments(post: Post): MediaAttachment[] { + const sourcePost = post.reblog ?? post; + if (sourcePost.sensitive) { + return []; + } + + return (sourcePost.media_attachments ?? []).filter( + (media) => + media.type === 'image' || media.type === 'gifv' || media.type === 'video' + ); +} + +interface MediaGridCell { + x: number; + y: number; + width: number; + height: number; +} + +function getMediaGridCells( + count: number, + x: number, + y: number, + width: number, + height: number, + gap: number +): MediaGridCell[] { + if (count === 1) { + return [{ x, y, width, height }]; + } + + if (count === 2) { + const cellWidth = (width - gap) / 2; + return [ + { x, y, width: cellWidth, height }, + { x: x + cellWidth + gap, y, width: cellWidth, height }, + ]; + } + + if (count === 3) { + const leftWidth = (width - gap) / 2; + const rightHeight = (height - gap) / 2; + return [ + { x, y, width: leftWidth, height }, + { x: x + leftWidth + gap, y, width: leftWidth, height: rightHeight }, + { + x: x + leftWidth + gap, + y: y + rightHeight + gap, + width: leftWidth, + height: rightHeight, + }, + ]; + } + + const cellWidth = (width - gap) / 2; + const cellHeight = (height - gap) / 2; + return [ + { x, y, width: cellWidth, height: cellHeight }, + { x: x + cellWidth + gap, y, width: cellWidth, height: cellHeight }, + { x, y: y + cellHeight + gap, width: cellWidth, height: cellHeight }, + { + x: x + cellWidth + gap, + y: y + cellHeight + gap, + width: cellWidth, + height: cellHeight, + }, + ]; +} + +function stripHtml(value: string): string { + const parser = new DOMParser(); + const document = parser.parseFromString(value, 'text/html'); + return (document.body.textContent || '') + .replace(/\s+/g, ' ') + .replace(/ /g, ' ') + .trim(); +} + +function wrapText( + context: CanvasRenderingContext2D, + text: string, + x: number, + y: number, + maxWidth: number, + lineHeight: number, + maxLines: number +) { + const normalizedText = text.replace(/\s+/g, ' ').trim(); + const lines: string[] = []; + let lineStart = 0; + + while (lineStart < normalizedText.length && lines.length < maxLines) { + let lineEnd = lineStart; + let lastBreak = -1; + + while (lineEnd < normalizedText.length) { + const candidate = normalizedText.slice(lineStart, lineEnd + 1); + if (context.measureText(candidate).width > maxWidth) { + break; + } + + if (isSoftBreakCharacter(normalizedText[lineEnd])) { + lastBreak = lineEnd + 1; + } + lineEnd += 1; + } + + if (lineEnd >= normalizedText.length) { + lines.push(normalizedText.slice(lineStart).trim()); + break; + } + + const breakAt = + lastBreak > lineStart ? lastBreak : Math.max(lineStart + 1, lineEnd); + lines.push(normalizedText.slice(lineStart, breakAt).trim()); + lineStart = breakAt; + + while (normalizedText[lineStart] === ' ') { + lineStart += 1; + } + } + + lines.slice(0, maxLines).forEach((lineText, index) => { + const finalLine = + index === maxLines - 1 && lineStart < normalizedText.length + ? truncateText(context, lineText, maxWidth) + : lineText; + context.fillText(finalLine, x, y + index * lineHeight); + }); +} + +function isSoftBreakCharacter(character: string): boolean { + return ( + character === ' ' || + character === '/' || + character === '-' || + character === '_' + ); +} + +function truncateText( + context: CanvasRenderingContext2D, + text: string, + maxWidth: number +): string { + if (context.measureText(text).width <= maxWidth) { + return text; + } + + let truncated = text; + while ( + truncated.length > 1 && + context.measureText(`${truncated}...`).width > maxWidth + ) { + truncated = truncated.slice(0, -1); + } + return `${truncated}...`; +} + +function roundedRect( + context: CanvasRenderingContext2D, + x: number, + y: number, + width: number, + height: number, + radius: number +) { + context.beginPath(); + context.moveTo(x + radius, y); + context.arcTo(x + width, y, x + width, y + height, radius); + context.arcTo(x + width, y + height, x, y + height, radius); + context.arcTo(x, y + height, x, y, radius); + context.arcTo(x, y, x + width, y, radius); + context.closePath(); +} + +function withAlpha(color: string, alpha: number): string { + if (color.startsWith('#')) { + const hex = color.slice(1); + const bigint = Number.parseInt( + hex.length === 3 + ? hex + .split('') + .map((part) => `${part}${part}`) + .join('') + : hex, + 16 + ); + const red = (bigint >> 16) & 255; + const green = (bigint >> 8) & 255; + const blue = bigint & 255; + return `rgba(${red}, ${green}, ${blue}, ${alpha})`; + } + + return color.startsWith('rgb(') + ? color.replace('rgb(', 'rgba(').replace(')', `, ${alpha})`) + : color; +} + +function drawImageContain( + context: CanvasRenderingContext2D, + image: HTMLImageElement, + x: number, + y: number, + width: number, + height: number +) { + const scale = Math.min( + width / image.naturalWidth, + height / image.naturalHeight + ); + const fittedWidth = image.naturalWidth * scale; + const fittedHeight = image.naturalHeight * scale; + const fittedX = x + (width - fittedWidth) / 2; + const fittedY = y + (height - fittedHeight) / 2; + + context.drawImage(image, fittedX, fittedY, fittedWidth, fittedHeight); +} + +function loadImage(src: string): Promise { + const cachedImage = imageCache.get(src); + if (cachedImage) { + return cachedImage; + } + + const imagePromise = new Promise((resolve) => { + const image = new Image(); + image.crossOrigin = 'anonymous'; + image.decoding = 'async'; + image.onload = () => resolve(image); + image.onerror = () => { + imageCache.delete(src); + resolve(null); + }; + image.src = src; + }); + + imageCache.set(src, imagePromise); + if (imageCache.size > maxCachedImages) { + const oldestKey = imageCache.keys().next().value; + if (oldestKey) { + imageCache.delete(oldestKey); + } + } + + return imagePromise; +} diff --git a/src/utils/warp/scene.ts b/src/utils/warp/scene.ts new file mode 100644 index 00000000..b819dcce --- /dev/null +++ b/src/utils/warp/scene.ts @@ -0,0 +1,131 @@ +import * as THREE from 'three'; +import type { WarpTheme } from './post-card-texture'; + +export const WARP_POST_SPACING = 7.5; +export const WARP_POST_LEAD = 0.72; + +export class WarpScene { + readonly scene = new THREE.Scene(); + readonly camera = new THREE.PerspectiveCamera(58, 1, 0.1, 120); + readonly renderer: THREE.WebGLRenderer; + readonly curve: THREE.CatmullRomCurve3; + + private readonly tube: THREE.Mesh; + private readonly curveLength: number; + private readonly resizeObserver: ResizeObserver; + + constructor( + canvas: HTMLCanvasElement, + private readonly host: HTMLElement, + theme: WarpTheme + ) { + this.renderer = new THREE.WebGLRenderer({ + canvas, + antialias: false, + alpha: true, + powerPreference: 'low-power', + }); + this.renderer.setClearColor(0x000000, 0); + this.renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 1.5)); + + const points = Array.from( + { length: 80 }, + (_, index) => + new THREE.Vector3( + Math.sin(index * 0.72) * 1.45, + Math.cos(index * 0.43) * 0.48, + index * -8 + ) + ); + points[0].set(0, 0, 0); + this.curve = new THREE.CatmullRomCurve3(points); + this.curveLength = this.curve.getLength(); + + const tubeGeometry = new THREE.TubeGeometry( + this.curve, + 512, + 4.4, + 24, + false + ); + const tubeMaterial = new THREE.MeshBasicMaterial({ + color: new THREE.Color(theme.primary), + wireframe: true, + transparent: true, + opacity: 0.28, + depthTest: false, + depthWrite: false, + side: THREE.DoubleSide, + }); + this.tube = new THREE.Mesh(tubeGeometry, tubeMaterial); + this.tube.renderOrder = 0; + this.scene.add(this.tube); + + this.scene.add(new THREE.AmbientLight(0xffffff, 1)); + this.setCameraT(0); + + this.resizeObserver = new ResizeObserver(() => this.resize()); + this.resizeObserver.observe(host); + this.resize(); + } + + setCameraT(t: number) { + const clampedT = THREE.MathUtils.clamp(t, 0, 0.985); + const position = this.curve.getPointAt(clampedT); + const lookAt = this.curve.getPointAt(Math.min(clampedT + 0.025, 1)); + + this.camera.position.copy(position); + this.camera.lookAt(lookAt); + } + + setCameraProgressWithLook(progress: number, yaw: number, pitch: number) { + const t = (progress * WARP_POST_SPACING) / this.curveLength; + const clampedT = THREE.MathUtils.clamp(t, 0, 0.985); + const position = this.curve.getPointAt(clampedT); + const forward = this.curve.getTangentAt(clampedT).normalize(); + const worldUp = new THREE.Vector3(0, 1, 0); + const right = new THREE.Vector3() + .crossVectors(forward, worldUp) + .normalize(); + + if (right.lengthSq() < 0.001) { + right.set(1, 0, 0); + } + + const lookDirection = forward + .clone() + .add(right.multiplyScalar(yaw)) + .add(worldUp.multiplyScalar(pitch)) + .normalize(); + + this.camera.position.copy(position); + this.camera.up.set(0, 1, 0); + this.camera.lookAt(position.clone().add(lookDirection)); + } + + setCameraProgress(progress: number) { + this.setCameraProgressWithLook(progress, 0, 0); + } + + render() { + this.renderer.render(this.scene, this.camera); + } + + dispose() { + this.resizeObserver.disconnect(); + this.scene.remove(this.tube); + this.tube.geometry.dispose(); + (this.tube.material as THREE.Material).dispose(); + this.renderer.dispose(); + } + + private resize() { + const rect = this.host.getBoundingClientRect(); + const width = Math.max(1, Math.floor(rect.width)); + const height = Math.max(1, Math.floor(rect.height)); + + this.renderer.setSize(width, height, false); + this.camera.aspect = width / height; + this.camera.updateProjectionMatrix(); + } +}