Path: blob/main/src/vs/sessions/contrib/aquarium/browser/fish.ts
13401 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { VSCODE_LOGO_PATH } from './vscodeLogoPath.js';67/**8* VS Code logo "fish" used by the Agents window aquarium. Each fish is a small9* SVG element styled with `color:` so the silhouette inherits via `currentColor`,10* with animated body strips providing the swimming motion.11*/1213/** The three VS Code release channel colors used as fish "species". */14export const enum FishSpecies {15Stable = 'stable',16Insiders = 'insiders',17Exploration = 'exploration',18}1920const SPECIES_COLOR: Record<FishSpecies, string> = {21[FishSpecies.Stable]: '#007ACC',22[FishSpecies.Insiders]: '#24bfa5',23[FishSpecies.Exploration]: '#E04F00',24};2526/** Pick a random species, weighted Stable > Insiders > Exploration. */27export function pickRandomSpecies(): FishSpecies {28const roll = Math.random();29if (roll < 0.5) {30return FishSpecies.Stable;31}32if (roll < 0.8) {33return FishSpecies.Insiders;34}35return FishSpecies.Exploration;36}3738/**39* Tear down the shared SVG defs container for the given document. Call when40* no fish are active in that document.41*/42export function disposeSharedFishDefs(targetDocument: Document): void {43const container = sharedDefsByDocument.get(targetDocument);44if (container) {45container.remove();46sharedDefsByDocument.delete(targetDocument);47}48}4950export interface IFishOptions {51readonly species: FishSpecies;52readonly size: number;53readonly positionX: number;54readonly positionY: number;55readonly velocityX: number;56readonly velocityY: number;57}5859/**60* A swimming fish. Owns its DOM element and exposes mutable position/velocity61* for the aquarium's RAF loop to update.62*/63export class Fish {6465readonly element: HTMLDivElement;66private readonly innerElement: HTMLDivElement;6768positionX: number;69positionY: number;70velocityX: number;71velocityY: number;72readonly size: number;7374/** Timestamp until which this fish is in "panic" mode (faster, scattering). */75panicUntil = 0;7677/**78* The fish's preferred swim heading in radians. Drifts smoothly each frame79* via a small random delta — much less jittery than randomizing per-axis80* acceleration every frame.81*/82wanderAngle: number;8384/**85* Smoothed facing in [-1, 1] (1 = right, -1 = left). Eased toward86* sign(velocityX) each frame so direction changes look like a turn instead of87* a snap-flip.88*/89private facing = 1;9091constructor(opts: IFishOptions, targetDocument: Document) {92this.positionX = opts.positionX;93this.positionY = opts.positionY;94this.velocityX = opts.velocityX;95this.velocityY = opts.velocityY;96this.size = opts.size;97this.wanderAngle = Math.atan2(opts.velocityY, opts.velocityX);9899this.element = targetDocument.createElement('div');100this.element.className = 'agents-aquarium-fish';101this.element.style.width = `${opts.size}px`;102this.element.style.height = `${opts.size}px`;103this.element.style.color = SPECIES_COLOR[opts.species];104105// Inner element receives the directional flip so the body strip animations106// (driven by --agents-aquarium-strip-index) are unaffected by direction changes.107this.innerElement = targetDocument.createElement('div');108this.innerElement.className = 'agents-aquarium-fish-inner';109this.innerElement.appendChild(buildFishSvg(targetDocument));110this.element.appendChild(this.innerElement);111112this.applyTransform();113}114115/**116* Write the current position/facing to the DOM.117*118* @param deltaSeconds seconds since last frame, used to ease facing toward119* velocity direction. Pass 0 for the initial paint.120*/121applyTransform(deltaSeconds: number = 0): void {122// Translate is on the outer element. Sub-pixel precision (2 decimals)123// avoids visible 0.1 px stepping when fish move slowly.124this.element.style.transform = `translate(${this.positionX.toFixed(2)}px, ${this.positionY.toFixed(2)}px)`;125126// Ease `facing` toward sign(velocityX) so the flip looks like a turn127// instead of an instant mirror. Time-constant ~120 ms (turnRate = 8/s).128const targetFacing = this.velocityX >= 0 ? 1 : -1;129if (deltaSeconds > 0) {130const turnRate = 8;131const easeFactor = 1 - Math.exp(-turnRate * deltaSeconds);132this.facing += (targetFacing - this.facing) * easeFactor;133} else {134this.facing = targetFacing;135}136// scaleX through 0 in the middle of a turn flattens the fish for one137// frame, mimicking a body roll. Floor at 0.05 to avoid zero-width.138const flipScaleX = Math.sign(this.facing) * Math.max(Math.abs(this.facing), 0.05);139this.innerElement.style.transform = `scaleX(${flipScaleX.toFixed(3)})`;140}141}142143const SVG_NS = 'http://www.w3.org/2000/svg';144145/**146* Number of vertical strips the body is sliced into. More strips = smoother147* wave, but each strip is one `<use>` node and one CSS animation per fish.148*/149const NUM_BODY_STRIPS = 8;150151/** The body's bounding range in the original logo's user units. */152const BODY_X_START = 5;153const BODY_X_END = 90;154155/**156* Lazily-built shared SVG element holding both the strip clipPath defs AND157* a single `<symbol>` containing the VS Code logo path. All fish reference158* these via `clip-path: url(#…)` and `<use href="#…">` instead of duplicating159* the path data per strip per fish (which previously caused 50 fish * 10160* strips = 500 path parses on every aquarium activation).161*162* Keyed by `Document` so multi-window scenarios (auxiliary windows) each get163* their own defs in their own document — `<use>` references can't cross164* document boundaries, so a single global would break in any window other165* than the first to activate.166*/167const sharedDefsByDocument = new WeakMap<Document, SVGSVGElement>();168169const SHARED_LOGO_SYMBOL_ID = 'agents-aquarium-fish-logo';170171function ensureSharedDefs(targetDocument: Document): void {172if (sharedDefsByDocument.has(targetDocument)) {173return;174}175const stripWidth = (BODY_X_END - BODY_X_START) / NUM_BODY_STRIPS;176const container = targetDocument.createElementNS(SVG_NS, 'svg');177container.setAttribute('xmlns', SVG_NS);178container.setAttribute('width', '0');179container.setAttribute('height', '0');180container.setAttribute('aria-hidden', 'true');181container.style.position = 'absolute';182container.style.width = '0';183container.style.height = '0';184container.style.overflow = 'hidden';185container.style.pointerEvents = 'none';186187// All strips reference this symbol via `<use href="#agents-aquarium-fish-logo">`,188// so the path data is parsed exactly ONCE per session instead of FISH_COUNT * NUM_STRIPS.189container.appendChild(createVSCodeLogoSymbol(targetDocument));190191const defs = targetDocument.createElementNS(SVG_NS, 'defs');192for (let i = 0; i < NUM_BODY_STRIPS; i++) {193const clip = targetDocument.createElementNS(SVG_NS, 'clipPath');194clip.setAttribute('id', `agents-aquarium-fish-clip-${i}`);195clip.setAttribute('clipPathUnits', 'userSpaceOnUse');196const rect = targetDocument.createElementNS(SVG_NS, 'rect');197rect.setAttribute('x', String(BODY_X_START + i * stripWidth));198rect.setAttribute('y', '-20');199// Larger overlap (0.8 user-units) hides seams when adjacent strips200// are at slightly different translateY values.201rect.setAttribute('width', String(stripWidth + 0.8));202rect.setAttribute('height', '136');203clip.appendChild(rect);204defs.appendChild(clip);205}206container.appendChild(defs);207targetDocument.body.appendChild(container);208sharedDefsByDocument.set(targetDocument, container);209}210211function createVSCodeLogoSymbol(targetDocument: Document): SVGSymbolElement {212const symbol = targetDocument.createElementNS(SVG_NS, 'symbol');213symbol.setAttribute('id', SHARED_LOGO_SYMBOL_ID);214symbol.setAttribute('viewBox', '0 0 96 96');215symbol.setAttribute('overflow', 'visible');216217const logoPath = targetDocument.createElementNS(SVG_NS, 'path');218logoPath.setAttribute('d', VSCODE_LOGO_PATH);219logoPath.setAttribute('fill', 'currentColor');220logoPath.setAttribute('fill-rule', 'evenodd');221symbol.appendChild(logoPath);222223return symbol;224}225226/**227* Build the inline SVG element tree for a fish:228* - VS Code logo body, sliced into N vertical strips that each oscillate in229* Y with a phase-offset CSS animation (the "swimming" sine wave)230*231* Colors come from `currentColor` on the parent element. Built with232* `document.createElementNS` (no innerHTML) to satisfy Trusted Types.233*234* The strip clipPath defs and the logo symbol are shared across all fish via235* {@link ensureSharedDefs}.236*/237function buildFishSvg(targetDocument: Document): SVGSVGElement {238ensureSharedDefs(targetDocument);239240const svg = targetDocument.createElementNS(SVG_NS, 'svg');241svg.setAttribute('xmlns', SVG_NS);242svg.setAttribute('focusable', 'false');243// viewBox 0..96 matches the original VS Code icon.244svg.setAttribute('viewBox', '0 0 96 96');245svg.setAttribute('preserveAspectRatio', 'xMidYMid meet');246// Tell the rasterizer to optimize for visual quality, not speed: smoother247// edges on the (potentially upscaled) logo paths.248svg.setAttribute('shape-rendering', 'geometricPrecision');249250// Body: NUM_BODY_STRIPS overlapping references to the shared logo symbol,251// each clipped to its vertical band via shared clipPath defs. Each strip252// animates translateY with a phase offset driven by --agents-aquarium-strip-index.253const bodyGroup = targetDocument.createElementNS(SVG_NS, 'g');254bodyGroup.setAttribute('class', 'agents-aquarium-fish-body');255for (let i = 0; i < NUM_BODY_STRIPS; i++) {256const stripG = targetDocument.createElementNS(SVG_NS, 'g');257stripG.setAttribute('class', 'agents-aquarium-fish-strip');258stripG.style.setProperty('--agents-aquarium-strip-index', String(i));259const stripUse = targetDocument.createElementNS(SVG_NS, 'use');260stripUse.setAttribute('href', `#${SHARED_LOGO_SYMBOL_ID}`);261stripUse.setAttribute('clip-path', `url(#agents-aquarium-fish-clip-${i})`);262stripG.appendChild(stripUse);263bodyGroup.appendChild(stripG);264}265svg.appendChild(bodyGroup);266267return svg;268}269270271