import FractalRenderer from "./fractalRenderer";
import {asyncDelay, ddSubDD, hexToRGBArray, lerp, normalizeRotation, splitFloat} from "../global/utils";
import {CONSOLE_GROUP_STYLE, EASE_TYPE, log, PI} from "../global/constants";
import presetsData from '../data/mandelbrot.json';
/** @type {string} */
import fragmentShaderRaw from '../shaders/mandelbrot.frag';
/**
* MandelbrotRenderer (Rebased Perturbation)
*
* @author Radim Brnka
* @description This module defines a MandelbrotRenderer class that inherits from fractalRenderer, implements the shader fragment code for the Mandelbrot set fractal and sets preset zoom-ins.
* @extends FractalRenderer
* @copyright Synaptory Fractal Traveler, 2025-2026
* @license MIT
*/
class MandelbrotRenderer extends FractalRenderer {
constructor(canvas) {
super(canvas);
this.DEFAULT_PAN = [-0.5, 0];
this.setPan(this.DEFAULT_PAN[0], this.DEFAULT_PAN[1]); // sync DD + array
// Reference state with DD precision
this.refPan = [...this.DEFAULT_PAN];
this.refPanDD = {
x: { hi: this.DEFAULT_PAN[0], lo: 0 },
y: { hi: this.DEFAULT_PAN[1], lo: 0 }
};
this.orbitDirty = true;
/** IMPORTANT: MAX_ITER must remain constant after shader compilation + orbit buffer allocation. */
this.MAX_ITER = 5000;
// Reference search parameters (for perturbation rebasing)
this.REF_SEARCH_GRID = 7;
this.REF_SEARCH_RADIUS = 0.50;
// Hysteresis state for rebasing
this.rebaseArmed = true;
// WebGL resources
this.orbitTex = null;
this.orbitData = null;
this.floatTexExt = null;
/** Mandelbrot-specific presets */
this.PRESETS = presetsData.views;
/**
* @type {Array.<PALETTE>}
* @description Palettes loaded from JSON (empty = random only)
*/
this.PALETTES = presetsData.palettes || [];
this.currentPaletteIndex = 0; // Start with Default palette
// Default color parameters matching original hardcoded values
this.DEFAULT_FREQUENCY = [3.1415, 6.2830, 1.7200];
this.DEFAULT_PHASE = [0, 0, 0];
this.frequency = [...this.DEFAULT_FREQUENCY];
this.phase = [...this.DEFAULT_PHASE];
this.init();
}
markOrbitDirty = () => this.orbitDirty = true;
/**
* Called by FractalRenderer.initGLProgram() after program creation + common uniform cache.
* Creates float texture, allocates orbit buffer, and binds texture unit.
*/
onProgramCreated() {
this.floatTexExt = this.gl.getExtension("OES_texture_float");
if (!this.floatTexExt) {
console.error('Missing OES_texture_float. Perturbation orbit texture upload requires it.');
return;
}
this.orbitTex = this.gl.createTexture();
this.gl.activeTexture(this.gl.TEXTURE0);
this.gl.bindTexture(this.gl.TEXTURE_2D, this.orbitTex);
this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_MIN_FILTER, this.gl.NEAREST);
this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_MAG_FILTER, this.gl.NEAREST);
this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_WRAP_S, this.gl.CLAMP_TO_EDGE);
this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_WRAP_T, this.gl.CLAMP_TO_EDGE);
this.orbitData = new Float32Array(this.MAX_ITER * 4);
// Shader expects orbit sampler in unit 0
if (this.orbitTexLoc) this.gl.uniform1i(this.orbitTexLoc, 0);
if (this.orbitWLoc) this.gl.uniform1f(this.orbitWLoc, this.MAX_ITER);
this.orbitDirty = true;
}
createFragmentShaderSource() {
return fragmentShaderRaw.replace('__MAX_ITER__', this.MAX_ITER).toString();
}
updateUniforms() {
super.updateUniforms();
// delta pan hi/lo (viewPan - refPan, computed on JS side for precision)
this.deltaPanHLoc = this.gl.getUniformLocation(this.program, 'u_delta_pan_h');
this.deltaPanLLoc = this.gl.getUniformLocation(this.program, 'u_delta_pan_l');
// zoom hi/lo
this.zoomHLoc = this.gl.getUniformLocation(this.program, 'u_zoom_h');
this.zoomLLoc = this.gl.getUniformLocation(this.program, 'u_zoom_l');
// orbit texture uniforms
this.orbitTexLoc = this.gl.getUniformLocation(this.program, 'u_orbitTex');
this.orbitWLoc = this.gl.getUniformLocation(this.program, 'u_orbitW');
// color parameters
this.frequencyLoc = this.gl.getUniformLocation(this.program, 'u_frequency');
this.phaseLoc = this.gl.getUniformLocation(this.program, 'u_phase');
}
/** Reference picking + orbit build */
escapeItersDouble(cx, cy, iters) {
let zx = 0.0, zy = 0.0;
for (let i = 0; i < iters; i++) {
const zx2 = zx * zx - zy * zy + cx;
const zy2 = 2.0 * zx * zy + cy;
zx = zx2;
zy = zy2;
if (zx * zx + zy * zy > 4.0) return i;
}
return iters;
}
/**
* Choose a good refPan near view center:
* - sample a grid around view center (radius scales with zoom)
* - pick the point with the highest escape iteration (prefer inside / late escape)
* - prefer keeping the current reference to avoid jitter unless a significantly better one is found
* @param {boolean} useViewCenter - If true, skip grid search and use view center directly (for stability during interaction)
*/
pickReferenceNearViewCenter(useViewCenter = false) {
// During interaction, use view center directly to prevent jitter from grid search
// Copy DD components to preserve deep zoom precision
if (useViewCenter) {
this.refPan[0] = this.pan[0];
this.refPan[1] = this.pan[1];
this.refPanDD.x.hi = this.panDD.x.hi;
this.refPanDD.x.lo = this.panDD.x.lo;
this.refPanDD.y.hi = this.panDD.y.hi;
this.refPanDD.y.lo = this.panDD.y.lo;
return;
}
const grid = this.REF_SEARCH_GRID;
const half = (grid - 1) / 2;
const step = (2.0 * this.REF_SEARCH_RADIUS) / (grid - 1);
const base = this.zoom; // scale offsets by current zoom
const probeIters = Math.min(this.MAX_ITER, Math.max(200, Math.floor(this.iterations)));
// Evaluate current reference point's score (if we have one within reasonable distance)
let currentRefScore = -1;
const currentRefDist = Math.hypot(this.refPan[0] - this.pan[0], this.refPan[1] - this.pan[1]);
const maxRefDist = base * this.REF_SEARCH_RADIUS * 2.5; // Allow some margin beyond search radius
if (currentRefDist < maxRefDist) {
currentRefScore = this.escapeItersDouble(this.refPan[0], this.refPan[1], probeIters);
}
// Start with view center as fallback
let bestCx = this.pan[0];
let bestCy = this.pan[1];
let bestScore = this.escapeItersDouble(this.pan[0], this.pan[1], probeIters);
for (let j = 0; j < grid; j++) {
for (let i = 0; i < grid; i++) {
const ox = (i - half) * step;
const oy = (j - half) * step;
const cx = this.pan[0] + ox * base;
const cy = this.pan[1] + oy * base;
const score = this.escapeItersDouble(cx, cy, probeIters);
if (score > bestScore) {
bestScore = score;
bestCx = cx;
bestCy = cy;
}
}
}
// Stability: only switch reference if the new one is significantly better.
// This prevents jitter from small score differences between frames.
// Require at least 10% improvement or 50 iterations better to switch.
const improvementThreshold = Math.max(currentRefScore * 0.10, 50);
const shouldSwitch = currentRefScore < 0 || (bestScore - currentRefScore) > improvementThreshold;
if (shouldSwitch) {
this.refPan[0] = bestCx;
this.refPan[1] = bestCy;
// New reference point from grid search - no DD precision accumulated yet
this.refPanDD.x.hi = bestCx;
this.refPanDD.x.lo = 0;
this.refPanDD.y.hi = bestCy;
this.refPanDD.y.lo = 0;
}
// else: keep current refPan for stability
}
computeReferenceOrbit() {
if (!this.orbitData || !this.orbitTex) return;
const cx = this.refPan[0];
const cy = this.refPan[1];
let zx = 0.0, zy = 0.0;
for (let n = 0; n < this.MAX_ITER; n++) {
const sx = splitFloat(zx);
const sy = splitFloat(zy);
const idx = n * 4;
this.orbitData[idx] = sx.high;
this.orbitData[idx + 1] = sx.low;
this.orbitData[idx + 2] = sy.high;
this.orbitData[idx + 3] = sy.low;
const zx2 = zx * zx - zy * zy + cx;
const zy2 = 2.0 * zx * zy + cy;
zx = zx2;
zy = zy2;
if (zx * zx + zy * zy > 4.0) {
// Fill remainder with last value (keeps texture defined)
const fx = splitFloat(zx);
const fy = splitFloat(zy);
for (let k = n + 1; k < this.MAX_ITER; k++) {
const j = k * 4;
this.orbitData[j] = fx.high;
this.orbitData[j + 1] = fx.low;
this.orbitData[j + 2] = fy.high;
this.orbitData[j + 3] = fy.low;
}
break;
}
}
this.gl.activeTexture(this.gl.TEXTURE0);
this.gl.bindTexture(this.gl.TEXTURE_2D, this.orbitTex);
// Upload 1D orbit texture: width=MAX_ITER, height=1
this.gl.texImage2D(
this.gl.TEXTURE_2D,
0,
this.gl.RGBA,
this.MAX_ITER,
1,
0,
this.gl.RGBA,
this.gl.FLOAT,
this.orbitData
);
if (this.orbitTexLoc) this.gl.uniform1i(this.orbitTexLoc, 0);
if (this.orbitWLoc) this.gl.uniform1f(this.orbitWLoc, this.MAX_ITER);
}
/**
* @inheritDoc
* @override
*/
draw() {
this.gl.useProgram(this.program);
// Iteration strategy avoids exploding to infinity at tiny zooms
// TODO Tune! main point is: clamp to MAX_ITER.
const safe = Math.max(this.zoom, 1e-300);
const log2Depth = Math.log2(this.DEFAULT_ZOOM / safe);
const baseIters = Math.floor(200 + 50 * log2Depth);
this.iterations = Math.max(50, Math.min(this.MAX_ITER, baseIters + this.extraIterations));
const mustRebaseNow = this.needsRebase();
const canRebaseNow = !this.interactionActive || mustRebaseNow;
if (this.orbitDirty && canRebaseNow) {
this.pickReferenceNearViewCenter();
this.computeReferenceOrbit();
this.orbitDirty = false;
}
// Compute deltaPan = panDD - refPanDD on JS side (float64) for precision
// This avoids float32 precision loss when the shader subtracts viewPan - refPan
const deltaPanX = ddSubDD(this.panDD.x, this.refPanDD.x);
const deltaPanY = ddSubDD(this.panDD.y, this.refPanDD.y);
// Upload deltaPan hi/lo
if (this.deltaPanHLoc) this.gl.uniform2f(this.deltaPanHLoc, deltaPanX.hi, deltaPanY.hi);
if (this.deltaPanLLoc) this.gl.uniform2f(this.deltaPanLLoc, deltaPanX.lo, deltaPanY.lo);
// Upload zoom hi/lo
const z = splitFloat(this.zoom);
if (this.zoomHLoc) this.gl.uniform1f(this.zoomHLoc, z.high);
if (this.zoomLLoc) this.gl.uniform1f(this.zoomLLoc, z.low);
// Upload color parameters
if (this.frequencyLoc) this.gl.uniform3fv(this.frequencyLoc, this.frequency);
if (this.phaseLoc) this.gl.uniform3fv(this.phaseLoc, this.phase);
super.draw();
}
needsRebase() {
const dx = this.pan[0] - this.refPan[0];
const dy = this.pan[1] - this.refPan[1];
// Rebase threshold is proportional to zoom (view scale).
// 0.75 is consistent with the Julia policy.
return Math.hypot(dx, dy) > this.zoom * 0.75;
}
/**
* Resets renderer to default state including frequency and phase.
* @override
*/
reset() {
this.frequency = [...this.DEFAULT_FREQUENCY];
this.phase = [...this.DEFAULT_PHASE];
this.currentPaletteIndex = 0;
this.orbitDirty = true;
super.reset();
}
// region > ANIMATION METHODS
/**
* Applies a specific palette by index.
* @param {number} index - Palette index (-1 for random)
* @param {number} [duration=250] - Transition duration in ms
* @param {Function} [coloringCallback] - Callback when done
* @return {Promise<void>}
*/
async applyPaletteByIndex(index, duration = 250, coloringCallback = null) {
this.currentPaletteIndex = index;
if (index >= 0 && index < this.PALETTES.length) {
const palette = this.PALETTES[index];
// Wrap callback to use keyColor for UI instead of theme
const wrappedCallback = coloringCallback && palette.keyColor
? () => coloringCallback(hexToRGBArray(palette.keyColor, 255))
: coloringCallback;
await this.animateColorPaletteTransition({
theme: palette.theme,
frequency: palette.frequency || this.DEFAULT_FREQUENCY,
phase: palette.phase || this.DEFAULT_PHASE
}, duration, wrappedCallback);
} else {
// Random palette (index -1 or out of range)
await this.animateColorPaletteTransition(null, duration, coloringCallback);
}
}
/**
* Generates a random palette with theme, frequency, and phase.
* Creates visually distinct palettes by varying all three parameters.
* @returns {{theme: number[], frequency: number[], phase: number[]}}
*/
generateRandomPalette() {
// Random hue with high saturation for theme
const hue = Math.random();
const angle = hue * Math.PI * 2;
// Create base color from hue (simplified HSV to RGB at full saturation)
const r = Math.max(0, Math.cos(angle));
const g = Math.max(0, Math.cos(angle - Math.PI * 2 / 3));
const b = Math.max(0, Math.cos(angle + Math.PI * 2 / 3));
// Scale to multiplier range (0.8 - 2.2) with some base brightness
const base = 0.8 + Math.random() * 0.4; // 0.8-1.2
const scale = 1.0 + Math.random() * 0.8; // 1.0-1.8
const theme = [
base + r * scale,
base + g * scale,
base + b * scale,
];
// Generate random frequency - controls how fast colors cycle
// Values 1-10 give nice variety without being too chaotic
const frequency = [
1.0 + Math.random() * 9.0,
1.0 + Math.random() * 9.0,
1.0 + Math.random() * 9.0,
];
// Generate random phase - shifts the color bands
// Values 0 to 2*PI cover full phase range
const phase = [
Math.random() * Math.PI * 2,
Math.random() * Math.PI * 2,
Math.random() * Math.PI * 2,
];
return { theme, frequency, phase };
}
/**
* Animates transition to a new palette (theme, frequency, phase).
* Accepts either:
* - Object: {theme: [...], frequency: [...], phase: [...]}
* - Array (legacy): [r, g, b] - uses current frequency/phase
* - null: generates random palette
* @param {{theme: number[], frequency: number[], phase: number[]}|number[]|null} newPalette
* @param {number} duration
* @param {Function|null} coloringCallback
*/
async animateColorPaletteTransition(newPalette, duration = 250, coloringCallback = null) {
// Normalize input to object format
if (!newPalette) {
newPalette = this.generateRandomPalette();
} else if (Array.isArray(newPalette)) {
// Legacy array format - just theme, keep current frequency/phase
newPalette = {
theme: newPalette,
frequency: [...this.frequency],
phase: [...this.phase]
};
}
this.stopCurrentColorAnimations();
const startTheme = [...this.colorPalette];
const startFrequency = [...this.frequency];
const startPhase = [...this.phase];
const targetTheme = newPalette.theme;
const targetFrequency = newPalette.frequency || this.DEFAULT_FREQUENCY;
const targetPhase = newPalette.phase || this.DEFAULT_PHASE;
await new Promise((resolve) => {
// Store resolve so stopCurrentColorAnimations can call it if interrupted
this._colorAnimationResolve = resolve;
let startTime = null;
const step = (timestamp) => {
if (!startTime) startTime = timestamp;
const progress = Math.min((timestamp - startTime) / duration, 1);
// Lerp theme (colorPalette)
this.colorPalette = [
lerp(startTheme[0], targetTheme[0], progress),
lerp(startTheme[1], targetTheme[1], progress),
lerp(startTheme[2], targetTheme[2], progress),
];
// Lerp frequency
this.frequency = [
lerp(startFrequency[0], targetFrequency[0], progress),
lerp(startFrequency[1], targetFrequency[1], progress),
lerp(startFrequency[2], targetFrequency[2], progress),
];
// Lerp phase
this.phase = [
lerp(startPhase[0], targetPhase[0], progress),
lerp(startPhase[1], targetPhase[1], progress),
lerp(startPhase[2], targetPhase[2], progress),
];
this.draw();
if (coloringCallback) coloringCallback();
if (progress < 1) {
this.currentColorAnimationFrame = requestAnimationFrame(step);
} else {
this._colorAnimationResolve = null;
this.stopCurrentColorAnimations();
resolve();
}
};
this.currentColorAnimationFrame = requestAnimationFrame(step);
});
}
/**
* Animates travel to a preset. It first zooms out to the default zoom, then rotates, then animates pan and zoom-in.
* If any of the final params is the same as the target, it won't animate it.
*
* @param {MANDELBROT_PRESET} preset An instance-specific object to define exact spot in the fractal
* @param {number} [zoomOutDuration=2000] Duration (ms) for zoom-out stage
* @param {number} [panDuration=1000] Duration (ms) for pan stage
* @param {number} [zoomInDuration=3500] Duration (ms) for zoom-in stage
* @param {Function} [coloringCallback=null] Optional callback for UI color updates
* @return {Promise<void>}
*/
async animateTravelToPreset(preset, zoomOutDuration = 2000, panDuration = 1000, zoomInDuration = 3500, coloringCallback = null) {
console.groupCollapsed(`%c ${this.constructor.name}: animateTravelToPreset`, CONSOLE_GROUP_STYLE);
log(`Traveling to preset: ${JSON.stringify(preset)}`);
const presetRotation = normalizeRotation(preset.rotation ?? this.DEFAULT_ROTATION);
// Determine how far we are from the preset
// Zoom ratio: how many "zoom levels" away (log scale makes sense for exponential zoom)
const zoomRatio = Math.abs(Math.log(this.zoom / preset.zoom));
const atTargetZoom = zoomRatio < 0.01; // Within ~1% zoom
const nearTargetZoom = zoomRatio < 2.5; // Within ~12x zoom difference
// Pan distance relative to TARGET view size (what matters at the end)
const panDist = Math.hypot(this.pan[0] - preset.pan[0], this.pan[1] - preset.pan[1]);
const targetViewSize = preset.zoom;
const panRatio = panDist / targetViewSize;
const atTargetPan = panRatio < 0.1; // Essentially same position at target zoom
const nearTargetPan = panRatio < 3.0; // Within 3 view-widths at target zoom
// Palette proximity check
let targetPaletteIndex = -1;
let atTargetPalette = true;
if (preset.paletteId) {
targetPaletteIndex = this.PALETTES.findIndex(p => p.id === preset.paletteId);
atTargetPalette = targetPaletteIndex < 0 || targetPaletteIndex === this.currentPaletteIndex;
}
const zoomInDurationWithSpeed = zoomInDuration * (preset.speed ?? 1);
// Readjust duration scales with how far off we are (min 500ms, max panDuration)
const readjustDuration = Math.max(500, Math.min(panDuration, 300 + panRatio * 200 + zoomRatio * 150));
if (atTargetZoom && atTargetPan && atTargetPalette) {
// Already at target - only rotate if needed
await this.animateRotationTo(presetRotation, zoomOutDuration, EASE_TYPE.QUINT);
} else if (atTargetZoom && atTargetPan) {
// At position and zoom, but different palette - rotate with palette transition
const animations = [
this.animateRotationTo(presetRotation, zoomOutDuration, EASE_TYPE.QUINT)
];
if (!atTargetPalette) {
animations.push(this.animatePaletteByIdTransition(preset, zoomOutDuration, coloringCallback));
}
await Promise.all(animations);
} else if (nearTargetZoom && nearTargetPan) {
// Close to preset - smooth readjustment without cinematic animation
log(`Near preset, readjusting (zoomRatio=${zoomRatio.toFixed(2)}, panRatio=${panRatio.toFixed(2)})`);
const animations = [
this.animatePanAndZoomTo(preset.pan, preset.zoom, readjustDuration, EASE_TYPE.CUBIC),
this.animateRotationTo(presetRotation, readjustDuration, EASE_TYPE.CUBIC)
];
if (!atTargetPalette) {
animations.push(this.animatePaletteByIdTransition(preset, readjustDuration, coloringCallback));
}
await Promise.all(animations);
} else if (atTargetZoom) {
// Same zoom but panned far away - pan back with rotation and palette
const animations = [
this.animatePanTo(preset.pan, panDuration, EASE_TYPE.CUBIC),
this.animateRotationTo(presetRotation, panDuration, EASE_TYPE.CUBIC)
];
if (!atTargetPalette) {
animations.push(this.animatePaletteByIdTransition(preset, panDuration, coloringCallback));
}
await Promise.all(animations);
} else if (atTargetPan) {
// Same position, just zoom and rotate with palette
const animations = [
this.animateZoomRotationTo(preset.zoom, presetRotation, readjustDuration, EASE_TYPE.QUINT)
];
if (!atTargetPalette) {
animations.push(this.animatePaletteByIdTransition(preset, readjustDuration, coloringCallback));
}
await Promise.all(animations);
} else {
// Full 3-stage cinematic animation:
// Stage 1: Zoom out to default zoom with random rotation (if significantly zoomed in)
const needsZoomOut = this.zoom < this.DEFAULT_ZOOM * 0.9;
if (needsZoomOut) {
const zoomOutRotation = this.rotation + (Math.random() * PI * 2 - PI);
await this.animateZoomRotationTo(this.DEFAULT_ZOOM, zoomOutRotation, zoomOutDuration, EASE_TYPE.QUINT);
}
// Stage 2: Pan to target coordinates
await this.animatePanTo(preset.pan, panDuration, EASE_TYPE.CUBIC);
// Stage 3: Zoom in with cinematic rotation AND palette transition in parallel
// Calculate extra rotations
const extraFullRotations = Math.floor(Math.random() * 2); // 1 or 2 full rotations
const rotationDirection = Math.random() > 0.5 ? 1 : -1;
// Calculate shortest angular path from current to preset rotation
let deltaAngle = presetRotation - this.rotation;
while (deltaAngle > PI) deltaAngle -= PI * 2;
while (deltaAngle < -PI) deltaAngle += PI * 2;
// Target includes the path to preset plus extra full rotations
// This will end exactly at presetRotation after normalization
const targetRotationWithSpins = this.rotation + deltaAngle + (rotationDirection * extraFullRotations * PI * 2);
const finalAnimations = [
this.animateZoomRotationTo(preset.zoom, targetRotationWithSpins, zoomInDurationWithSpeed, EASE_TYPE.NONE)
];
if (!atTargetPalette) {
finalAnimations.push(this.animatePaletteByIdTransition(preset, zoomInDurationWithSpeed, coloringCallback));
}
await Promise.all(finalAnimations);
}
// Normalize rotation to ensure it's exactly at preset value (handles 2π wrapping)
this.rotation = normalizeRotation(this.rotation);
this.currentPresetIndex = preset.index || 0;
// Force a clean orbit rebuild with full grid search now that animation is complete
this.markOrbitDirty();
this.draw();
console.log(`Travel complete.`);
console.groupEnd();
}
/**
* Animates infinite demo loop of traveling to the presets
* @param {boolean} random Determines whether presets are looped in order from 1-9 or ordered randomly
* @param {Function} [coloringCallback] Optional callback for UI color updates
* @param {Function} [onPresetComplete] Optional callback when each preset completes
* @param {Array<PRESET>} [userPresets] Optional array of user-saved presets to include in the demo
* @return {Promise<void>}
*/
async animateDemo(random = true, coloringCallback = null, onPresetComplete = null, userPresets = []) {
console.groupCollapsed(`%c ${this.constructor.name}: animateDemo`, CONSOLE_GROUP_STYLE);
this.stopAllNonColorAnimations();
// Merge built-in presets with user presets
const allPresets = [...this.PRESETS, ...userPresets];
if (!allPresets.length) {
console.warn('No presets defined for Mandelbrot mode ');
return;
}
console.log(`Demo includes ${this.PRESETS.length} built-in + ${userPresets.length} user presets`);
this.demoActive = true;
// Simple index cycling - just go through all presets
let demoIndex = 0;
const getNextPresetIndex = (random) => {
if (allPresets.length <= 1) {
return 0; // Only one preset, always return it
}
if (random) {
// Pick a random preset different from current
let index;
do {
index = Math.floor(Math.random() * allPresets.length);
} while (index === demoIndex && allPresets.length > 1);
return index;
} else {
// Sequential: just increment
return (demoIndex + 1) % allPresets.length;
}
};
// Continue cycling through presets while demo is active.
while (this.demoActive) {
demoIndex = getNextPresetIndex(random);
const currentPreset = allPresets[demoIndex];
if (!currentPreset) {
console.warn(`No preset at index ${demoIndex}, stopping demo`);
break;
}
console.log(`Animating to preset ${demoIndex}/${allPresets.length}: "${currentPreset.id}"`);
await this.animateTravelToPreset(currentPreset, 3000, 1000, 3000, coloringCallback);
// Call completion callback to update UI state
if (onPresetComplete) {
onPresetComplete();
}
await asyncDelay(3500);
}
console.log(`Demo interrupted.`);
console.groupEnd();
}
// endregion--------------------------------------------------------------------------------------------------------
}
export default MandelbrotRenderer