signature: add systems map, command palette and motion primitives
Ship the Signature UX as standalone, SSR-safe pieces: bilingual copy, a dual SVG/list systems map, a Map-backed command palette in the shell, opt-in reveal and metric primitives, and a leak-free decorative canvas. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,13 +1,18 @@
|
||||
import { DOCUMENT } from '@angular/common';
|
||||
import {
|
||||
afterNextRender,
|
||||
Component,
|
||||
DestroyRef,
|
||||
ElementRef,
|
||||
inject,
|
||||
NgZone,
|
||||
OnDestroy,
|
||||
ViewChild,
|
||||
} from '@angular/core';
|
||||
import { isBrowserPlatform, prefersCoarsePointer } from '../../core/platform/browser';
|
||||
import {
|
||||
isBrowserPlatform,
|
||||
prefersCoarsePointer,
|
||||
prefersReducedMotion,
|
||||
} from '../../core/platform/browser';
|
||||
import { Dot } from '../../models/dot';
|
||||
|
||||
@Component({
|
||||
@@ -15,21 +20,30 @@ import { Dot } from '../../models/dot';
|
||||
imports: [],
|
||||
templateUrl: './dot-background.html',
|
||||
styleUrl: './dot-background.scss',
|
||||
host: {
|
||||
'aria-hidden': 'true',
|
||||
},
|
||||
})
|
||||
export class DotBackground implements OnDestroy {
|
||||
export class DotBackground {
|
||||
@ViewChild('canvas') canvasRef!: ElementRef<HTMLCanvasElement>;
|
||||
|
||||
private readonly document = inject(DOCUMENT);
|
||||
private readonly ngZone = inject(NgZone);
|
||||
private readonly coarsePointer = prefersCoarsePointer();
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly isBrowser = isBrowserPlatform();
|
||||
private readonly coarsePointer = prefersCoarsePointer();
|
||||
private readonly reducedMotion = prefersReducedMotion();
|
||||
|
||||
private ctx: CanvasRenderingContext2D | undefined;
|
||||
private dots: Dot[] = [];
|
||||
private mouse = { x: -1000, y: -1000 };
|
||||
private animationId = 0;
|
||||
private initialized = false;
|
||||
private resizeFrameId = 0;
|
||||
private loopActive = false;
|
||||
private tornDown = false;
|
||||
private readonly teardowns: Array<() => void> = [];
|
||||
|
||||
private readonly INIT_DOT_COUNT = 12;
|
||||
private readonly MIN_DOT_COUNT = 8;
|
||||
private readonly MAX_DOT_COUNT = 100;
|
||||
private readonly MAX_DOT_COUNT_MOBILE = 40;
|
||||
private readonly COLORS = ['#6366f1', '#8b5cf6', '#a855f7', '#3b82f6'];
|
||||
@@ -39,27 +53,34 @@ export class DotBackground implements OnDestroy {
|
||||
private ballSpawnNextColor = 0;
|
||||
|
||||
constructor() {
|
||||
this.destroyRef.onDestroy(() => this.teardown());
|
||||
|
||||
afterNextRender(() => {
|
||||
this.init();
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
if (!this.initialized) {
|
||||
private view(): Window | null {
|
||||
return this.document.defaultView;
|
||||
}
|
||||
|
||||
private init(): void {
|
||||
if (this.tornDown || !this.isBrowser) {
|
||||
return;
|
||||
}
|
||||
|
||||
cancelAnimationFrame(this.animationId);
|
||||
const view = this.view();
|
||||
|
||||
if (this.isBrowser) {
|
||||
window.removeEventListener('resize', this.resize);
|
||||
window.removeEventListener('mousemove', this.onMouseMove);
|
||||
window.removeEventListener('click', this.onMouseClick);
|
||||
if (!view) {
|
||||
return;
|
||||
}
|
||||
|
||||
const canvas = this.canvasRef?.nativeElement;
|
||||
|
||||
if (!canvas) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private init() {
|
||||
const canvas = this.canvasRef.nativeElement;
|
||||
let ctx: CanvasRenderingContext2D | null = null;
|
||||
|
||||
try {
|
||||
@@ -76,21 +97,113 @@ export class DotBackground implements OnDestroy {
|
||||
this.resize();
|
||||
this.initDots();
|
||||
|
||||
window.addEventListener('resize', this.resize);
|
||||
window.addEventListener('mousemove', this.onMouseMove);
|
||||
window.addEventListener('click', this.onMouseClick);
|
||||
this.listen(view, 'resize', this.onResize);
|
||||
this.listen(this.document, 'visibilitychange', this.onVisibilityChange);
|
||||
|
||||
this.initialized = true;
|
||||
this.ngZone.runOutsideAngular(() => this.animate());
|
||||
if (!this.reducedMotion && !this.coarsePointer) {
|
||||
this.listen(view, 'mousemove', this.onMouseMove);
|
||||
this.listen(view, 'click', this.onMouseClick);
|
||||
}
|
||||
|
||||
if (this.reducedMotion) {
|
||||
this.drawFrame();
|
||||
return;
|
||||
}
|
||||
|
||||
this.ngZone.runOutsideAngular(() => this.startLoop());
|
||||
}
|
||||
|
||||
private resize = () => {
|
||||
const canvas = this.canvasRef.nativeElement;
|
||||
const width = window.innerWidth;
|
||||
const height = window.innerHeight;
|
||||
private listen(target: EventTarget, type: string, handler: EventListener): void {
|
||||
target.addEventListener(type, handler);
|
||||
this.teardowns.push(() => target.removeEventListener(type, handler));
|
||||
}
|
||||
|
||||
const dx = Math.abs(width - canvas.width) / width;
|
||||
const dy = Math.abs(height - canvas.height) / height;
|
||||
private requestFrame(callback: FrameRequestCallback): number {
|
||||
const view = this.view();
|
||||
return view ? view.requestAnimationFrame(callback) : 0;
|
||||
}
|
||||
|
||||
private cancelFrame(id: number): void {
|
||||
const view = this.view();
|
||||
|
||||
if (!view || id === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
view.cancelAnimationFrame(id);
|
||||
}
|
||||
|
||||
private startLoop(): void {
|
||||
if (this.loopActive || this.reducedMotion || this.tornDown) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.loopActive = true;
|
||||
this.scheduleAnimate();
|
||||
}
|
||||
|
||||
private stopLoop(): void {
|
||||
this.loopActive = false;
|
||||
this.cancelFrame(this.animationId);
|
||||
this.animationId = 0;
|
||||
}
|
||||
|
||||
private scheduleAnimate(): void {
|
||||
this.animationId = this.requestFrame(this.animate);
|
||||
}
|
||||
|
||||
private teardown(): void {
|
||||
if (this.tornDown) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.tornDown = true;
|
||||
this.stopLoop();
|
||||
this.cancelFrame(this.resizeFrameId);
|
||||
this.resizeFrameId = 0;
|
||||
|
||||
for (const dispose of this.teardowns) {
|
||||
dispose();
|
||||
}
|
||||
|
||||
this.teardowns.length = 0;
|
||||
}
|
||||
|
||||
private onResize = (): void => {
|
||||
if (this.resizeFrameId !== 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.resizeFrameId = this.requestFrame(() => {
|
||||
this.resizeFrameId = 0;
|
||||
this.resize();
|
||||
});
|
||||
};
|
||||
|
||||
private onVisibilityChange = (): void => {
|
||||
if (this.document.hidden) {
|
||||
this.stopLoop();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.reducedMotion) {
|
||||
this.ngZone.runOutsideAngular(() => this.startLoop());
|
||||
}
|
||||
};
|
||||
|
||||
private resize = (): void => {
|
||||
const view = this.view();
|
||||
const canvas = this.canvasRef?.nativeElement;
|
||||
|
||||
if (!view || !canvas) {
|
||||
return;
|
||||
}
|
||||
|
||||
const width = view.innerWidth;
|
||||
const height = view.innerHeight;
|
||||
|
||||
const dx = width === 0 ? 0 : Math.abs(width - canvas.width) / width;
|
||||
const dy = height === 0 ? 0 : Math.abs(height - canvas.height) / height;
|
||||
|
||||
if (!this.coarsePointer || dy > 0.2 || dx > 0.05) {
|
||||
canvas.width = width;
|
||||
@@ -103,18 +216,35 @@ export class DotBackground implements OnDestroy {
|
||||
}
|
||||
};
|
||||
|
||||
private onMouseMove = (e: MouseEvent) => {
|
||||
const canvas = this.canvasRef.nativeElement;
|
||||
this.mouse.x = (e.clientX / window.innerWidth) * canvas.width;
|
||||
this.mouse.y = (e.clientY / window.innerHeight) * canvas.height;
|
||||
private onMouseMove = (event: Event): void => {
|
||||
if (!(event instanceof MouseEvent)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const view = this.view();
|
||||
const canvas = this.canvasRef?.nativeElement;
|
||||
|
||||
if (!view || !canvas) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.mouse.x = (event.clientX / view.innerWidth) * canvas.width;
|
||||
this.mouse.y = (event.clientY / view.innerHeight) * canvas.height;
|
||||
};
|
||||
|
||||
private onMouseClick = () => {
|
||||
private onMouseClick = (): void => {
|
||||
const dot = this.spawnDot();
|
||||
dot.x = this.mouse.x;
|
||||
dot.y = this.mouse.y;
|
||||
};
|
||||
|
||||
private targetDotCount(width: number, height: number): number {
|
||||
const maxCount = this.coarsePointer ? this.MAX_DOT_COUNT_MOBILE : this.MAX_DOT_COUNT;
|
||||
const area = Math.max(0, width) * Math.max(0, height);
|
||||
const fromArea = Math.round(area / 20_000);
|
||||
return Math.min(maxCount, Math.max(this.MIN_DOT_COUNT, fromArea));
|
||||
}
|
||||
|
||||
private spawnDot(): Dot {
|
||||
const dotId = this.ballSpawnId++;
|
||||
const maxCount = this.coarsePointer ? this.MAX_DOT_COUNT_MOBILE : this.MAX_DOT_COUNT;
|
||||
@@ -140,7 +270,7 @@ export class DotBackground implements OnDestroy {
|
||||
return dot;
|
||||
}
|
||||
|
||||
private populateDot(dot: Dot) {
|
||||
private populateDot(dot: Dot): void {
|
||||
const { width, height } = this.canvasRef.nativeElement;
|
||||
|
||||
dot.x = Math.random() * width;
|
||||
@@ -151,17 +281,29 @@ export class DotBackground implements OnDestroy {
|
||||
dot.color = this.COLORS[this.ballSpawnNextColor++ % this.COLORS.length];
|
||||
}
|
||||
|
||||
private initDots() {
|
||||
for (let i = 0; i < this.INIT_DOT_COUNT; i++) {
|
||||
private initDots(): void {
|
||||
const { width, height } = this.canvasRef.nativeElement;
|
||||
const count = this.targetDotCount(width, height);
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
this.spawnDot();
|
||||
}
|
||||
}
|
||||
|
||||
private animate = () => {
|
||||
const canvas = this.canvasRef.nativeElement;
|
||||
private animate = (): void => {
|
||||
this.animationId = 0;
|
||||
this.drawFrame();
|
||||
|
||||
if (this.loopActive && !this.tornDown) {
|
||||
this.scheduleAnimate();
|
||||
}
|
||||
};
|
||||
|
||||
private drawFrame(): void {
|
||||
const canvas = this.canvasRef?.nativeElement;
|
||||
const ctx = this.ctx;
|
||||
|
||||
if (!ctx) {
|
||||
if (!canvas || !ctx) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -213,7 +355,5 @@ export class DotBackground implements OnDestroy {
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
this.animationId = requestAnimationFrame(this.animate);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user