Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/sessions/contrib/aquarium/browser/fish.ts
13401 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import { VSCODE_LOGO_PATH } from './vscodeLogoPath.js';
7
8
/**
9
* VS Code logo "fish" used by the Agents window aquarium. Each fish is a small
10
* SVG element styled with `color:` so the silhouette inherits via `currentColor`,
11
* with animated body strips providing the swimming motion.
12
*/
13
14
/** The three VS Code release channel colors used as fish "species". */
15
export const enum FishSpecies {
16
Stable = 'stable',
17
Insiders = 'insiders',
18
Exploration = 'exploration',
19
}
20
21
const SPECIES_COLOR: Record<FishSpecies, string> = {
22
[FishSpecies.Stable]: '#007ACC',
23
[FishSpecies.Insiders]: '#24bfa5',
24
[FishSpecies.Exploration]: '#E04F00',
25
};
26
27
/** Pick a random species, weighted Stable > Insiders > Exploration. */
28
export function pickRandomSpecies(): FishSpecies {
29
const roll = Math.random();
30
if (roll < 0.5) {
31
return FishSpecies.Stable;
32
}
33
if (roll < 0.8) {
34
return FishSpecies.Insiders;
35
}
36
return FishSpecies.Exploration;
37
}
38
39
/**
40
* Tear down the shared SVG defs container for the given document. Call when
41
* no fish are active in that document.
42
*/
43
export function disposeSharedFishDefs(targetDocument: Document): void {
44
const container = sharedDefsByDocument.get(targetDocument);
45
if (container) {
46
container.remove();
47
sharedDefsByDocument.delete(targetDocument);
48
}
49
}
50
51
export interface IFishOptions {
52
readonly species: FishSpecies;
53
readonly size: number;
54
readonly positionX: number;
55
readonly positionY: number;
56
readonly velocityX: number;
57
readonly velocityY: number;
58
}
59
60
/**
61
* A swimming fish. Owns its DOM element and exposes mutable position/velocity
62
* for the aquarium's RAF loop to update.
63
*/
64
export class Fish {
65
66
readonly element: HTMLDivElement;
67
private readonly innerElement: HTMLDivElement;
68
69
positionX: number;
70
positionY: number;
71
velocityX: number;
72
velocityY: number;
73
readonly size: number;
74
75
/** Timestamp until which this fish is in "panic" mode (faster, scattering). */
76
panicUntil = 0;
77
78
/**
79
* The fish's preferred swim heading in radians. Drifts smoothly each frame
80
* via a small random delta — much less jittery than randomizing per-axis
81
* acceleration every frame.
82
*/
83
wanderAngle: number;
84
85
/**
86
* Smoothed facing in [-1, 1] (1 = right, -1 = left). Eased toward
87
* sign(velocityX) each frame so direction changes look like a turn instead of
88
* a snap-flip.
89
*/
90
private facing = 1;
91
92
constructor(opts: IFishOptions, targetDocument: Document) {
93
this.positionX = opts.positionX;
94
this.positionY = opts.positionY;
95
this.velocityX = opts.velocityX;
96
this.velocityY = opts.velocityY;
97
this.size = opts.size;
98
this.wanderAngle = Math.atan2(opts.velocityY, opts.velocityX);
99
100
this.element = targetDocument.createElement('div');
101
this.element.className = 'agents-aquarium-fish';
102
this.element.style.width = `${opts.size}px`;
103
this.element.style.height = `${opts.size}px`;
104
this.element.style.color = SPECIES_COLOR[opts.species];
105
106
// Inner element receives the directional flip so the body strip animations
107
// (driven by --agents-aquarium-strip-index) are unaffected by direction changes.
108
this.innerElement = targetDocument.createElement('div');
109
this.innerElement.className = 'agents-aquarium-fish-inner';
110
this.innerElement.appendChild(buildFishSvg(targetDocument));
111
this.element.appendChild(this.innerElement);
112
113
this.applyTransform();
114
}
115
116
/**
117
* Write the current position/facing to the DOM.
118
*
119
* @param deltaSeconds seconds since last frame, used to ease facing toward
120
* velocity direction. Pass 0 for the initial paint.
121
*/
122
applyTransform(deltaSeconds: number = 0): void {
123
// Translate is on the outer element. Sub-pixel precision (2 decimals)
124
// avoids visible 0.1 px stepping when fish move slowly.
125
this.element.style.transform = `translate(${this.positionX.toFixed(2)}px, ${this.positionY.toFixed(2)}px)`;
126
127
// Ease `facing` toward sign(velocityX) so the flip looks like a turn
128
// instead of an instant mirror. Time-constant ~120 ms (turnRate = 8/s).
129
const targetFacing = this.velocityX >= 0 ? 1 : -1;
130
if (deltaSeconds > 0) {
131
const turnRate = 8;
132
const easeFactor = 1 - Math.exp(-turnRate * deltaSeconds);
133
this.facing += (targetFacing - this.facing) * easeFactor;
134
} else {
135
this.facing = targetFacing;
136
}
137
// scaleX through 0 in the middle of a turn flattens the fish for one
138
// frame, mimicking a body roll. Floor at 0.05 to avoid zero-width.
139
const flipScaleX = Math.sign(this.facing) * Math.max(Math.abs(this.facing), 0.05);
140
this.innerElement.style.transform = `scaleX(${flipScaleX.toFixed(3)})`;
141
}
142
}
143
144
const SVG_NS = 'http://www.w3.org/2000/svg';
145
146
/**
147
* Number of vertical strips the body is sliced into. More strips = smoother
148
* wave, but each strip is one `<use>` node and one CSS animation per fish.
149
*/
150
const NUM_BODY_STRIPS = 8;
151
152
/** The body's bounding range in the original logo's user units. */
153
const BODY_X_START = 5;
154
const BODY_X_END = 90;
155
156
/**
157
* Lazily-built shared SVG element holding both the strip clipPath defs AND
158
* a single `<symbol>` containing the VS Code logo path. All fish reference
159
* these via `clip-path: url(#…)` and `<use href="#…">` instead of duplicating
160
* the path data per strip per fish (which previously caused 50 fish * 10
161
* strips = 500 path parses on every aquarium activation).
162
*
163
* Keyed by `Document` so multi-window scenarios (auxiliary windows) each get
164
* their own defs in their own document — `<use>` references can't cross
165
* document boundaries, so a single global would break in any window other
166
* than the first to activate.
167
*/
168
const sharedDefsByDocument = new WeakMap<Document, SVGSVGElement>();
169
170
const SHARED_LOGO_SYMBOL_ID = 'agents-aquarium-fish-logo';
171
172
function ensureSharedDefs(targetDocument: Document): void {
173
if (sharedDefsByDocument.has(targetDocument)) {
174
return;
175
}
176
const stripWidth = (BODY_X_END - BODY_X_START) / NUM_BODY_STRIPS;
177
const container = targetDocument.createElementNS(SVG_NS, 'svg');
178
container.setAttribute('xmlns', SVG_NS);
179
container.setAttribute('width', '0');
180
container.setAttribute('height', '0');
181
container.setAttribute('aria-hidden', 'true');
182
container.style.position = 'absolute';
183
container.style.width = '0';
184
container.style.height = '0';
185
container.style.overflow = 'hidden';
186
container.style.pointerEvents = 'none';
187
188
// All strips reference this symbol via `<use href="#agents-aquarium-fish-logo">`,
189
// so the path data is parsed exactly ONCE per session instead of FISH_COUNT * NUM_STRIPS.
190
container.appendChild(createVSCodeLogoSymbol(targetDocument));
191
192
const defs = targetDocument.createElementNS(SVG_NS, 'defs');
193
for (let i = 0; i < NUM_BODY_STRIPS; i++) {
194
const clip = targetDocument.createElementNS(SVG_NS, 'clipPath');
195
clip.setAttribute('id', `agents-aquarium-fish-clip-${i}`);
196
clip.setAttribute('clipPathUnits', 'userSpaceOnUse');
197
const rect = targetDocument.createElementNS(SVG_NS, 'rect');
198
rect.setAttribute('x', String(BODY_X_START + i * stripWidth));
199
rect.setAttribute('y', '-20');
200
// Larger overlap (0.8 user-units) hides seams when adjacent strips
201
// are at slightly different translateY values.
202
rect.setAttribute('width', String(stripWidth + 0.8));
203
rect.setAttribute('height', '136');
204
clip.appendChild(rect);
205
defs.appendChild(clip);
206
}
207
container.appendChild(defs);
208
targetDocument.body.appendChild(container);
209
sharedDefsByDocument.set(targetDocument, container);
210
}
211
212
function createVSCodeLogoSymbol(targetDocument: Document): SVGSymbolElement {
213
const symbol = targetDocument.createElementNS(SVG_NS, 'symbol');
214
symbol.setAttribute('id', SHARED_LOGO_SYMBOL_ID);
215
symbol.setAttribute('viewBox', '0 0 96 96');
216
symbol.setAttribute('overflow', 'visible');
217
218
const logoPath = targetDocument.createElementNS(SVG_NS, 'path');
219
logoPath.setAttribute('d', VSCODE_LOGO_PATH);
220
logoPath.setAttribute('fill', 'currentColor');
221
logoPath.setAttribute('fill-rule', 'evenodd');
222
symbol.appendChild(logoPath);
223
224
return symbol;
225
}
226
227
/**
228
* Build the inline SVG element tree for a fish:
229
* - VS Code logo body, sliced into N vertical strips that each oscillate in
230
* Y with a phase-offset CSS animation (the "swimming" sine wave)
231
*
232
* Colors come from `currentColor` on the parent element. Built with
233
* `document.createElementNS` (no innerHTML) to satisfy Trusted Types.
234
*
235
* The strip clipPath defs and the logo symbol are shared across all fish via
236
* {@link ensureSharedDefs}.
237
*/
238
function buildFishSvg(targetDocument: Document): SVGSVGElement {
239
ensureSharedDefs(targetDocument);
240
241
const svg = targetDocument.createElementNS(SVG_NS, 'svg');
242
svg.setAttribute('xmlns', SVG_NS);
243
svg.setAttribute('focusable', 'false');
244
// viewBox 0..96 matches the original VS Code icon.
245
svg.setAttribute('viewBox', '0 0 96 96');
246
svg.setAttribute('preserveAspectRatio', 'xMidYMid meet');
247
// Tell the rasterizer to optimize for visual quality, not speed: smoother
248
// edges on the (potentially upscaled) logo paths.
249
svg.setAttribute('shape-rendering', 'geometricPrecision');
250
251
// Body: NUM_BODY_STRIPS overlapping references to the shared logo symbol,
252
// each clipped to its vertical band via shared clipPath defs. Each strip
253
// animates translateY with a phase offset driven by --agents-aquarium-strip-index.
254
const bodyGroup = targetDocument.createElementNS(SVG_NS, 'g');
255
bodyGroup.setAttribute('class', 'agents-aquarium-fish-body');
256
for (let i = 0; i < NUM_BODY_STRIPS; i++) {
257
const stripG = targetDocument.createElementNS(SVG_NS, 'g');
258
stripG.setAttribute('class', 'agents-aquarium-fish-strip');
259
stripG.style.setProperty('--agents-aquarium-strip-index', String(i));
260
const stripUse = targetDocument.createElementNS(SVG_NS, 'use');
261
stripUse.setAttribute('href', `#${SHARED_LOGO_SYMBOL_ID}`);
262
stripUse.setAttribute('clip-path', `url(#agents-aquarium-fish-clip-${i})`);
263
stripG.appendChild(stripUse);
264
bodyGroup.appendChild(stripG);
265
}
266
svg.appendChild(bodyGroup);
267
268
return svg;
269
}
270
271