ホーム / モジュール / UI / HUD / DomHudRenderer

DomHudRenderer

純ロジック

共有 UI 状態を DOM の HUD 要素へ描画する。

カテゴリUI / HUD
依存ティア純ロジック
内部依存ScalarUtils
関連モジュールなし
このモジュールだけ取得
modules/user-interface/DomHudRenderer.js

内部依存もまとめて、相対ディレクトリ構造を保ってコピーします。

ソース

import { clamp01 } from '../math/ScalarUtils.js';

const DEFAULT_FORMATTER = (value) => String(value ?? '');

function resolveDocument(documentRef = null) {
  return documentRef ?? (typeof document !== 'undefined' ? document : null);
}

export class DomHudRenderer {
  constructor(
    model,
    bindings = [],
    documentRef = null,
    label = 'DomHudRenderer'
  ) {
    if (!model) {
      throw new Error(`${label}: model is required`);
    }

    this.model = model;
    this.bindings = bindings;
    this.documentRef = documentRef;
    this.unsubscribe = null;
  }

  bindText(selector, key, formatter = DEFAULT_FORMATTER) {
    this.bindings.push({
      type: 'text',
      selector,
      key,
      formatter,
    });
    return this;
  }

  bindAttribute(selector, key, attribute, formatter = DEFAULT_FORMATTER) {
    this.bindings.push({
      type: 'attribute',
      selector,
      key,
      attribute,
      formatter,
    });
    return this;
  }

  bindClassToggle(selector, key, className, predicate = Boolean) {
    this.bindings.push({
      type: 'classToggle',
      selector,
      key,
      className,
      predicate,
    });
    return this;
  }

  bindStyleWidth(selector, key, maxKey) {
    this.bindings.push({
      type: 'styleWidth',
      selector,
      key,
      maxKey,
    });
    return this;
  }

  attach() {
    if (this.unsubscribe) return;
    this.unsubscribe = this.model.subscribe((state, changedKeys) => {
      this.render(state, changedKeys);
    });
  }

  detach() {
    this.unsubscribe?.();
    this.unsubscribe = null;
  }

  render(state, changedKeys = null) {
    const doc = resolveDocument(this.documentRef);
    if (!doc || typeof doc.querySelector !== 'function') return;
    const changedSet = changedKeys ? new Set(changedKeys) : null;

    for (const binding of this.bindings) {
      if (
        changedSet &&
        !changedSet.has(binding.key) &&
        !(binding.maxKey && changedSet.has(binding.maxKey))
      ) {
        continue;
      }

      const element = doc.querySelector(binding.selector);
      if (!element) continue;

      if (binding.type === 'text') {
        element.textContent = binding.formatter(state[binding.key], state);
      } else if (binding.type === 'attribute') {
        element.setAttribute(
          binding.attribute,
          binding.formatter(state[binding.key], state)
        );
      } else if (binding.type === 'classToggle') {
        const active = binding.predicate(state[binding.key], state);
        if (active) {
          element.classList?.add?.(binding.className);
        } else {
          element.classList?.remove?.(binding.className);
        }
      } else if (binding.type === 'styleWidth') {
        const current = Number(state[binding.key] ?? 0);
        const max = Number(state[binding.maxKey] ?? 1);
        const ratio = max <= 0 ? 0 : clamp01(current / max);
        element.style.width = `${Math.round(ratio * 100)}%`;
      }
    }
  }
}