Source: AutoUI.js

/*jshint esversion: 11 */
// @ts-check

/**
 * CS559 3D World Framework Code
 *
 * Simple, automatic UI from an object with properly declared parameters
 * 
 * @module AutoUI 
 * */

// we need to have the BaseClass definition
import { GrObject } from "./GrObject.js";
import { GrWorld } from "./GrWorld.js";
// we need to import the module to get its typedefs for the type checker
import * as InputHelpers from "CS559/inputHelpers.js";
import { GUI } from 'three/examples/jsm/libs/lil-gui.module.min.js';

/**
 * AutoUI options for world-scoped UI assembly.
 *
 * World-level defaults come from `GrWorld.setAutoUIOptions(...)`.
 * Per-object overrides can be supplied here when constructing `AutoUI`.
 *
 * @typedef AutoUIOptions
 * @property {GrWorld} [world] - world that owns UI policy and shared GUI root
 * @property {number} [width=300] - panel width
 * @property {InputHelpers.WhereSpec} [where] - explicit container (overrides world default)
 * @property {number} [widthdiv=1] - panel width subdivision
 * @property {boolean} [adjusted=false] - adjust label widths for sliders
 * @property {boolean} [useLilGUI=true] - true for lil-gui controls, false for div/slider controls
 * @property {string} [labelDisplay="inline-block"] - CSS display style for slider labels in div mode
 * @property {"where"|"floating"|"canvas-overlay"} [guiPlacement="canvas-overlay"] - lil-gui placement strategy
 */

/**
 * Migration notes for Spring 2026:
 *
 * 1. `AutoUI` now uses an options object as its second parameter.
 * 2. Global page-level panel and global `#gui` behavior were removed.
 * 3. UI placement should come from the world (`options.world`) and optionally `options.where`.
 * 4. `display` is replaced by explicit mode options: `useLilGUI` and `labelDisplay`.
 * 5. UI is created eagerly at construction time. This allows controls for
 *    objects that are part of a hierarchy even if they are not directly
 *    installed with `world.add(obj)`.
 *
 * Typical conversion:
 *
 * Old:
 * `new AutoUI(obj, 200, div, 1, false, "inline")`
 *
 * New:
 * `new AutoUI(obj, { world, width: 200, where: div, widthdiv: 1, adjusted: false, useLilGUI: false, labelDisplay: "inline" })`
 */

export class AutoUI {
  /**
   * Create a UI panel for a GrObject
   *
   * UI layout defaults are world-scoped. The object declares its parameters,
   * while the world controls overall panel placement and style.
   *
   * This does place the panel into the DOM (onto the web page)
   * using `insertElement` in the CS559 helper library. The place
   * it is placed is controlled the `where` parameter. By default,
   * it is taken from the world's AutoUI options.
   *
   * @param {GrObject} object
   * @param {AutoUIOptions} [options]
   */
  constructor(object, options = {}) {
    this.object = object;
    /** @type {Array<any>|undefined} */
    this.controllers = undefined;
    /** @type {Array<Object>} */
    this.pendingSetOps = [];
    this.built = false;

    const world = options.world;
    const worldOpts = world ? world.getAutoUIOptions() : {};
    this.options = Object.assign(
      {
        width: 300,
        where: undefined,
        widthdiv: 1,
        adjusted: false,
        useLilGUI: true,
        labelDisplay: "inline-block",
        guiPlacement: "canvas-overlay"
      },
      worldOpts,
      options
    );

    this.build();
  }

  build() {
    if (this.built) return;
    const self = this;
    const width = this.options.width;
    let where = this.options.where;
    const widthdiv = this.options.widthdiv;
    const adjusted = this.options.adjusted;
    const useLilGUI = this.options.useLilGUI;
    const labelDisplay = this.options.labelDisplay;
    const guiPlacement = this.options.guiPlacement;

    if (!where && this.options.world) {
      where = this.options.world.renderer?.domElement?.parentElement || document.body;
    }
    if (!where) {
      where = document.body;
    }

    if (!useLilGUI) {
      // Create the sliders using the CS559 inputHelpers
      this.div = InputHelpers.makeBoxDiv({ width: width, flex: widthdiv > 1 }, where);
      InputHelpers.makeHead(this.object.name, this.div, { tight: true });
      if (widthdiv > 1) InputHelpers.makeFlexBreak(this.div);

      this.sliders = this.object.params.map(function (param) {
        const slider = new InputHelpers.LabelSlider(param.name, {
          where: self.div,
          width: (width / widthdiv) - 20,
          min: param.min,
          max: param.max,
          step: param.step ?? ((param.max - param.min) / 30),
          initial: param.initial,
          id: self.object.name + "-" + param.name,
          adjusted: adjusted,
          display: labelDisplay,
        });
        return slider;
      });

      this.sliders.forEach(function (sl) {
        sl.oninput = function () {
          self.update();
        };
      });

      this.update();
    }
    else {
      // Create/reuse the GUI using lil-gui, scoped by world when available.
      const attachGUI = (guiInstance) => {
        if (guiPlacement === "floating") {
          return;
        }

        if (guiPlacement === "canvas-overlay" && this.options.world) {
          const canvas = this.options.world.renderer?.domElement;
          let overlayParent = canvas?.parentElement || where;

          // If the canvas is directly under body (or no suitable parent),
          // create a local wrapper so overlay anchoring is canvas-relative.
          if (!overlayParent || overlayParent === document.body) {
            const wrapper = document.createElement("div");
            wrapper.style.position = "relative";
            wrapper.style.display = "inline-block";
            if (canvas && canvas.parentElement) {
              canvas.parentElement.insertBefore(wrapper, canvas);
              wrapper.appendChild(canvas);
            } else {
              InputHelpers.insertElement(wrapper, where || document.body);
            }
            overlayParent = wrapper;
          }

          InputHelpers.insertElement(guiInstance.domElement, overlayParent);

          const parentEl = /** @type {HTMLElement} */ (overlayParent);
          const parentStyle = window.getComputedStyle(parentEl).position;
          if (parentStyle === "static") {
            parentEl.style.position = "relative";
          }
          guiInstance.domElement.style.position = "absolute";
          guiInstance.domElement.style.top = "0";
          guiInstance.domElement.style.right = "0";
          guiInstance.domElement.style.zIndex = "10";
          return;
        }

        InputHelpers.insertElement(guiInstance.domElement, where);
      };

      let gui;
      if (this.options.world) {
        if (!this.options.world.autoUIGUI) {
          const autoPlace = guiPlacement === "floating";
          if (adjusted) gui = new GUI({ title: "AutoUI", autoPlace });
          else gui = new GUI({ width: width / widthdiv, title: "AutoUI", autoPlace });
          attachGUI(gui);
          this.options.world.autoUIGUI = gui;
        } else {
          gui = this.options.world.autoUIGUI;
        }
      }
      else {
        const autoPlace = guiPlacement === "floating";
        if (adjusted) gui = new GUI({ title: "AutoUI", autoPlace });
        else gui = new GUI({ width: width / widthdiv, title: "AutoUI", autoPlace });
        attachGUI(gui);
      }

      const folder = gui.addFolder(this.object.name);
      const controllers = [];
      this.object.params.forEach(function (param) {
        if (self.object.values) self.object.values[param.name] = param.initial;
        else self.object.values = { [param.name]: param.initial };
        const controller = folder.add(self.object.values, param.name, param.min, param.max, param.step || Math.max((param.max - param.min) / 30, Number.EPSILON));
        controllers.push(controller);
        controller.onChange(function () {
          self.object.update(controllers.map(c => c.getValue()));
        });
      });
      // Apply initial parameter values once so object state matches controller defaults.
      this.object.update(controllers.map(c => c.getValue()));
      folder.close();
      this.gui = gui;
      this.folder = folder;
      this.controllers = controllers;
    }

    this.built = true;
    this.pendingSetOps.forEach(([param, value]) => this.set(param, value));
    this.pendingSetOps = [];
  }

  update() {
    if (!this.built) return;
    if (!this.sliders) {
      this.object.update(this.controllers.map(c => c.getValue()));
    }
    else {
      const vals = this.sliders.map(sl => Number(sl.value()));
      this.object.update(vals);
    }
  }

  /**
   *
   * @param {number | string} param
   * @param {number} value
   */
  set(param, value) {
    if (!this.built) {
      this.pendingSetOps.push([param, value]);
      return;
    }
    if (!this.sliders) {
      let vals = this.controllers.map(c => c.getValue());
      if (typeof param === "string") {
        for (let i = 0; i < this.object.params.length; i++) {
          if (param == this.object.params[i].name) {
            vals[i] = Number(value);
            this.controllers[i].setValue(Number(value));
          }
        }
      }
      else {
        vals[param] = Number(value);
        this.controllers[param].setValue(Number(value));
      }
      this.object.update(vals);
    }
    else if (typeof param === "string") {
      for (let i = 0; i < this.object.params.length; i++) {
        if (param == this.object.params[i].name) {
          this.sliders[i].set(Number(value));
          return;
        }
      }
      throw `Bad parameter ${param} to set`;
    } else {
      this.sliders[param].set(Number(value));
    }
  }
}