From 41d4412ada0cea748deeb80258e963ec507eb87b Mon Sep 17 00:00:00 2001 From: Enea Jahollari Date: Thu, 30 Nov 2023 12:01:12 +0100 Subject: [PATCH] initial impl --- apps/test-app/src/app/app.component.ts | 4 + apps/test-app/src/app/app.config.ts | 4 + .../app/inject-fps/inject-fps.component.ts | 18 +++ libs/ngxtension/inject-fps/README.md | 3 + libs/ngxtension/inject-fps/ng-package.json | 5 + libs/ngxtension/inject-fps/project.json | 33 ++++ libs/ngxtension/inject-fps/src/index.ts | 1 + libs/ngxtension/inject-fps/src/inject-fps.ts | 51 +++++++ libs/ngxtension/inject-raf-fn/README.md | 3 + libs/ngxtension/inject-raf-fn/ng-package.json | 5 + libs/ngxtension/inject-raf-fn/project.json | 33 ++++ libs/ngxtension/inject-raf-fn/src/index.ts | 1 + .../inject-raf-fn/src/inject-raf-fn.ts | 141 ++++++++++++++++++ tsconfig.base.json | 4 + 14 files changed, 306 insertions(+) create mode 100644 apps/test-app/src/app/inject-fps/inject-fps.component.ts create mode 100644 libs/ngxtension/inject-fps/README.md create mode 100644 libs/ngxtension/inject-fps/ng-package.json create mode 100644 libs/ngxtension/inject-fps/project.json create mode 100644 libs/ngxtension/inject-fps/src/index.ts create mode 100644 libs/ngxtension/inject-fps/src/inject-fps.ts create mode 100644 libs/ngxtension/inject-raf-fn/README.md create mode 100644 libs/ngxtension/inject-raf-fn/ng-package.json create mode 100644 libs/ngxtension/inject-raf-fn/project.json create mode 100644 libs/ngxtension/inject-raf-fn/src/index.ts create mode 100644 libs/ngxtension/inject-raf-fn/src/inject-raf-fn.ts diff --git a/apps/test-app/src/app/app.component.ts b/apps/test-app/src/app/app.component.ts index 51b3c949c..33170e71d 100644 --- a/apps/test-app/src/app/app.component.ts +++ b/apps/test-app/src/app/app.component.ts @@ -31,6 +31,10 @@ import { RouterLink, RouterOutlet } from '@angular/router';
  • Active Element
  • + +
  • + Fps +

  • diff --git a/apps/test-app/src/app/app.config.ts b/apps/test-app/src/app/app.config.ts index 1470b2114..d66086eee 100644 --- a/apps/test-app/src/app/app.config.ts +++ b/apps/test-app/src/app/app.config.ts @@ -24,6 +24,10 @@ export const appConfig: ApplicationConfig = { path: 'drag', loadComponent: () => import('./drag/drag.component'), }, + { + path: 'fps', + loadComponent: () => import('./inject-fps/inject-fps.component'), + }, { path: 'active-element', loadComponent: () => diff --git a/apps/test-app/src/app/inject-fps/inject-fps.component.ts b/apps/test-app/src/app/inject-fps/inject-fps.component.ts new file mode 100644 index 000000000..73dd7c52f --- /dev/null +++ b/apps/test-app/src/app/inject-fps/inject-fps.component.ts @@ -0,0 +1,18 @@ +import { Component } from '@angular/core'; +import { injectFps } from 'ngxtension/inject-fps'; + +@Component({ + standalone: true, + host: { + style: 'display: block; margin: 12px', + }, + template: ` +
    FPS: {{ fps() }}
    + + + + `, +}) +export default class InjectFpsCmp { + fps = injectFps(); +} diff --git a/libs/ngxtension/inject-fps/README.md b/libs/ngxtension/inject-fps/README.md new file mode 100644 index 000000000..eb3c4393d --- /dev/null +++ b/libs/ngxtension/inject-fps/README.md @@ -0,0 +1,3 @@ +# ngxtension/inject-fps + +Secondary entry point of `ngxtension`. It can be used by importing from `ngxtension/inject-fps`. diff --git a/libs/ngxtension/inject-fps/ng-package.json b/libs/ngxtension/inject-fps/ng-package.json new file mode 100644 index 000000000..b3e53d699 --- /dev/null +++ b/libs/ngxtension/inject-fps/ng-package.json @@ -0,0 +1,5 @@ +{ + "lib": { + "entryFile": "src/index.ts" + } +} diff --git a/libs/ngxtension/inject-fps/project.json b/libs/ngxtension/inject-fps/project.json new file mode 100644 index 000000000..5390cc2df --- /dev/null +++ b/libs/ngxtension/inject-fps/project.json @@ -0,0 +1,33 @@ +{ + "name": "ngxtension/inject-fps", + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "projectType": "library", + "sourceRoot": "libs/ngxtension/inject-fps/src", + "targets": { + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "libs/ngxtension/jest.config.ts", + "testPathPattern": ["inject-fps"], + "passWithNoTests": true + }, + "configurations": { + "ci": { + "ci": true, + "codeCoverage": true + } + } + }, + "lint": { + "executor": "@nx/eslint:lint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": [ + "libs/ngxtension/inject-fps/**/*.ts", + "libs/ngxtension/inject-fps/**/*.html" + ] + } + } + } +} diff --git a/libs/ngxtension/inject-fps/src/index.ts b/libs/ngxtension/inject-fps/src/index.ts new file mode 100644 index 000000000..fb504fba1 --- /dev/null +++ b/libs/ngxtension/inject-fps/src/index.ts @@ -0,0 +1 @@ +export * from './inject-fps'; diff --git a/libs/ngxtension/inject-fps/src/inject-fps.ts b/libs/ngxtension/inject-fps/src/inject-fps.ts new file mode 100644 index 000000000..ba87c8f45 --- /dev/null +++ b/libs/ngxtension/inject-fps/src/inject-fps.ts @@ -0,0 +1,51 @@ +import { + runInInjectionContext, + signal, + type Injector, + type Signal, +} from '@angular/core'; +import { assertInjector } from 'ngxtension/assert-injector'; +import { injectRafFn } from 'ngxtension/inject-raf-fn'; + +export interface InjectFpsOptions { + /** + * Calculate the FPS on every x frames. + * @default 10 + */ + every?: number; + + /** + * The injector to use. + */ + injector?: Injector; +} + +export function injectFps(options?: InjectFpsOptions): Signal { + const injector = assertInjector(injectFps, options?.injector); + + return runInInjectionContext(injector, () => { + const fps = signal(0); + if (typeof performance === 'undefined') return fps.asReadonly(); + + const every = options?.every ?? 10; + + let last = performance.now(); + let ticks = 0; + + injectRafFn( + () => { + ticks += 1; + if (ticks >= every) { + const now = performance.now(); + const diff = now - last; + fps.set(Math.round(1000 / (diff / ticks))); + last = now; + ticks = 0; + } + }, + { injector } + ); + + return fps; + }); +} diff --git a/libs/ngxtension/inject-raf-fn/README.md b/libs/ngxtension/inject-raf-fn/README.md new file mode 100644 index 000000000..b9a488a0d --- /dev/null +++ b/libs/ngxtension/inject-raf-fn/README.md @@ -0,0 +1,3 @@ +# ngxtension/inject-raf-fn + +Secondary entry point of `ngxtension`. It can be used by importing from `ngxtension/inject-raf-fn`. diff --git a/libs/ngxtension/inject-raf-fn/ng-package.json b/libs/ngxtension/inject-raf-fn/ng-package.json new file mode 100644 index 000000000..b3e53d699 --- /dev/null +++ b/libs/ngxtension/inject-raf-fn/ng-package.json @@ -0,0 +1,5 @@ +{ + "lib": { + "entryFile": "src/index.ts" + } +} diff --git a/libs/ngxtension/inject-raf-fn/project.json b/libs/ngxtension/inject-raf-fn/project.json new file mode 100644 index 000000000..b6ca401a9 --- /dev/null +++ b/libs/ngxtension/inject-raf-fn/project.json @@ -0,0 +1,33 @@ +{ + "name": "ngxtension/inject-raf-fn", + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "projectType": "library", + "sourceRoot": "libs/ngxtension/inject-raf-fn/src", + "targets": { + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "libs/ngxtension/jest.config.ts", + "testPathPattern": ["inject-raf-fn"], + "passWithNoTests": true + }, + "configurations": { + "ci": { + "ci": true, + "codeCoverage": true + } + } + }, + "lint": { + "executor": "@nx/eslint:lint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": [ + "libs/ngxtension/inject-raf-fn/**/*.ts", + "libs/ngxtension/inject-raf-fn/**/*.html" + ] + } + } + } +} diff --git a/libs/ngxtension/inject-raf-fn/src/index.ts b/libs/ngxtension/inject-raf-fn/src/index.ts new file mode 100644 index 000000000..2e8261cc5 --- /dev/null +++ b/libs/ngxtension/inject-raf-fn/src/index.ts @@ -0,0 +1 @@ +export * from './inject-raf-fn'; diff --git a/libs/ngxtension/inject-raf-fn/src/inject-raf-fn.ts b/libs/ngxtension/inject-raf-fn/src/inject-raf-fn.ts new file mode 100644 index 000000000..b3442ab3a --- /dev/null +++ b/libs/ngxtension/inject-raf-fn/src/inject-raf-fn.ts @@ -0,0 +1,141 @@ +import { isPlatformServer } from '@angular/common'; +import { + NgZone, + PLATFORM_ID, + inject, + runInInjectionContext, + signal, + type Injector, + type Signal, +} from '@angular/core'; +import { assertInjector } from 'ngxtension/assert-injector'; + +export interface InjectRafFnCallbackArguments { + /** + * Time elapsed between this and the last frame. + */ + delta: number; + + /** + * Time elapsed since the creation of the web page. See {@link https://developer.mozilla.org/en-US/docs/Web/API/DOMHighResTimeStamp#the_time_origin Time origin}. + */ + timestamp: DOMHighResTimeStamp; +} + +export interface UseRafFnOptions { + /** + * Start the requestAnimationFrame loop immediately on creation + * + * @default true + */ + immediate?: boolean; + /** + * The maximum frame per second to execute the function. + * Set to `undefined` to disable the limit. + * + * @default undefined + */ + fpsLimit?: number; + + /** + * The injector to use. + */ + injector?: Injector; +} + +export interface RafFn { + /** + * A signal to indicate whether an instance is active + */ + isActive: Signal; + + /** + * Temporary pause the effect from executing + */ + pause: () => void; + + /** + * Resume the effects + */ + resume: () => void; +} + +/** + * Call function on every `requestAnimationFrame`. With controls of pausing and resuming. + * + * @param fn + * @param options + */ +export function injectRafFn( + fn: (args: InjectRafFnCallbackArguments) => void, + options: UseRafFnOptions = {} +): RafFn { + const injector = assertInjector(injectRafFn, options?.injector); + + return runInInjectionContext(injector, () => { + const ngZone = inject(NgZone); + const isServer = isPlatformServer(inject(PLATFORM_ID)); + + if (isServer) { + return { + isActive: signal(false).asReadonly(), + pause: () => {}, + resume: () => {}, + }; + } + + const { immediate = true, fpsLimit = undefined } = options; + + const isActive = signal(false); + + const intervalLimit = fpsLimit ? 1000 / fpsLimit : null; + let previousFrameTimestamp = 0; + let rafId: null | number = null; + + const loop = (timestamp: DOMHighResTimeStamp) => { + if (!isActive() || !window) return; + + const delta = timestamp - (previousFrameTimestamp || timestamp); + + if (intervalLimit && delta < intervalLimit) { + rafId = ngZone.runOutsideAngular(() => + window.requestAnimationFrame(loop) + ); + return; + } + + fn({ delta, timestamp }); + + previousFrameTimestamp = timestamp; + + rafId = ngZone.runOutsideAngular(() => + window.requestAnimationFrame(loop) + ); + }; + + const resume = () => { + if (!isActive() && window) { + isActive.set(true); + rafId = ngZone.runOutsideAngular(() => + window.requestAnimationFrame(loop) + ); + } + }; + + const pause = () => { + isActive.set(false); + if (rafId && typeof rafId === 'number' && window) { + ngZone.runOutsideAngular(() => window.cancelAnimationFrame(rafId!)); + rafId = null; + } + }; + + if (immediate) resume(); + + return { + isActive: isActive.asReadonly(), + pause, + resume, + }; + }); +} diff --git a/tsconfig.base.json b/tsconfig.base.json index 9a54f9c92..1514a0d79 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -52,6 +52,7 @@ "ngxtension/inject-destroy": [ "libs/ngxtension/inject-destroy/src/index.ts" ], + "ngxtension/inject-fps": ["libs/ngxtension/inject-fps/src/index.ts"], "ngxtension/inject-is-intersecting": [ "libs/ngxtension/inject-is-intersecting/src/index.ts" ], @@ -62,6 +63,9 @@ "ngxtension/inject-query-params": [ "libs/ngxtension/inject-query-params/src/index.ts" ], + "ngxtension/inject-raf-fn": [ + "libs/ngxtension/inject-raf-fn/src/index.ts" + ], "ngxtension/intl": ["libs/ngxtension/intl/src/index.ts"], "ngxtension/map-array": ["libs/ngxtension/map-array/src/index.ts"], "ngxtension/map-skip-undefined": [