chromium/chrome/browser/resources/lens/overlay/overlay_shimmer_canvas.ts

// Copyright 2024 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import {assert, assertNotReached} from '//resources/js/assert.js';
import {EventTracker} from '//resources/js/event_tracker.js';
import {loadTimeData} from '//resources/js/load_time_data.js';
import {PolymerElement} from '//resources/polymer/v3_0/polymer/polymer_bundled.min.js';

import {BrowserProxyImpl} from './browser_proxy.js';
import {getFallbackTheme, getShaderLayerColorRgbas, modifyRgbaTransparency} from './color_utils.js';
import {CubicBezier} from './cubic_bezier.js';
import type {OverlayTheme} from './lens.mojom-webui.js';
import {getTemplate} from './overlay_shimmer_canvas.html.js';
import type {OverlayShimmerFocusedRegion, OverlayShimmerUnfocusRegion, Point} from './selection_utils.js';
import {ShimmerControlRequester} from './selection_utils.js';
import {Wiggle} from './wiggle.js';

// The frequency val used in the Wiggle functions.
const STEADY_STATE_FREQ_VAL = 0.06;
const INTERACTION_STATE_FREQ_VAL = 0.03;

// INVOCATION CONSTANTS: These are the values that the circles will have on
// the initial invocation, which then transition to the steady state
// constants.
const INVOCATION_OPACITY_PERCENT = 0;
const INVOCATION_RADIUS_PERCENT = 0;
const INVOCATION_CENTER_X_PERCENT = 50;
const INVOCATION_CENTER_Y_PERCENT = 0;
// Amplitude is the amount amount of randomness that is applied to each value.
// For example, if the base radius is 10% and radius amplitude is 10%, the
// actual rendered radius can be between 0% and 20%.
const INVOCATION_RADIUS_AMPLITUDE_PERCENT = 0;
const INVOCATION_CENTER_X_AMPLITUDE_PERCENT = 0;
const INVOCATION_CENTER_Y_AMPLITUDE_PERCENT = 0;

// STEADY STATE CONSTANTS: These are the values that the circles will have when
// no other state is being applied.
const STEADY_STATE_OPACITY_PERCENT = 0.3;
const STEADY_STATE_RADIUS_PERCENT = 21;
// The blur value is relative to the radius of the circle. A value of 2, doubles
// the circle radius to visually make the circle more blurred across the screen.
// The blur value should always be greater than 1.
const STEADY_STATE_CIRCLE_BLUR = 2;
// All circles Wiggle around a different position in the steady state. These
// offset control how far from the center the randomized position can be. For
// example, a 10% offset on the X axis means the X value of the randomized
// position can be between 40% and 60%.
const STEADY_STATE_CENTER_X_PERCENT_OFFSET = 50;
const STEADY_STATE_CENTER_Y_PERCENT_OFFSET = 30;
// Amplitude is the amount amount of randomness that is applied to each value.
// For example, if the base radius is 10% and radius amplitude is 10%, the
// actual rendered radius can be between 0% and 20%.
const STEADY_STATE_RADIUS_AMPLITUDE_PERCENT = 0;
const STEADY_STATE_CENTER_X_AMPLITUDE_PERCENT = 21;
const STEADY_STATE_CENTER_Y_AMPLITUDE_PERCENT = 21;
const STEADY_STATE_TRANSITION_DURATION = 800;
const STEADY_STATE_EASING_FUNCTION = new CubicBezier(0.05, 0.7, 0.1, 1.0);

// INTERACTION STATE CONSTANTS: These are the values that the circles will have
// the circles are interacting with the interaction the user is making (via
// their cursor, on a post selection bounding box, etc).
// Exception: segmentations use a different opacity.
const INTERACTION_STATE_OPACITY_PERCENT = 0.4;
const INTERACTION_STATE_EASING_FUNCTION = new CubicBezier(0.2, 0.0, 0.0, 1.0);

// CURSOR STATE CONSTANTS: These are the values that are only applied when the
// shimmer is following the cursor.
// The cursor radius is relative to the size of the cursor icon.
const CURSOR_STATE_RADIUS_PERCENT = 0;
// Amplitude is the amount amount of randomness that is applied to each value.
// For example, if the base radius is 10% and radius amplitude is 10%, the
// actual rendered radius can be between 0% and 20%.
const CURSOR_STATE_RADIUS_AMPLITUDE_PERCENT = 0;
const CURSOR_STATE_CENTER_X_AMPLITUDE_PERCENT = 0;
const CURSOR_STATE_CENTER_Y_AMPLITUDE_PERCENT = 0;
// The time it takes in MS to transition to the cursor state.
const CURSOR_STATE_TRANSITION_DURATION = 1000;
// The time it takes in MS to transition the shimmer circles to scale down to
// zero.
const CURSOR_SHRINK_TRANSITION_DURATION = 750;
const FADE_OUT_STATE_OPACITY_PERCENT = 0;
// The transition duration for the fade out animation
const FADE_OUT_TRANSITION_DURATION = 100;
const FADE_OUT_EASING_FUNCTION = new CubicBezier(0, 0, 1, 1);

// REGION SELECTION STATE CONSTANTS: These are the values that are only applied
// when the shimmer is focusing on a selected region. In the region selection
// state, these values are in relation to the bounding box smallest size, rather
// than the entire viewport.
const REGION_SELECTION_STATE_RADIUS_PERCENT = 45;
const REGION_SELECTION_STATE_CIRCLE_BLUR = 1.8;
const REGION_SELECTION_STATE_RADIUS_AMPLITUDE_PERCENT = 0;
const REGION_SELECTION_STATE_CENTER_X_AMPLITUDE_PERCENT = 40;
const REGION_SELECTION_STATE_CENTER_Y_AMPLITUDE_PERCENT = 40;
// The time it takes in MS to transition from a different state to the region
// selection state.
const REGION_SELECTION_TRANSITION_DURATION = 750;

// SEGMENTATION STATE CONSTANTS: These are the values that are only applied when
// the shimmer is focusing on a segmentation mask. In the segmentation state,
// these values are in relation to the bounding box smallest size, rather than
// the entire viewport.
const SEGMENTATION_STATE_OPACITY_PERCENT = 0.3;
const SEGMENTATION_STATE_RADIUS_PERCENT = 30;
const SEGMENTATION_STATE_CIRCLE_BLUR = 1.8;
const SEGMENTATION_STATE_RADIUS_AMPLITUDE_PERCENT = 0;
const SEGMENTATION_STATE_CENTER_X_AMPLITUDE_PERCENT = 20;
const SEGMENTATION_STATE_CENTER_Y_AMPLITUDE_PERCENT = 20;
// The time it takes in MS to transition from a different state to the
// segmentation state.
const SEGMENTATION_TRANSITION_DURATION = 750;

// The opacity of the sparkles. The sparkles opacity also is dictated by the
// circle pixel opacity below it. Meaning, the true opacity value is
// SPARKLES_OPACITY * CIRCLE_OPACITY.
const SPARKLES_OPACITY = 1;

// Specifies the current animation state of the shader canvas.
enum ShimmerState {
  NONE = 0,
  INVOCATION = 1,
  TRANSITION_TO_STEADY_STATE = 2,
  STEADY_STATE = 3,
  TRANSITION_SHRINK_TO_CURSOR = 4,
  CURSOR = 5,
  TRANSITION_FADE_IN_TO_REGION = 6,
  REGION = 7,
  TRANSITION_FADE_OUT_TO_CURSOR = 8,
  TRANSITION_FADE_OUT_TO_REGION = 9,
  TRANSITION_FADE_IN_TO_SEGMENTATION = 10,
  SEGMENTATION = 11,
  TRANSITION_FADE_OUT_TO_SEGMENTATION = 12,
}

// An interface representing the current values of a circle on the canvas.
interface ShimmerCircle {
  // The rgba value to color the circle.
  colorRgba: string;
  // A point with values between 0-100 that represents the (x,y) position of
  // this circles steady state, relative to the parent rect.
  steadyStateCenter: Point;
  // The current blur of the circle.
  blur: number;
  // The current values of the circle. The are values between 0-1 and
  // represents a percentage of the canvas size.
  radius: number;
  center: Point;
  // These are the amplitudes that should be applied to each wiggle for the
  // corresponding attribute.
  centerXAmpPercent: number;
  centerYAmpPercent: number;
  radiusAmpPercent: number;
  // The wiggles for each circle. This is a random noise generator so each
  // circle can simulate its own wiggle.
  radiusWiggle: Wiggle;
  centerXWiggle: Wiggle;
  centerYWiggle: Wiggle;
}

// An interface representing a shimmer animation with keyframes for its start
// and ending attributes.
interface ShimmerAnimation {
  startKeyframe: ShimmerAnimationKeyframe;
  endKeyframe: ShimmerAnimationKeyframe;
}

// An interface representing a keyframe or starting/ending position of the
// shimmer during an animation.
interface ShimmerAnimationKeyframe {
  blur: number;
  radius: number;
  center: Point;
  centerXAmpPercent: number;
  centerYAmpPercent: number;
  radiusAmpPercent: number;
  radiusWiggleValue: number;
  centerXWiggleValue: number;
  centerYWiggleValue: number;
  opacity: number;
}

/** Function for performing linear interpolation. */
function lerp(a: number, b: number, x: number): number {
  x = Math.max(0, Math.min(1, x));
  return a + (b - a) * x;
}

/**
 * Function for creating a radial gradient from the provided input parameters.
 */
function createCircleGradient(
    ctx: OffscreenCanvasRenderingContext2D, centerX: number, centerY: number,
    radius: number, colorRgba: string): CanvasGradient {
  // Centered radial gradient.
  const radialGradient = ctx.createRadialGradient(
      centerX,
      centerY,
      0,
      centerX,
      centerY,
      radius,
  );
  // Simulate a Gaussian full page blur by mimicking a smooth step alpha
  // change through the circle radius.
  radialGradient.addColorStop(0, modifyRgbaTransparency(colorRgba, 1));
  radialGradient.addColorStop(0.125, modifyRgbaTransparency(colorRgba, 0.957));
  radialGradient.addColorStop(0.25, modifyRgbaTransparency(colorRgba, 0.84375));
  radialGradient.addColorStop(
      0.375, modifyRgbaTransparency(colorRgba, 0.68359));
  radialGradient.addColorStop(0.5, modifyRgbaTransparency(colorRgba, 0.5));
  radialGradient.addColorStop(0.625, modifyRgbaTransparency(colorRgba, 0.3164));
  radialGradient.addColorStop(0.75, modifyRgbaTransparency(colorRgba, 0.15625));
  radialGradient.addColorStop(
      0.875, modifyRgbaTransparency(colorRgba, 0.04297));
  radialGradient.addColorStop(1, modifyRgbaTransparency(colorRgba, 0));
  return radialGradient;
}

export interface OverlayShimmerCanvasElement {
  $: {
    shaderCanvas: HTMLCanvasElement,
    sparklesSvg: SVGImageElement,
  };
}

/*
 * Controls the shimmer overlaid on the selection elements. The shimmer is
 * composed of multiple circles, each following a randomized pattern. Some
 * properties like the randomized movement, are per circle and controlled by
 * each circle, not this class. This class is responsible for controlling
 * behavior shared by each circle, like their general positioning and opacity.
 */
export class OverlayShimmerCanvasElement extends PolymerElement {
  static get is() {
    return 'overlay-shimmer-canvas';
  }

  static get template() {
    return getTemplate();
  }

  static get properties() {
    return {
      canvasHeight: Number,
      canvasWidth: Number,
      shaderLayerRgbaColors: {
        type: Array,
        computed: 'computeShaderLayerColorRgbas(theme)',
      },
      theme: {
        type: Object,
        value: () => getFallbackTheme(),
      },
    };
  }


  // Canvas height property for setting the pixel height of the canvas element.
  private canvasHeight: number;
  // Canvas width property for setting the pixel width of the canvas element.
  private canvasWidth: number;
  // Shader rgba colors.
  private shaderLayerRgbaColors: string[];
  // The overlay theme.
  private theme: OverlayTheme;

  // The properties of circles currently being rendered.
  private circles: ShimmerCircle[] = [];
  // Whether the circles should be applying wiggle.
  private isWiggling: boolean = true;
  // Event tracker for receiving DOM events.
  private eventTracker_: EventTracker = new EventTracker();
  // Stack that represents the current requesters for control. Once control is
  // relinquished, the next highest priority requester in the stack gains
  // control. If no one is in the stack, returns to steady state.
  private shimmerControllerStack: ShimmerControlRequester[] =
      [ShimmerControlRequester.NONE];
  // Used to put the shimmer back on the post selection after shimmer focuses on
  // another object, (like Segmentation).
  private previousPostSelection?: OverlayShimmerFocusedRegion;
  // Whether the results are showing or not.
  private areResultsShowing: boolean = false;

  // Listener ids for events from the browser side.
  private listenerIds: number[];

  // The height of the canvas taking into account device pixel ratio. Used to
  // resize the canvas on the next draw instead of immediately.
  private canvasPhysicalHeight: number;
  // The width of the canvas taking into account device pixel ratio. Used to
  // resize the canvas on the next draw instead of immediately.
  private canvasPhysicalWidth: number;
  private canvas: OffscreenCanvas;
  private context: OffscreenCanvasRenderingContext2D;
  // A list of animations for each shimmer circle. There should be one object
  // per circle and the list should be ordered identically to |circles|.
  private shimmerAnimation: ShimmerAnimation[] = [];

  // The last received cursor values that requested focusing the shimmer.
  private cursorCenter: Point;
  // The last received region values that requested focusing the shimmer.
  private regionCenter: Point;
  private regionWidth: number;
  private regionHeight: number;

  // The sparkles pattern used for rendering the sparkling animation. Created
  // when the sparkles PNG is loaded in. Null otherwise.
  private sparklesPattern: CanvasPattern|null = null;
  // A randomized value every 100ms to transform the sparkles to create
  // movement.
  private sparklesOffset: number = 0;
  // The ID of the setInterval call that updates the sparklesOffset.
  private sparklesIntervalId?: number;
  // Whether the sparkles are enabled or not.
  private enableSparkles: boolean =
      loadTimeData.getBoolean('enableShimmerSparkles');

  // The current shimmer state.
  private shimmerState: ShimmerState = ShimmerState.NONE;
  // The start time of the current animation. When undefined, it will be set at
  // next draw if there is an animiation needed.
  private animationStartTime?: number = undefined;
  // Whether the last transition was given time to finish.
  private didLastTransitionFinish = true;

  override ready() {
    super.ready();

    this.canvas = this.$.shaderCanvas.transferControlToOffscreen();
    this.context = this.canvas.getContext('2d')!;
  }

  override connectedCallback() {
    super.connectedCallback();
    this.eventTracker_.add(
        document, 'focus-region',
        (e: CustomEvent<OverlayShimmerFocusedRegion>) => {
          this.onFocusRegion(e);
        });
    this.eventTracker_.add(
        document, 'unfocus-region',
        (e: CustomEvent<OverlayShimmerUnfocusRegion>) => {
          this.onUnfocusRegion(e);
        });

    this.listenerIds = [
      BrowserProxyImpl.getInstance()
          .callbackRouter.notifyResultsPanelOpened.addListener(() => {
            this.areResultsShowing = true;
          }),
    ];
  }

  override disconnectedCallback() {
    super.disconnectedCallback();
    this.eventTracker_.removeAll();
    this.listenerIds.forEach(
        id => assert(
            BrowserProxyImpl.getInstance().callbackRouter.removeListener(id)));
    this.listenerIds = [];

    // Stop updating the sparkles if they are currently updating.
    if (this.sparklesIntervalId) {
      clearInterval(this.sparklesIntervalId);
      this.sparklesIntervalId = undefined;
    }
  }

  private onSparklesLoad() {
    // If the flag to enable sparkles is off, ignore the SVG loading in which
    // will cause skip initializing sparklesPattern so no sparkles appear.
    if (!this.enableSparkles) {
      return;
    }

    this.sparklesPattern =
        this.context.createPattern(this.$.sparklesSvg, 'repeat');

    this.sparklesIntervalId = setInterval(() => {
      this.sparklesOffset = Math.round(Math.random() * 500);
    }, 100);
  }


  // Starts the initial animation into the steady state or into a post
  // selection region if already focused.
  startAnimation() {
    // Draw invocation state.
    this.context.globalAlpha = INVOCATION_OPACITY_PERCENT;
    this.shimmerState = ShimmerState.INVOCATION;

    // Create a circle for each color rgb string defined. We do this when the
    // invocation animation is started to make sure we grab the latest set
    // overlay theme.
    this.circles = this.shaderLayerRgbaColors.map((colorRgbaString: string) => {
      return {
        colorRgba: colorRgbaString,
        steadyStateCenter: {
          x: 50 -
              STEADY_STATE_CENTER_X_PERCENT_OFFSET * (Math.random() * 2 - 1),
          y: 50 -
              STEADY_STATE_CENTER_Y_PERCENT_OFFSET * (Math.random() * 2 - 1),
        },
        blur: STEADY_STATE_CIRCLE_BLUR,
        radius: INVOCATION_RADIUS_PERCENT,
        center: this.regionCenter ?
            this.regionCenter :
            {x: INVOCATION_CENTER_X_PERCENT, y: INVOCATION_CENTER_Y_PERCENT},
        centerXAmpPercent: INVOCATION_CENTER_X_AMPLITUDE_PERCENT,
        centerYAmpPercent: INVOCATION_CENTER_Y_AMPLITUDE_PERCENT,
        radiusAmpPercent: INVOCATION_RADIUS_AMPLITUDE_PERCENT,
        radiusWiggle: new Wiggle(STEADY_STATE_FREQ_VAL),
        centerXWiggle: new Wiggle(STEADY_STATE_FREQ_VAL),
        centerYWiggle: new Wiggle(STEADY_STATE_FREQ_VAL),
      };
    });

    // Start the animation.
    requestAnimationFrame((timeMs: number) => {
      this.stepAnimation(timeMs);

      // Transition to the post selection region if it has already been set.
      if (this.regionCenter) {
        // Do not wiggle if going into post selection state.
        this.isWiggling = false;
        this.setTransitionState(ShimmerState.TRANSITION_FADE_IN_TO_REGION);
        return;
      }
      this.setTransitionState(ShimmerState.TRANSITION_TO_STEADY_STATE);
    });
  }

  // Resets the canvas size and stores the physical size for setting on the
  // next redraw.
  setCanvasSizeTo(width: number, height: number) {
    this.canvasWidth = width;
    this.canvasHeight = height;
    this.canvasPhysicalWidth = Math.floor(width * window.devicePixelRatio);
    this.canvasPhysicalHeight = Math.floor(height * window.devicePixelRatio);
  }

  private computeShaderLayerColorRgbas() {
    return getShaderLayerColorRgbas(this.theme);
  }

  private stepAnimation(timeMs: number) {
    // We need to clear the canvas ourselves if we did not resize.
    if (!this.resetCanvasSizeIfNeeded()) {
      this.context.clearRect(0, 0, this.canvasWidth, this.canvasHeight);
    }
    this.resetCanvasPixelRatioIfNeeded();

    this.drawCircles(timeMs);
    this.drawSparkles();
    requestAnimationFrame(this.stepAnimation.bind(this));
  }

  private onFocusRegion(e: CustomEvent<OverlayShimmerFocusedRegion>) {
    const centerX = e.detail.left + e.detail.width / 2;
    const centerY = e.detail.top + e.detail.height / 2;

    // Ignore invalid regions.
    if (centerX <= 0 || centerY <= 0) {
      return;
    }

    // Save the post selection in case we need to move the shimmer back to it.
    if (e.detail.requester === ShimmerControlRequester.POST_SELECTION) {
      // Include the post selection into the stack if it is not already present.
      if (!this.shimmerControllerStack.includes(
              ShimmerControlRequester.POST_SELECTION)) {
        this.shimmerControllerStack.push(
            ShimmerControlRequester.POST_SELECTION);
        this.shimmerControllerStack.sort();
      }
      this.previousPostSelection = e.detail;
    }

    this.focusRegion(
        centerX, centerY, e.detail.width, e.detail.height, e.detail.requester);
  }

  private onUnfocusRegion(e: CustomEvent<OverlayShimmerUnfocusRegion>) {
    const controllerBeforeUnfocus = this.getCurrentShimmerController();
    const index = this.shimmerControllerStack.indexOf(e.detail.requester);
    // Only relinquish control if the requester currently has control.
    if (index === -1) {
      return;
    }
    // Remove the control requester from the stack.
    this.shimmerControllerStack.splice(index, 1);

    // Only make changes to the shimmmer if the controller was changed.
    const newCurrentController = this.getCurrentShimmerController();
    if (newCurrentController === controllerBeforeUnfocus) {
      return;
    }

    if (newCurrentController === ShimmerControlRequester.POST_SELECTION &&
        this.previousPostSelection) {
      // Target the shimmer back to the post selection.
      const centerX = this.previousPostSelection.left +
          this.previousPostSelection.width / 2;
      const centerY = this.previousPostSelection.top +
          this.previousPostSelection.height / 2;

      this.focusRegion(
          centerX, centerY, this.previousPostSelection.width,
          this.previousPostSelection.height,
          this.previousPostSelection.requester);
    } else if (newCurrentController === ShimmerControlRequester.NONE) {
      if (this.areResultsShowing) {
        // Hide shimmer if user focusing on results.
        this.context.globalAlpha = 0;
      } else {
        this.setTransitionState(ShimmerState.TRANSITION_TO_STEADY_STATE);
      }
    }
  }

  // Focuses the shimmer on a specific region of the screen. The inputted values
  // should be percentage values between 0-1 representing the region to focus.
  private async focusRegion(
      centerX: number, centerY: number, width: number, height: number,
      requester: ShimmerControlRequester) {
    const currentShimmerController = this.getCurrentShimmerController();
    if (currentShimmerController > requester) {
      // Ignore this request because the current controller has a higher
      // priority than the requester.
      return;
    } else if (currentShimmerController < requester) {
      this.shimmerControllerStack.push(requester);
    }

    switch (requester) {
      case ShimmerControlRequester.SEGMENTATION:
        this.regionCenter = {x: centerX * 100, y: centerY * 100};
        this.regionWidth = width;
        this.regionHeight = height;
        this.setTransitionState(
            ShimmerState.TRANSITION_FADE_OUT_TO_SEGMENTATION);
        break;
      case ShimmerControlRequester.POST_SELECTION:
        this.regionCenter = {x: centerX * 100, y: centerY * 100};
        this.regionWidth = width;
        this.regionHeight = height;

        // If the current shimmer controller was already the post selection
        // requester, the bounds are changing on the post selection region so do
        // not fade out.
        if (currentShimmerController ===
            ShimmerControlRequester.POST_SELECTION) {
          this.setTransitionState(ShimmerState.TRANSITION_FADE_IN_TO_REGION);
          break;
        }

        // We want to fade out from the region if it is currently drawn or has
        // already begun to fade in.
        if (this.shimmerState === ShimmerState.REGION ||
            this.shimmerState === ShimmerState.TRANSITION_FADE_IN_TO_REGION) {
          this.setTransitionState(ShimmerState.TRANSITION_FADE_OUT_TO_REGION);
          break;
        }

        // If the shimmer is already fading out to a region, we don't need to
        // start fading in since that will occur when the fade out finishes.
        if (this.shimmerState !== ShimmerState.TRANSITION_FADE_OUT_TO_REGION) {
          this.setTransitionState(ShimmerState.TRANSITION_FADE_IN_TO_REGION);
        }
        break;
      case ShimmerControlRequester.MANUAL_REGION:
        this.regionCenter = {x: centerX * 100, y: centerY * 100};
        this.regionWidth = width;
        this.regionHeight = height;

        // Only restart the animation if we are going to a new region and the
        // previous animation has finished.
        if (this.shimmerState !== ShimmerState.TRANSITION_FADE_IN_TO_REGION) {
          this.setTransitionState(ShimmerState.TRANSITION_FADE_IN_TO_REGION);
        }
        break;
      case ShimmerControlRequester.CURSOR:
        this.cursorCenter = {x: centerX * 100, y: centerY * 100};

        // Only start the animation if the circles haven't already transitioned
        // to the cursor state from the steady state.
        if (this.shimmerState !== ShimmerState.TRANSITION_FADE_OUT_TO_CURSOR &&
            this.shimmerState !== ShimmerState.TRANSITION_SHRINK_TO_CURSOR &&
            this.shimmerState !== ShimmerState.CURSOR) {
          // The shimmer should only transition to the cursor if the previous
          // controller was the steady state. Otherwise, we want the shimmer to
          // fade out in place.
          const transitionState =
              currentShimmerController === ShimmerControlRequester.NONE ?
              ShimmerState.TRANSITION_SHRINK_TO_CURSOR :
              ShimmerState.TRANSITION_FADE_OUT_TO_CURSOR;
          this.setTransitionState(transitionState);
        }
        break;
      default:
        assertNotReached();
    }

    // We should only stop wiggling in the post selection state.
    if (requester === ShimmerControlRequester.POST_SELECTION) {
      this.isWiggling = false;
    } else {
      this.isWiggling = true;
    }
  }

  private drawCircles(timeMs: number) {
    // Update the animation time variables. These should be the same for all of
    // the animations we need to do.
    this.setCurrentAnimationStartTimeIfNeeded(timeMs);
    const elapsed = this.getElapsedAnimationTime(timeMs);
    const easingFunction = this.getEasingFunctionForCurrentState();

    // Animate the opacity if required for the upcoming drawing functions.
    this.animateOpacityIfNeeded(elapsed, easingFunction);

    for (let i = 0; i < this.circles.length; i++) {
      const circle = this.circles[i];
      const shimmerAnimation = this.shimmerAnimation[i];
      this.setWiggleFrequency(circle);

      // If we are no longer wiggling, we do not want the circles to abruptly
      // shift where they were not before. So use the previous wiggle value.
      let radiusWiggle = circle.radiusWiggle.getPreviousWiggleValue();
      let centerXWiggle = circle.centerXWiggle.getPreviousWiggleValue();
      let centerYWiggle = circle.centerYWiggle.getPreviousWiggleValue();
      if (this.isWiggling) {
        radiusWiggle = circle.radiusWiggle.calculateNext(timeMs / 1000);
        centerXWiggle = circle.centerXWiggle.calculateNext(timeMs / 1000);
        centerYWiggle = circle.centerYWiggle.calculateNext(timeMs / 1000);
      }

      this.stepUpdateCircle(circle, shimmerAnimation, elapsed, easingFunction);

      // We need to set the latest cursor point after we are done transitioning
      // to make sure the circles appear wherever the cursor last was.
      if (this.shimmerState === ShimmerState.CURSOR) {
        circle.center.x = this.cursorCenter.x;
        circle.center.y = this.cursorCenter.y;
      }

      const baseRadius = circle.radius / 100 *
          Math.max(this.canvasPhysicalWidth, this.canvasPhysicalHeight) *
          circle.blur;
      const baseCircleX = circle.center.x / 100 * this.canvasPhysicalWidth;
      const baseCircleY = circle.center.y / 100 * this.canvasPhysicalHeight;

      // Get the actual values as they should be rendered on the screen.
      const radiusAmp = circle.radiusAmpPercent / 100 *
          Math.max(this.canvasPhysicalWidth, this.canvasPhysicalHeight);
      const centerXAmp =
          circle.centerXAmpPercent / 100 * this.canvasPhysicalWidth;
      const centerYAmp =
          circle.centerYAmpPercent / 100 * this.canvasPhysicalHeight;

      // Floor these values to prevent sub pixel rendering. This provides better
      // performance.
      const adjustedRadius = Math.floor(
          (baseRadius + radiusAmp * radiusWiggle) / window.devicePixelRatio);
      const adjustedCenterX = Math.floor(
          (baseCircleX + centerXAmp * centerXWiggle) / window.devicePixelRatio);
      const adjustedCenterY = Math.floor(
          (baseCircleY + centerYAmp * centerYWiggle) / window.devicePixelRatio);

      this.drawCircle(
          adjustedRadius, adjustedCenterX, adjustedCenterY, circle.colorRgba);
    }

    // If the last transition update resulted in a completed animation, clean up
    // an leftover animation state.
    this.finishAnimationIfNeeded(elapsed);
  }

  // Draws sparkles on the canvas using circles as an alpha mask.
  private drawSparkles(): void {
    if (!this.sparklesPattern) {
      return;
    }

    // Update the sparkles position to use across the circles.
    this.sparklesPattern!.setTransform(new DOMMatrixReadOnly().translate(
        this.sparklesOffset, this.sparklesOffset));

    this.context.save();

    // Draw a path over the entire canvas.
    this.context.beginPath();
    this.context.rect(0, 0, this.canvasWidth, this.canvasHeight);
    this.context.closePath();

    this.context.globalCompositeOperation = 'source-atop';
    this.context.globalAlpha = SPARKLES_OPACITY;
    this.context.fillStyle = this.sparklesPattern;
    this.context.fill();

    this.context.restore();
  }

  // Checks the set canvas size and if it has changed, resets it on the canvas.
  // Returns false if no changes were made.
  private resetCanvasSizeIfNeeded(): boolean {
    if (this.canvas.width !== this.canvasPhysicalWidth ||
        this.canvas.height !== this.canvasPhysicalHeight) {
      // The opacity of the canvas is cleared on resize. So store it before it
      // is reset so we can set it back on the context after the resizing.
      const currentOpacity = this.context.globalAlpha;

      this.canvas.height = this.canvasPhysicalHeight;
      this.canvas.width = this.canvasPhysicalWidth;
      this.context.globalAlpha = currentOpacity;
      return true;
    }

    return false;
  }

  // Check if the devicePixelRatio has changed since the last redraw and modify
  // the canvas context if it has.
  private resetCanvasPixelRatioIfNeeded() {
    const transform = this.context.getTransform();
    if (transform.a !== window.devicePixelRatio ||
        transform.d !== window.devicePixelRatio) {
      this.context.setTransform(
          window.devicePixelRatio, 0, 0, window.devicePixelRatio, 0, 0);
    }
  }

  // Animate opacity if required. This sets the opacity for the all of the
  // drawing operations that follow.
  private animateOpacityIfNeeded(elapsed: number, easingFunction: CubicBezier) {
    if (this.isShimmerInTransitionState()) {
      // All of the circles should share the same end opacity.
      const shimmerAnimation = this.shimmerAnimation[0];
      if (shimmerAnimation) {
        const opacityProgress =
            Math.min(elapsed / this.getTransitionDuration(), 1);
        const easedOpacityProgress =
            Math.min(easingFunction.solveForY(opacityProgress), 1);
        this.context.globalAlpha = lerp(
            shimmerAnimation.startKeyframe.opacity,
            shimmerAnimation.endKeyframe.opacity, easedOpacityProgress);
      }
    }
  }

  // Updates the current circle's attributes according to the current shimmer
  // transition state, and provided elapsed duration, easing function, and
  // shimmer animation. This is a no-op if the shimmer is not in a transition
  // state.
  private stepUpdateCircle(
      circle: ShimmerCircle, shimmerAnimation: ShimmerAnimation,
      elapsed: number, easingFunction: CubicBezier) {
    // Animate the circles if needed according to the current shimmer state.
    if (this.isShimmerInTransitionState()) {
      let endCenter = structuredClone(shimmerAnimation.endKeyframe.center);
      let endCenterXAmpPrecent = shimmerAnimation.endKeyframe.centerXAmpPercent;
      let endCenterYAmpPrecent = shimmerAnimation.endKeyframe.centerYAmpPercent;
      let endRadius = shimmerAnimation.endKeyframe.radius;
      let endRadiusAmpPercent = shimmerAnimation.endKeyframe.radiusAmpPercent;

      // The cursor and region states set base values in their key frames. We
      // use instance members to make sure we always use the most up to date
      // values without needing to update key frames.
      if (this.shimmerState === ShimmerState.TRANSITION_SHRINK_TO_CURSOR) {
        endCenter = structuredClone(this.cursorCenter);
      } else if (
          this.shimmerState === ShimmerState.TRANSITION_FADE_IN_TO_REGION ||
          this.shimmerState ===
              ShimmerState.TRANSITION_FADE_IN_TO_SEGMENTATION) {
        const smallestLength = Math.min(this.regionHeight, this.regionWidth);
        endCenter = structuredClone(this.regionCenter);
        endCenterXAmpPrecent = endCenterXAmpPrecent * this.regionWidth;
        endCenterYAmpPrecent = endCenterYAmpPrecent * this.regionHeight;
        endRadius = endRadius * smallestLength;
        endRadiusAmpPercent = endRadiusAmpPercent * smallestLength;
      }

      const progress = Math.min(elapsed / this.getTransitionDuration(), 1);
      const easedProgress = Math.min(easingFunction.solveForY(progress), 1);

      // The cursor has a different transition duration for the radius than
      // for the other attributes.
      let easedRadiusProgress = easedProgress;
      if (this.shimmerState === ShimmerState.TRANSITION_SHRINK_TO_CURSOR) {
        const radiusProgress =
            Math.min(elapsed / CURSOR_STATE_TRANSITION_DURATION, 1);
        easedRadiusProgress =
            Math.min(easingFunction.solveForY(radiusProgress), 1);
      }

      circle.center.x = lerp(
          shimmerAnimation.startKeyframe.center.x, endCenter.x, easedProgress);
      circle.center.y = lerp(
          shimmerAnimation.startKeyframe.center.y, endCenter.y, easedProgress);
      circle.centerXAmpPercent = lerp(
          shimmerAnimation.startKeyframe.centerXAmpPercent,
          endCenterXAmpPrecent, easedProgress);
      circle.centerYAmpPercent = lerp(
          shimmerAnimation.startKeyframe.centerYAmpPercent,
          endCenterYAmpPrecent, easedProgress);
      circle.radiusAmpPercent = lerp(
          shimmerAnimation.startKeyframe.radiusAmpPercent, endRadiusAmpPercent,
          easedProgress);

      circle.radius = lerp(
          shimmerAnimation.startKeyframe.radius, endRadius,
          easedRadiusProgress);
    }
  }

  private drawCircle(
      radius: number, centerX: number, centerY: number, colorRgba: string) {
    this.context.beginPath();
    this.context.fillStyle =
        createCircleGradient(this.context, centerX, centerY, radius, colorRgba);
    this.context.arc(centerX, centerY, radius, 0, 2 * Math.PI);
    this.context.closePath();
    this.context.fill();
  }

  private createShimmerAnimation() {
    this.shimmerAnimation = this.circles.map(
        item => ({
          startKeyframe: this.createStartKeyframeFromCircle(item),
          endKeyframe: this.createEndKeyframeFromCircle(item),
        }));
  }

  private setWiggleFrequency(circle: ShimmerCircle) {
    if (this.shimmerState === ShimmerState.TRANSITION_FADE_IN_TO_REGION ||
        this.shimmerState === ShimmerState.TRANSITION_FADE_OUT_TO_REGION ||
        this.shimmerState === ShimmerState.REGION ||
        this.shimmerState === ShimmerState.TRANSITION_FADE_IN_TO_SEGMENTATION ||
        this.shimmerState ===
            ShimmerState.TRANSITION_FADE_OUT_TO_SEGMENTATION ||
        this.shimmerState === ShimmerState.SEGMENTATION) {
      circle.radiusWiggle.setFrequency(INTERACTION_STATE_FREQ_VAL);
      circle.centerXWiggle.setFrequency(INTERACTION_STATE_FREQ_VAL);
      circle.centerYWiggle.setFrequency(INTERACTION_STATE_FREQ_VAL);
      return;
    }
    circle.radiusWiggle.setFrequency(STEADY_STATE_FREQ_VAL);
    circle.centerXWiggle.setFrequency(STEADY_STATE_FREQ_VAL);
    circle.centerYWiggle.setFrequency(STEADY_STATE_FREQ_VAL);
  }

  private createStartKeyframeFromCircle(circle: ShimmerCircle):
      ShimmerAnimationKeyframe {
    const centerPoint = {x: circle.center.x, y: circle.center.y};
    let blur = circle.blur;
    let centerXAmpPercent = circle.centerXAmpPercent;
    let centerYAmpPercent = circle.centerYAmpPercent;
    let radius = circle.radius;
    let radiusAmpPercent = circle.radiusAmpPercent;
    // When fading in, the circles should use the region position.
    if (this.shimmerState === ShimmerState.TRANSITION_FADE_IN_TO_REGION) {
      const smallestLength = Math.min(this.regionHeight, this.regionWidth);
      centerPoint.x = this.regionCenter.x;
      centerPoint.y = this.regionCenter.y;
      radius = REGION_SELECTION_STATE_RADIUS_PERCENT * smallestLength;
      radiusAmpPercent =
          REGION_SELECTION_STATE_RADIUS_AMPLITUDE_PERCENT * smallestLength;
      blur = REGION_SELECTION_STATE_CIRCLE_BLUR;
      centerXAmpPercent =
          REGION_SELECTION_STATE_CENTER_X_AMPLITUDE_PERCENT * this.regionWidth;
      centerYAmpPercent =
          REGION_SELECTION_STATE_CENTER_Y_AMPLITUDE_PERCENT * this.regionHeight;
    } else if (
        this.shimmerState === ShimmerState.TRANSITION_FADE_IN_TO_SEGMENTATION) {
      const smallestLength = Math.min(this.regionHeight, this.regionWidth);
      centerPoint.x = this.regionCenter.x;
      centerPoint.y = this.regionCenter.y;
      radius = SEGMENTATION_STATE_RADIUS_PERCENT * smallestLength;
      radiusAmpPercent =
          SEGMENTATION_STATE_RADIUS_AMPLITUDE_PERCENT * smallestLength;
      blur = SEGMENTATION_STATE_CIRCLE_BLUR;
      centerXAmpPercent =
          SEGMENTATION_STATE_CENTER_X_AMPLITUDE_PERCENT * this.regionWidth;
      centerYAmpPercent =
          SEGMENTATION_STATE_CENTER_Y_AMPLITUDE_PERCENT * this.regionHeight;
    }
    return {
      blur,
      center: centerPoint,
      centerXAmpPercent,
      centerYAmpPercent,
      radius,
      radiusAmpPercent,
      radiusWiggleValue: circle.radiusWiggle.getPreviousWiggleValue(),
      centerXWiggleValue: circle.centerXWiggle.getPreviousWiggleValue(),
      centerYWiggleValue: circle.centerYWiggle.getPreviousWiggleValue(),
      opacity: this.context.globalAlpha,
    };
  }

  private createEndKeyframeFromCircle(circle: ShimmerCircle):
      ShimmerAnimationKeyframe {
    // Assume we are staying the same.
    const keyframe = this.createStartKeyframeFromCircle(circle);
    if (this.shimmerState === ShimmerState.TRANSITION_TO_STEADY_STATE) {
      keyframe.blur = STEADY_STATE_CIRCLE_BLUR;
      keyframe.center = structuredClone(circle.steadyStateCenter);
      keyframe.centerXAmpPercent = STEADY_STATE_CENTER_X_AMPLITUDE_PERCENT;
      keyframe.centerYAmpPercent = STEADY_STATE_CENTER_Y_AMPLITUDE_PERCENT;
      keyframe.radius = STEADY_STATE_RADIUS_PERCENT;
      keyframe.radiusAmpPercent = STEADY_STATE_RADIUS_AMPLITUDE_PERCENT;
      keyframe.opacity = STEADY_STATE_OPACITY_PERCENT;
    } else if (this.shimmerState === ShimmerState.TRANSITION_SHRINK_TO_CURSOR) {
      // The centerX and centerY can change in between key frames, so we use an
      // instance member of this component to track that end.
      keyframe.centerXAmpPercent = CURSOR_STATE_CENTER_X_AMPLITUDE_PERCENT;
      keyframe.centerYAmpPercent = CURSOR_STATE_CENTER_Y_AMPLITUDE_PERCENT;
      keyframe.radiusAmpPercent = CURSOR_STATE_RADIUS_AMPLITUDE_PERCENT;
      keyframe.radius = CURSOR_STATE_RADIUS_PERCENT;
    } else if (
        this.shimmerState === ShimmerState.TRANSITION_FADE_OUT_TO_CURSOR ||
        this.shimmerState === ShimmerState.TRANSITION_FADE_OUT_TO_REGION ||
        this.shimmerState ===
            ShimmerState.TRANSITION_FADE_OUT_TO_SEGMENTATION) {
      keyframe.opacity = FADE_OUT_STATE_OPACITY_PERCENT;
    } else if (
        this.shimmerState === ShimmerState.TRANSITION_FADE_IN_TO_REGION) {
      // The centerX and centerY can change in between key frames, so we use an
      // instance member of this component to track that end.
      keyframe.blur = REGION_SELECTION_STATE_CIRCLE_BLUR;
      keyframe.centerXAmpPercent =
          REGION_SELECTION_STATE_CENTER_X_AMPLITUDE_PERCENT;
      keyframe.centerYAmpPercent =
          REGION_SELECTION_STATE_CENTER_Y_AMPLITUDE_PERCENT;
      // This radius is dependent on a instance member of this component because
      // it can change quickly in between key frames.
      keyframe.radius = REGION_SELECTION_STATE_RADIUS_PERCENT;
      keyframe.radiusAmpPercent =
          REGION_SELECTION_STATE_RADIUS_AMPLITUDE_PERCENT;
      keyframe.opacity = INTERACTION_STATE_OPACITY_PERCENT;
    } else if (
        this.shimmerState === ShimmerState.TRANSITION_FADE_IN_TO_SEGMENTATION) {
      // The centerX and centerY can change in between key frames, so we use an
      // instance member of this component to track that end.
      keyframe.blur = SEGMENTATION_STATE_CIRCLE_BLUR;
      keyframe.centerXAmpPercent =
          SEGMENTATION_STATE_CENTER_X_AMPLITUDE_PERCENT;
      keyframe.centerYAmpPercent =
          SEGMENTATION_STATE_CENTER_Y_AMPLITUDE_PERCENT;
      // This radius is dependent on a instance member of this component because
      // it can change quickly in between key frames.
      keyframe.radius = SEGMENTATION_STATE_RADIUS_PERCENT;
      keyframe.radiusAmpPercent = SEGMENTATION_STATE_RADIUS_AMPLITUDE_PERCENT;
      keyframe.opacity = SEGMENTATION_STATE_OPACITY_PERCENT;
    }
    return keyframe;
  }

  private isShimmerInTransitionState(): boolean {
    return this.shimmerState === ShimmerState.TRANSITION_FADE_IN_TO_REGION ||
        this.shimmerState === ShimmerState.TRANSITION_FADE_IN_TO_SEGMENTATION ||
        this.shimmerState === ShimmerState.TRANSITION_TO_STEADY_STATE ||
        this.shimmerState === ShimmerState.TRANSITION_SHRINK_TO_CURSOR ||
        this.shimmerState === ShimmerState.TRANSITION_FADE_OUT_TO_CURSOR ||
        this.shimmerState === ShimmerState.TRANSITION_FADE_OUT_TO_REGION ||
        this.shimmerState === ShimmerState.TRANSITION_FADE_OUT_TO_SEGMENTATION;
  }

  private setCurrentAnimationStartTimeIfNeeded(currentTimeMs: number) {
    if (this.isShimmerInTransitionState() &&
        this.animationStartTime === undefined) {
      this.createShimmerAnimation();
      this.animationStartTime = currentTimeMs;
    }
  }

  private getElapsedAnimationTime(currentTimeMs: number): number {
    if (this.animationStartTime && this.animationStartTime > 0) {
      return currentTimeMs - this.animationStartTime;
    }
    return 0;
  }

  private getEasingFunctionForCurrentState(): CubicBezier {
    if (this.shimmerState === ShimmerState.TRANSITION_TO_STEADY_STATE) {
      return STEADY_STATE_EASING_FUNCTION;
    } else if (
        this.shimmerState === ShimmerState.TRANSITION_FADE_OUT_TO_CURSOR ||
        this.shimmerState === ShimmerState.TRANSITION_FADE_OUT_TO_REGION ||
        this.shimmerState ===
            ShimmerState.TRANSITION_FADE_OUT_TO_SEGMENTATION) {
      return FADE_OUT_EASING_FUNCTION;
    }
    return INTERACTION_STATE_EASING_FUNCTION;
  }

  private setTransitionState(state: ShimmerState) {
    if (this.isShimmerInTransitionState()) {
      this.didLastTransitionFinish = false;
    }
    // Mark the fade out as complete unless we are going to begin fading out.
    this.dispatchEvent(new CustomEvent('shimmer-fade-out-complete', {
      bubbles: true,
      composed: true,
      detail: state !== ShimmerState.TRANSITION_FADE_OUT_TO_REGION &&
          state !== ShimmerState.TRANSITION_FADE_OUT_TO_SEGMENTATION,
    }));
    this.animationStartTime = undefined;
    this.shimmerState = state;
  }

  private finishAnimationIfNeeded(elapsed: number) {
    if (this.shimmerState === ShimmerState.TRANSITION_TO_STEADY_STATE &&
        elapsed >= STEADY_STATE_TRANSITION_DURATION) {
      this.shimmerState = ShimmerState.STEADY_STATE;
      this.didLastTransitionFinish = true;
    } else if (
        this.shimmerState === ShimmerState.TRANSITION_FADE_IN_TO_REGION &&
        elapsed >= REGION_SELECTION_TRANSITION_DURATION) {
      this.shimmerState = ShimmerState.REGION;
      this.didLastTransitionFinish = true;
    } else if (
        this.shimmerState === ShimmerState.TRANSITION_FADE_IN_TO_SEGMENTATION &&
        elapsed >= SEGMENTATION_TRANSITION_DURATION) {
      this.shimmerState = ShimmerState.SEGMENTATION;
      this.didLastTransitionFinish = true;
    } else if (
        this.shimmerState === ShimmerState.TRANSITION_SHRINK_TO_CURSOR &&
        elapsed >= CURSOR_STATE_TRANSITION_DURATION) {
      this.shimmerState = ShimmerState.CURSOR;
      this.didLastTransitionFinish = true;
    } else if (
        this.shimmerState === ShimmerState.TRANSITION_FADE_OUT_TO_CURSOR &&
        elapsed >= FADE_OUT_TRANSITION_DURATION) {
      this.shimmerState = ShimmerState.CURSOR;
      this.didLastTransitionFinish = true;
    } else if (
        this.shimmerState === ShimmerState.TRANSITION_FADE_OUT_TO_REGION &&
        elapsed >= FADE_OUT_TRANSITION_DURATION) {
      this.dispatchEvent(new CustomEvent('shimmer-fade-out-complete', {
        bubbles: true,
        composed: true,
        detail: true,
      }));
      this.didLastTransitionFinish = true;
      this.shimmerState = ShimmerState.NONE;
      this.setTransitionState(ShimmerState.TRANSITION_FADE_IN_TO_REGION);
    } else if (
        this.shimmerState ===
            ShimmerState.TRANSITION_FADE_OUT_TO_SEGMENTATION &&
        elapsed >= FADE_OUT_TRANSITION_DURATION) {
      this.dispatchEvent(new CustomEvent('shimmer-fade-out-complete', {
        bubbles: true,
        composed: true,
        detail: true,
      }));
      this.didLastTransitionFinish = true;
      this.shimmerState = ShimmerState.NONE;
      this.setTransitionState(ShimmerState.TRANSITION_FADE_IN_TO_SEGMENTATION);
    }
  }

  /** Returns the expected duration of the current transition. */
  private getTransitionDuration(): number {
    if (this.shimmerState === ShimmerState.TRANSITION_TO_STEADY_STATE) {
      return STEADY_STATE_TRANSITION_DURATION;
    } else if (
        this.shimmerState === ShimmerState.TRANSITION_FADE_IN_TO_REGION) {
      return REGION_SELECTION_TRANSITION_DURATION;
    } else if (
        this.shimmerState === ShimmerState.TRANSITION_FADE_IN_TO_SEGMENTATION) {
      return SEGMENTATION_TRANSITION_DURATION;
    } else if (this.shimmerState === ShimmerState.TRANSITION_SHRINK_TO_CURSOR) {
      return CURSOR_SHRINK_TRANSITION_DURATION;
    } else if (
        this.shimmerState === ShimmerState.TRANSITION_FADE_OUT_TO_CURSOR ||
        this.shimmerState === ShimmerState.TRANSITION_FADE_OUT_TO_REGION ||
        this.shimmerState ===
            ShimmerState.TRANSITION_FADE_OUT_TO_SEGMENTATION) {
      return FADE_OUT_TRANSITION_DURATION;
    }
    return 0;
  }

  private getCurrentShimmerController(): ShimmerControlRequester {
    return this.shimmerControllerStack[this.shimmerControllerStack.length - 1];
  }
}

declare global {
  interface HTMLElementTagNameMap {
    'overlay-shimmer-canvas': OverlayShimmerCanvasElement;
  }
}

customElements.define(
    OverlayShimmerCanvasElement.is, OverlayShimmerCanvasElement);