import {updateInfo} from "../ui/ui";
import FractalRenderer from "./fractalRenderer";
import {
asyncDelay,
compareComplex,
ddSubDD,
degToRad,
hexToRGB,
lerp,
normalizeRotation,
splitFloat,
} from "../global/utils";
import "../global/types";
import {CONSOLE_GROUP_STYLE, CONSOLE_MESSAGE_STYLE, DEFAULT_JULIA_PALETTE, EASE_TYPE,} from "../global/constants";
import {updateJuliaSliders} from "../ui/juliaSlidersController";
/** @type {string} */
import fragmentShaderRaw from '../shaders/julia.frag';
import fragmentShaderRawLegacy from '../shaders/julia.legacy.frag';
import data from '../data/julia.json';
/**
* JuliaRenderer (Rebased Perturbation)
*
* @author Radim Brnka
* @description This module defines a JuliaRenderer class that inherits from fractalRenderer, implements the shader fragment code for the Julia set fractal, and sets preset zoom-ins.
* Reference orbit is for current c and a chosen z0_ref (near the view center).
* For each pixel:
* z0 = pan + zoom * rotated(st)
* dz0 = z0 - z0_ref
* dz_{n+1} = 2*zref*dz + dz^2 (NO +dc each step for Julia!)
* @extends FractalRenderer
* @see MandelbrotRenderer
* @copyright Synaptory Fractal Traveler, 2025-2026
* @license MIT
*/
export class JuliaRenderer extends FractalRenderer {
/**
* Enables legacy Julia renderer for intermittent troubleshooting of the perturbation renderer
* @type {boolean}
*/
static FF_LEGACY_JULIA_RENDERER = true;
constructor(canvas) {
super(canvas);
this.DEFAULT_ZOOM = data.views[0].zoom;
this.MAX_ZOOM = JuliaRenderer.FF_LEGACY_JULIA_RENDERER ? 3e-3 : 1e-17;
this.zoom = this.DEFAULT_ZOOM;
this.DEFAULT_ROTATION = degToRad(data.views[0].rotation || 0);
this.rotation = this.DEFAULT_ROTATION;
// TODO Use less detailed initial set for less performant devices
/** @type COMPLEX */
this.DEFAULT_C = [data.views[0].c[0], data.views[0].c[1]];
/** @type COMPLEX */
this.c = [...this.DEFAULT_C];
// IMPORTANT: use setPan (syncs DD + array) – keep this.pan canonical (TODO remove pan completely at some point)
this.setPan(this.DEFAULT_PAN[0], this.DEFAULT_PAN[1]);
// --- Perturbation state with DD precision ---
this.refZ0 = [0, 0];
this.refZ0DD = {
x: { hi: 0, lo: 0 },
y: { hi: 0, lo: 0 }
};
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;
this._prevPan0 = NaN;
this._prevPan1 = NaN;
this._prevZoom = NaN;
this._prevC0 = NaN;
this._prevC1 = NaN;
this.MAX_ITER = 5000;
this.REF_SEARCH_GRID = 7;
this.REF_SEARCH_RADIUS = 0.50;
this.orbitTex = null;
this.orbitData = null;
this.floatTexExt = null;
this.rebaseArmed = true;
/** @type {Array.<JULIA_PRESET>} */
this.PRESETS = data.views.map(p => ({
...p,
rotation: degToRad(p.rotation || 0)
}));
/** @type {Array.<DIVE>} */
this.DIVES = data.dives.map(d => ({
...d,
rotation: degToRad(d.rotation || 0)
}));
/** @type {Array.<JULIA_PALETTE>} */
this.PALETTES = data.palettes || [];
// Derive default palette index from the first preset's paletteId
const firstPresetPaletteId = data.views[0]?.paletteId;
this.DEFAULT_PALETTE_INDEX = firstPresetPaletteId
? this.PALETTES.findIndex(p => p.id === firstPresetPaletteId)
: 0;
if (this.DEFAULT_PALETTE_INDEX < 0) this.DEFAULT_PALETTE_INDEX = 0;
this.currentPaletteIndex = this.DEFAULT_PALETTE_INDEX;
this.innerStops = this.PALETTES.length > 0
? new Float32Array(this.PALETTES[this.currentPaletteIndex].theme)
: new Float32Array(DEFAULT_JULIA_PALETTE.theme);
// Initialize colorPalette (3-element RGB) for UI theming from keyColor
const initialPalette = this.PALETTES[this.currentPaletteIndex] || DEFAULT_JULIA_PALETTE;
const keyColor = initialPalette.keyColor ? hexToRGB(initialPalette.keyColor) : null;
this.colorPalette = keyColor
? [keyColor.r, keyColor.g, keyColor.b]
: [initialPalette.theme[9], initialPalette.theme[10], initialPalette.theme[11]]; // Fallback to 4th stop
this.currentCAnimationFrame = null;
this.cAnimationActive = false; // Defers orbit rebuild during C-animation for performance
this.demoTime = 0;
this.init();
}
markOrbitDirty = () => this.orbitDirty = true;
onProgramCreated() {
this.floatTexExt = this.gl.getExtension("OES_texture_float");
if (!this.floatTexExt) {
console.error("Missing OES_texture_float. Julia deep zoom perturbation 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);
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 (JuliaRenderer.FF_LEGACY_JULIA_RENDERER
? fragmentShaderRawLegacy
: fragmentShaderRaw).replace('__MAX_ITER__', this.MAX_ITER).toString();
}
/**
* @inheritDoc
* @override
*/
updateUniforms() {
super.updateUniforms();
this.cLoc = this.gl.getUniformLocation(this.program, "u_c");
this.innerStopsLoc = this.gl.getUniformLocation(this.program, "u_innerStops");
// delta z0 (pan - refZ0) computed on JS side for float64 precision
this.deltaZ0HLoc = this.gl.getUniformLocation(this.program, "u_delta_z0_h");
this.deltaZ0LLoc = this.gl.getUniformLocation(this.program, "u_delta_z0_l");
this.zoomHLoc = this.gl.getUniformLocation(this.program, "u_zoom_h");
this.zoomLLoc = this.gl.getUniformLocation(this.program, "u_zoom_l");
this.orbitTexLoc = this.gl.getUniformLocation(this.program, "u_orbitTex");
this.orbitWLoc = this.gl.getUniformLocation(this.program, "u_orbitW");
}
// --- Reference orbit building ---
escapeItersJulia(zx0, zy0, iters) {
let zx = zx0, zy = zy0;
const cx = this.c[0], cy = this.c[1];
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 refZ0 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.refZ0[0] = this.pan[0];
this.refZ0[1] = this.pan[1];
this.refZ0DD.x.hi = this.panDD.x.hi;
this.refZ0DD.x.lo = this.panDD.x.lo;
this.refZ0DD.y.hi = this.panDD.y.hi;
this.refZ0DD.y.lo = this.panDD.y.lo;
// Keep refPan in sync for needsRebase() to work correctly
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;
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.refZ0[0] - this.pan[0], this.refZ0[1] - this.pan[1]);
const maxRefDist = base * this.REF_SEARCH_RADIUS * 2.5; // Allow some margin beyond search radius
if (currentRefDist < maxRefDist) {
currentRefScore = this.escapeItersJulia(this.refZ0[0], this.refZ0[1], probeIters);
}
// Start with view center as fallback
let bestX = this.pan[0];
let bestY = this.pan[1];
let bestScore = this.escapeItersJulia(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 zx0 = this.pan[0] + ox * base;
const zy0 = this.pan[1] + oy * base;
const score = this.escapeItersJulia(zx0, zy0, probeIters);
if (score > bestScore) {
bestScore = score;
bestX = zx0;
bestY = zy0;
}
}
}
// 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.refZ0[0] = bestX;
this.refZ0[1] = bestY;
// New reference point from grid search - no DD precision accumulated yet
this.refZ0DD.x.hi = bestX;
this.refZ0DD.x.lo = 0;
this.refZ0DD.y.hi = bestY;
this.refZ0DD.y.lo = 0;
// Keep refPan in sync for needsRebase() to work correctly
this.refPan[0] = bestX;
this.refPan[1] = bestY;
this.refPanDD.x.hi = bestX;
this.refPanDD.x.lo = 0;
this.refPanDD.y.hi = bestY;
this.refPanDD.y.lo = 0;
}
// else: keep current refZ0 for stability
}
computeReferenceOrbit() {
if (!this.orbitData || !this.orbitTex) return;
const cx = this.c[0];
const cy = this.c[1];
let zx = this.refZ0[0];
let zy = this.refZ0[1];
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) {
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);
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);
}
/**
* Julia "needsRebase": keep reference initial condition close to view center.
* Same intent as Mandelbrot, but for z0 reference.
*/
needsRebase() {
const REBASE_ON = 1.75;
const REBASE_OFF = 0.90;
if (this.rebaseArmed === undefined) this.rebaseArmed = true;
if (!this.refPan) return true;
if (!Number.isFinite(this.zoom)) return false;
const z = Math.max(this.zoom, 1e-300);
const dx = this.pan[0] - this.refPan[0];
const dy = this.pan[1] - this.refPan[1];
const deltaView = Math.hypot(dx, dy) / z;
if (!this.rebaseArmed && deltaView < REBASE_OFF) {
this.rebaseArmed = true;
return false;
}
if (this.rebaseArmed && deltaView > REBASE_ON) {
this.rebaseArmed = false;
return true;
}
return false;
}
draw() {
this.gl.useProgram(this.program);
// Iteration strategy avoids exploding to infinity at tiny zooms
// 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 baseIters = Math.floor(3000 * Math.pow(2, -Math.log2(this.zoom)));
this.iterations = Math.min(2000, baseIters + this.extraIterations);
// Detect view changes -> mark orbit dirty (use tolerant checks to avoid noise)
const panMoved =
Number.isFinite(this._prevPan0) && Number.isFinite(this._prevPan1)
? (Math.abs(this.pan[0] - this._prevPan0) + Math.abs(this.pan[1] - this._prevPan1)) > (this.zoom * 1e-6)
: true;
const zoomChanged =
Number.isFinite(this._prevZoom)
? Math.abs(this.zoom - this._prevZoom) > (this.zoom * 1e-8)
: true;
const cChanged =
Number.isFinite(this._prevC0) && Number.isFinite(this._prevC1)
? (Math.abs(this.c[0] - this._prevC0) + Math.abs(this.c[1] - this._prevC1)) > 0.0
: true;
if (panMoved || zoomChanged || cChanged) {
this.orbitDirty = true;
this._prevPan0 = this.pan[0];
this._prevPan1 = this.pan[1];
this._prevZoom = this.zoom;
this._prevC0 = this.c[0];
this._prevC1 = this.c[1];
}
// Rebase policy for Julia:
// - c changes REQUIRE orbit rebuild (orbit depends on c) - cannot be deferred
// - during C-animation, skip expensive grid search (use view center directly)
// - pan/zoom during interaction can defer until settled
const mustRebaseNow = cChanged || this.needsRebase();
const isDeferringForAnimation = this.cAnimationActive || this.interactionActive;
const canRebaseNow = !isDeferringForAnimation || mustRebaseNow;
if (this.orbitDirty && canRebaseNow) {
// During interaction/animation, skip grid search (use view center directly)
this.pickReferenceNearViewCenter(isDeferringForAnimation);
this.computeReferenceOrbit();
this.orbitDirty = false;
}
// Compute deltaZ0 = panDD - refZ0DD on JS side (float64) for precision
// This avoids float32 precision loss when the shader subtracts pan - refZ0
const deltaZ0X = ddSubDD(this.panDD.x, this.refZ0DD.x);
const deltaZ0Y = ddSubDD(this.panDD.y, this.refZ0DD.y);
// Upload deltaZ0 hi/lo
if (this.deltaZ0HLoc) this.gl.uniform2f(this.deltaZ0HLoc, deltaZ0X.hi, deltaZ0Y.hi);
if (this.deltaZ0LLoc) this.gl.uniform2f(this.deltaZ0LLoc, deltaZ0X.lo, deltaZ0Y.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);
if (this.cLoc) this.gl.uniform2fv(this.cLoc, this.c);
if (this.innerStopsLoc) this.gl.uniform3fv(this.innerStopsLoc, this.innerStops);
super.draw();
}
/**
* @inheritDoc
* @override
*/
reset() {
this.c = [...this.DEFAULT_C];
// Reset to the palette matching the first preset (Event Horizon, index 13)
this.currentPaletteIndex = this.DEFAULT_PALETTE_INDEX;
const resetPalette = this.PALETTES[this.currentPaletteIndex] || DEFAULT_JULIA_PALETTE;
this.innerStops = new Float32Array(resetPalette.theme);
// Update colorPalette for UI theming
const keyColor = resetPalette.keyColor ? hexToRGB(resetPalette.keyColor) : null;
this.colorPalette = keyColor
? [keyColor.r, keyColor.g, keyColor.b]
: [resetPalette.theme[9], resetPalette.theme[10], resetPalette.theme[11]];
this.orbitDirty = true;
this._prevPan0 = NaN;
this._prevPan1 = NaN;
this._prevZoom = NaN;
this._prevC0 = NaN;
this._prevC1 = NaN;
super.reset();
}
// region > ANIMATION METHODS --------------------------------------------------------------------------------------
/** Stops currently running C animation and triggers final orbit rebuild */
stopCurrentCAnimation() {
console.log(`%c ${this.constructor.name}: %c stopCurrentCAnimation`, CONSOLE_GROUP_STYLE, CONSOLE_MESSAGE_STYLE);
if (this.currentCAnimationFrame !== null) {
cancelAnimationFrame(this.currentCAnimationFrame);
this.currentCAnimationFrame = null;
}
// Clear animation flag and force accurate orbit rebuild
if (this.cAnimationActive) {
this.cAnimationActive = false;
this.markOrbitDirty();
}
}
/**
* @inheritDoc
* @override
*/
stopAllNonColorAnimations() {
this.stopCurrentCAnimation();
super.stopAllNonColorAnimations();
}
/**
* Smoothly transitions the inner color stops (used by the shader for inner coloring)
* from the current value to the provided toPalette over the specified duration.
* Also updates the colorPalette to match the theme (using the first stop, for example).
*
* @param {JULIA_PALETTE} toPalette - The target theme as an array of numbers (e.g., 15 numbers for 5 stops).
* @param {number} [duration=250] - Duration of the transition in milliseconds.
* @param {Function} [callback] - A callback invoked when the transition completes.
* @return {Promise<void>}
*/
async animateInnerStopsTransition(toPalette, duration = 250, callback = null) {
console.groupCollapsed(`%c ${this.constructor.name}: animateInnerStopsTransition`, CONSOLE_GROUP_STYLE);
this.stopCurrentColorAnimations();
// Save the starting stops as a plain array.
const startStops = Array.from(this.innerStops);
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);
// Interpolate each component of the inner stops.
const interpolated = startStops.map((v, i) => lerp(v, toPalette.theme[i], progress));
this.innerStops = new Float32Array(interpolated);
let keyColor;
if (toPalette.keyColor) {
keyColor = hexToRGB(toPalette.keyColor);
if (keyColor) this.colorPalette = [keyColor.r, keyColor.g, keyColor.b];
}
if (!keyColor) {
const stopIndex = 3;
this.colorPalette = [
toPalette.theme[stopIndex * 3] * 1.5,
toPalette.theme[stopIndex * 3 + 1] * 1.5,
toPalette.theme[stopIndex * 3 + 2] * 1.5
];
}
this.draw();
if (callback) callback();
if (progress < 1) {
this.currentColorAnimationFrame = requestAnimationFrame(step);
} else {
this._colorAnimationResolve = null;
this.stopCurrentColorAnimations();
console.groupEnd();
resolve();
}
};
this.currentColorAnimationFrame = requestAnimationFrame(step);
});
}
/** @inheritDoc */
async animateColorPaletteTransition(duration = 250, coloringCallback = null) {
if (this.PALETTES.length === 0) {
// No palettes defined - just trigger random color (handled by base class)
return super.animateColorPaletteTransition(duration, coloringCallback);
}
this.currentPaletteIndex = (this.currentPaletteIndex + 1) % this.PALETTES.length;
await this.animateInnerStopsTransition(this.PALETTES[this.currentPaletteIndex], duration, coloringCallback);
}
/**
* Applies a specific palette by index
* @param {number} index - Palette index
* @param {number} [duration=250] - Transition duration in ms
* @param {Function} [coloringCallback] - Callback when done
* @return {Promise<void>}
*/
async applyPaletteByIndex(index, duration = 250, coloringCallback = null) {
if (this.PALETTES.length === 0 || index < 0 || index >= this.PALETTES.length) {
return;
}
this.currentPaletteIndex = index;
await this.animateInnerStopsTransition(this.PALETTES[index], duration, coloringCallback);
}
/** @inheritDoc */
async animateFullColorSpaceCycle(duration = 15000, coloringCallback = null) {
await new Promise(() => {
this.animateColorPaletteTransition(duration, coloringCallback);
});
}
/**
* Animates Julia from current C to target C
*
* @param {COMPLEX} [targetC] Defaults to default C
* @param {number} [duration] in ms
* @param {EASE_TYPE|Function} easeFunction
* @return {Promise<void>}
*/
async animateToC(targetC = [...this.DEFAULT_C], duration = 500, easeFunction = EASE_TYPE.QUINT) {
console.groupCollapsed(`%c ${this.constructor.name}: animateToC`, CONSOLE_GROUP_STYLE);
this.stopCurrentCAnimation();
if (compareComplex(this.c, targetC)) {
console.log(`Already at the target c. Skipping.`);
console.groupEnd();
return;
}
console.log(`Animating c from ${this.c} to ${targetC}.`);
const startC = [...this.c];
this.cAnimationActive = true; // Defer orbit rebuilds during animation
await new Promise((resolve) => {
let startTime = null;
const step = (timestamp) => {
if (!startTime) startTime = timestamp;
const progress = Math.min((timestamp - startTime) / duration, 1);
// Interpolate `c` smoothly
const easedProgress = easeFunction(progress);
this.c[0] = lerp(startC[0], targetC[0], easedProgress);
this.c[1] = lerp(startC[1], targetC[1], easedProgress);
this.draw();
updateInfo(true);
updateJuliaSliders();
if (progress < 1) {
this.currentCAnimationFrame = requestAnimationFrame(step);
} else {
this.cAnimationActive = false;
// Force final orbit rebuild with accurate reference
this.markOrbitDirty();
this.draw();
this.stopCurrentCAnimation();
this.onAnimationFinished();
console.groupEnd();
resolve();
}
};
this.currentCAnimationFrame = requestAnimationFrame(step);
});
}
/**
* Animates Julia from current C and zoom to target C and zoom
*
* @param {number} [targetZoom] Target zoom
* @param {COMPLEX} [targetC] Defaults to default C
* @param {number} [duration] in ms
* @param {EASE_TYPE|Function} easeFunction
* @return {Promise<void>}
*/
async animateToZoomAndC(targetZoom, targetC = [...this.DEFAULT_C], duration = 500, easeFunction = EASE_TYPE.QUINT) {
console.groupCollapsed(`%c ${this.constructor.name}: animateToZoomAndC`, CONSOLE_GROUP_STYLE);
this.stopCurrentCAnimation();
await Promise.all([
this.animateZoomTo(targetZoom, duration, easeFunction),
this.animateToC(targetC, duration, easeFunction)
]);
console.groupEnd();
}
/**
* Animates travel to a preset using a single consolidated animation loop.
*
* @param {JULIA_PRESET|PRESET} preset
* @param {number} [duration] in ms
* @param {Function} [coloringCallback=null] Optional callback for UI color updates
* @return {Promise<void>}
*/
async animateTravelToPreset(preset, duration = 500, coloringCallback = null) {
console.groupCollapsed(`%c ${this.constructor.name}: animateTravelToPreset`, CONSOLE_GROUP_STYLE);
this.stopAllNonColorAnimations();
this.stopCurrentColorAnimations();
const durationWithSpeed = duration * (preset.speed ?? 1);
// Phase 1: Setting default params (zoom out to default view)
await this.animatePanAndZoomTo(this.DEFAULT_PAN, this.DEFAULT_ZOOM, 1000);
// Phase 2: Consolidated animation to preset
// Capture start values
const startC = [...this.c];
const startPan = [...this.pan];
const startZoom = this.zoom;
const startRotation = this.rotation;
const startStops = Array.from(this.innerStops);
// Target values
const targetC = preset.c || this.DEFAULT_C;
const targetPan = preset.pan || this.DEFAULT_PAN;
const targetZoom = preset.zoom || this.DEFAULT_ZOOM;
const targetRotation = preset.rotation ?? 0;
// Target palette (if specified)
let targetPalette = null;
if (preset.paletteId) {
const paletteIndex = this.PALETTES.findIndex(p => p.id === preset.paletteId);
if (paletteIndex >= 0) {
targetPalette = this.PALETTES[paletteIndex];
this.currentPaletteIndex = paletteIndex;
}
}
// Use the longer duration for the main animation
const mainDuration = Math.max(duration, durationWithSpeed);
const easeFunc = EASE_TYPE.QUINT;
this.cAnimationActive = true;
this.markOrbitDirty();
await new Promise((resolve) => {
let startTime = null;
const step = (timestamp) => {
if (!startTime) startTime = timestamp;
const t = Math.min((timestamp - startTime) / mainDuration, 1);
const k = easeFunc(t);
// Interpolate C
this.c[0] = lerp(startC[0], targetC[0], k);
this.c[1] = lerp(startC[1], targetC[1], k);
// Interpolate pan
this.setPan(
lerp(startPan[0], targetPan[0], k),
lerp(startPan[1], targetPan[1], k)
);
// Interpolate zoom (exponential for smooth zoom feel)
this.zoom = startZoom * Math.pow(targetZoom / startZoom, k);
// Interpolate rotation
this.rotation = lerp(startRotation, targetRotation, k);
// Interpolate palette if target specified
if (targetPalette) {
const interpolatedStops = startStops.map((v, i) =>
lerp(v, targetPalette.theme[i], k)
);
this.innerStops = new Float32Array(interpolatedStops);
// Update colorPalette for UI theme
if (targetPalette.keyColor) {
const keyColor = hexToRGB(targetPalette.keyColor);
if (keyColor) {
this.colorPalette = [keyColor.r, keyColor.g, keyColor.b];
}
}
}
// Single draw call per frame
this.draw();
updateInfo(true);
updateJuliaSliders();
if (coloringCallback) {
coloringCallback();
}
if (t < 1) {
this.currentCAnimationFrame = requestAnimationFrame(step);
} else {
// Animation complete - finalize state
this.cAnimationActive = false;
this.markOrbitDirty();
this.draw();
this.currentPresetIndex = preset.index;
console.groupEnd();
resolve();
}
};
this.currentCAnimationFrame = requestAnimationFrame(step);
});
}
/**
* Infinite animation of the dive (c-param interpolations)
* @param {DIVE} dive
* @return {Promise<void>}
*/
async animateDive(dive) {
console.groupCollapsed(`%c ${this.constructor.name}: animateDive`, CONSOLE_GROUP_STYLE);
this.stopCurrentCAnimation();
console.log(`Diving to ${dive}.`);
// Note: Do NOT set cAnimationActive for continuous animations.
// The grid search in pickReferenceNearViewCenter is needed for stability
// when c changes to values where view center might escape immediately.
// Return a Promise that never resolves (continuous animation)
await new Promise(() => {
// Ensure phases are defined
dive.phases ||= [1, 2, 3, 4];
let phase = dive.phases[0];
const diveStep = () => {
const step = dive.step;
// Phase 1: Animate cx (real part) toward endC[0]
if (phase === 1) {
this.c[0] += dive.cxDirection * step;
if ((dive.cxDirection < 0 && this.c[0] <= dive.endC[0]) || (dive.cxDirection > 0 && this.c[0] >= dive.endC[0])) {
this.c[0] = dive.endC[0];
phase = 2;
}
}
// Phase 2: Animate cy (imaginary part) toward endC[1]
else if (phase === 2) {
this.c[1] += dive.cyDirection * step;
if ((dive.cyDirection < 0 && this.c[1] <= dive.endC[1]) || (dive.cyDirection > 0 && this.c[1] >= dive.endC[1])) {
this.c[1] = dive.endC[1];
phase = 3;
}
}
// Phase 3: Animate cx back toward startC[0]
else if (phase === 3) {
this.c[0] -= dive.cxDirection * step;
if ((dive.cxDirection < 0 && this.c[0] >= dive.startC[0]) || (dive.cxDirection > 0 && this.c[0] <= dive.startC[0])) {
this.c[0] = dive.startC[0];
phase = 4;
}
}
// Phase 4: Animate cy back toward startC[1]
else if (phase === 4) {
this.c[1] -= dive.cyDirection * step;
if ((dive.cyDirection < 0 && this.c[1] >= dive.startC[1]) || (dive.cyDirection > 0 && this.c[1] <= dive.startC[1])) {
this.c[1] = dive.startC[1];
phase = 1; // Loop back to start phase.
}
}
this.draw();
updateInfo(true);
updateJuliaSliders();
this.currentCAnimationFrame = requestAnimationFrame(diveStep);
};
this.currentCAnimationFrame = requestAnimationFrame(diveStep);
});
}
/**
* Animates infinite demo loop with oscillating c between predefined values
* @return {Promise<void>}
*/
async animateRandomDemo() {
console.log(`%c ${this.constructor.name}: animateDemo`, CONSOLE_GROUP_STYLE);
this.stopAllNonColorAnimations();
this.demoActive = true;
// Note: Do NOT set cAnimationActive for continuous animations - grid search needed for stability
// Return a Promise that never resolves (continuous animation)
await new Promise(() => {
const step = () => {
this.c = [
((Math.sin(this.demoTime) + 1) / 2) * 1.5 - 1, // Oscillates between -1 and 0.5
((Math.cos(this.demoTime) + 1) / 2) * 1.4 - 0.7 // Oscillates between -0.7 and 0.7
];
this.rotation = normalizeRotation(this.rotation - 0.001);
this.demoTime += 0.0005; // Speed
this.draw();
updateInfo(true);
updateJuliaSliders();
this.currentCAnimationFrame = requestAnimationFrame(step);
};
this.currentCAnimationFrame = requestAnimationFrame(step);
});
}
/**
* Animates infinite demo loop of traveling to the presets
* @param {boolean} random Determines whether presets are looped in order or randomly
* @param {Function} [coloringCallback] Optional callback for UI color updates
* @param {Function} [onPresetComplete] Optional callback when each preset completes
* @param {Array<JULIA_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 Julia mode');
console.groupEnd();
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, 4000, coloringCallback);
// Call completion callback to update UI state
if (onPresetComplete) {
onPresetComplete();
}
await asyncDelay(500);
}
console.log(`Demo interrupted.`);
console.groupEnd();
}
// endregion--------------------------------------------------------------------------------------------------------
}