import {
  getHoverPaletteForScheme,
  GRAY_PALETTE,
  escapeHtml,
  i18nStr,
  MAX_BARS,
  parseBarColor,
  resolveDataItemColor,
  isEmptyBar,
  resolveStraightBarLabels,
  resolveStackedBarSumAbove,
  resolvePieSliceLabelMode,
  resolvePieInnerRadius,
  resolvePieThickness,
  resolveHeaderTextColorAttr,
  resolvePieLabelAutoContrast,
  resolvePieCalloutLineLength,
  resolvePieCalloutFontScale,
  resolvePieCalloutColor,
  resolvePieAngleBasis,
  resolveStairStepHeight,
  isPieLayout,
  isPyramidLayout,
  resolvePyramidSegmentGap,
  resolvePyramidInverted,
  resolvePyramidSliceLabelMode,
  resolvePyramidLabelAutoContrast,
  resolvePyramidAnnotation,
  resolvePyramidAnnotationLineLength,
  resolvePyramidAnnotationFontScale,
  resolveWaveLabelAtNodes,
  resolveWaveLabelAlongFloor,
  mergeSeriesAxisXLabelTransform,
  contrastTextColor,
  WEBGL_PIXEL_RATIO_CAP,
  joinHtmlTickValueAndSuffix,
} from '../shared/config.js';

/* Label-/Achsen-Resolver fuer Add-on-Layouts. Fallback `'off'` schadet nicht, weil
 * die Standard-Layouts (straight, stacked, pie) diese Werte nicht auswerten. */
const resolveHorizontalBarLabels = (config) => (typeof window !== 'undefined'
  && window.__CE_PD_SCENE_EXT__?.resolveHorizontalBarLabels?.(config))
  || { left: 'off', right: 'off' };
const resolveGroupedBarLabelAbove = (config) => (typeof window !== 'undefined'
  && window.__CE_PD_SCENE_EXT__?.resolveGroupedBarLabelAbove?.(config)) || 'off';
const resolveArcBarLabelAbove = (config) => (typeof window !== 'undefined'
  && window.__CE_PD_SCENE_EXT__?.resolveArcBarLabelAbove?.(config)) || 'off';
const resolveRadialBarLabel = (config) => (typeof window !== 'undefined'
  && window.__CE_PD_SCENE_EXT__?.resolveRadialBarLabel?.(config)) || 'headline';
const resolveBubbleLabelMode = (config) => (typeof window !== 'undefined'
  && window.__CE_PD_SCENE_EXT__?.resolveBubbleLabelMode?.(config)) || 'both';
const resolveBubbleLabelColor = (config) => (typeof window !== 'undefined'
  && window.__CE_PD_SCENE_EXT__?.resolveBubbleLabelColor?.(config)) || '';
import { createChartNumberFormatter, shouldFormatRowUnitSeparately } from '../shared/numberFormat.js';
import { createState, layoutSupportsOrthographic } from './state.js';
import { clearDiagramWakeSchedule } from './cePdTimerHub.js';
import { getTroikaDebugStats, onAllLabelsSynced } from './troikaLabel.js';

/* `onAllLabelsSynced`: gemeinsames Register in `troikaLabel.js` (Idle-Drain + pro Aufruf
 * optionaler Safety-Timeout); Callbacks bleiben getrennt, Safety-Timer sind koalesziert. */

import { createBars, gradientStopsForIndex } from './layouts/bars.js';
import { createPie } from './layouts/pie.js';
import { createPyramid } from './layouts/pyramid.js';
import { lerpInstanceColors, setInstancePosScale, setInstanceTransform } from './instanced.js';
import { buildStackedLayout } from './layouts/stackedLayout.js';

/**
 * Layout-Treiber-Lookup. Add-on-Plugins koennen weitere Layouts via
 * `window.__CE_PD_LAYOUTS__[id] = { create: fn, ... }` registrieren. Die
 * folgenden Wrapper sind generische Aufruf-Helfer, die nichts tun, wenn
 * kein Treiber fuer die jeweilige ID registriert ist.
 */
const _laReg = () => ((typeof window !== 'undefined' && window.__CE_PD_LAYOUTS__) ? window.__CE_PD_LAYOUTS__ : {});
const _laCall = (id, ctx) => _laReg()[id]?.create?.(ctx);
const _laCallMethod = (id, method, ...a) => _laReg()[id]?.[method]?.(...a);
import {
  createRuler,
  syncHorizontalRightLabels,
  resolveCanvasFontFamily,
  clampDataLabelFontScale,
  normalizeRulerStyle,
  updateRulerColor,
  AXIS_PAIR_LABEL_PLANE_H,
  SERIES_AXIS_X_MARGIN_WORLD,
  STRAIGHT_XLABEL_PLANE_W,
  STRAIGHT_XLABEL_PLANE_H,
} from './ruler.js';
import {
  createStackedBarSumLabels,
  createStraightBarTopLabels,
} from './groupedBarLabels.js';
import './labels/waveLabels.js';

/* Add-on-Label-Fabriken: Aufrufer pruefen das globale Scene-Ext-Objekt,
 * weil Standard-Layouts (straight/stacked/pie) diese Funktionen nicht
 * benoetigen und das Plugin sie deshalb auch nicht selbst implementiert. */
function _extLabels() {
  return (typeof window !== 'undefined' && window.__CE_PD_SCENE_EXT__ && window.__CE_PD_SCENE_EXT__.labels) || null;
}
function _emptyLabelGroup(THREE) {
  const group = new THREE.Group();
  group.layers.set(1);
  group.renderOrder = 1000;
  return { group, meshes: [] };
}
function _callExtLabel(THREE, name, ctx, extraOut) {
  const ext = _extLabels();
  const fn = ext && ext[name];
  if (typeof fn === 'function') return fn(ctx);
  const base = _emptyLabelGroup(THREE);
  if (extraOut) Object.assign(base, extraOut);
  return base;
}
/** Anzahl gestapelter Säulen (= distinct groupKeys; leerer groupKey zählt als eigene Einzel-Spalte).
 *  Gleiche Keying-Logik wie buildStackedLayout, damit die Stacked-Dimensionierung zur tatsächlichen
 *  Spaltenzahl passt. */
function countStackedColumns(data) {
  const seen = new Set();
  let singletons = 0;
  for (let i = 0; i < data.length; i++) {
    const raw = data[i].groupKey;
    const key = raw != null && String(raw).trim() !== '' ? String(raw).trim() : null;
    if (key === null) singletons++;
    else seen.add(key);
  }
  return seen.size + singletons;
}

/**
 * Init-Layout-Deskriptor: zentralisiert die layoutabhaengigen Orchestrierungs-
 * Entscheidungen in `initScene`, damit der Free-Core keine Pro-Layout-Namen mehr
 * hartcodiert. Free liefert die Defaults fuer seine eigenen Layouts (straight/
 * stacked/pie/pieStairs) inline; Add-on-Plugins steuern ihre Layouts ueber
 * `window.__CE_PD_SCENE_EXT__.init.layoutDescriptor(layout)` bei (arc/grouped/
 * horizontal/wave/radial/bubblePack/surface). Felder (waxen pro Teilschritt):
 *  - isArc                : gekruemmte Polar-Geometrie (eigener diagramRightEdgeX, Arc-Top-Labels).
 *  - isHorizontal         : liegende Balken (Ruler-Bar-Farben, Bar-Center-Z).
 *  - isWave               : Wellen-Layout (Group-Z, Node-/Serien-/Annotations-Labels, Floor-X-Labels).
 *  - isGrouped            : gruppierte Balken (Bar-Headline-Labels, getBarLayout-Cluster).
 *  - isRadial             : radiales Layout (Radial-Bar-Start-Labels, Build-Dispatch).
 *  - isSurface            : Surface-Layout (DATA-Slice, Zellfarben, Build-Dispatch).
 *  - isBubblePack         : BubblePack-Layout (Build-Dispatch).
 *  - usesStraightBarPlane : vertikale Balken in der Straight-Z-Ebene (wie straight).
 */
function resolveInitLayoutDescriptor(layout) {
  if (layout === 'wave') {
    return {
      isArc: false,
      isHorizontal: false,
      isWave: true,
      isGrouped: false,
      isRadial: false,
      isSurface: false,
      isBubblePack: false,
      usesStraightBarPlane: false,
    };
  }
  const ext = (typeof window !== 'undefined' && window.__CE_PD_SCENE_EXT__ && window.__CE_PD_SCENE_EXT__.init) || null;
  const fromPro = ext && typeof ext.layoutDescriptor === 'function' ? ext.layoutDescriptor(layout) : null;
  const d = (fromPro && typeof fromPro === 'object') ? fromPro : {};
  return {
    isArc: d.isArc === true,
    isHorizontal: d.isHorizontal === true,
    isWave: d.isWave === true,
    isGrouped: d.isGrouped === true,
    isRadial: d.isRadial === true,
    isSurface: d.isSurface === true,
    isBubblePack: d.isBubblePack === true,
    usesStraightBarPlane: d.usesStraightBarPlane === true,
  };
}

import { createBarLabelSync } from './barLabelSync.js';
import { createLayoutFrame } from './layoutFrame.js';
import { createLayoutApply } from './layoutApply.js';
import { createHitTesting } from './hitTesting.js';
import { createTooltipController } from './tooltipController.js';
import { createPointerInput } from './pointerInput.js';
import { disposeSceneGeometriesAndMaterials, disposeReflector } from './disposeHelpers.js';
import { createBackground } from './background.js';
import { createLightsAndShadows } from './lights.js';
import { createViewerSortControl } from './ui/viewerSort.js';
/* labelTexture.js wurde mit der Troika-Migration entfernt; Pixel-Ratio + Anisotropy
 * werden nicht mehr fuer Canvas-Texturen gebraucht — Troika erzeugt einen seitenweit
 * geteilten SDF-Atlas. */
import {
  IS_MOBILE_GLOBAL,
  IS_IOS_GLOBAL,
  isWebGL2Available,
  acquireRenderer as poolAcquireRenderer,
  enqueueSerializedDiagramInit,
  enqueueSerializedWake,
  releaseRenderer,
  registerActiveRenderer,
  unregisterActiveRenderer,
  unregisterPendingDeepDispose,
  isScrollFrozen,
  addScrollUnfreezeCallback,
  removeScrollUnfreezeCallback,
} from './rendererPool.js';
import {
  observeResize as _ceObserveResize,
  unobserveResize as _ceUnobserveResize,
} from './observers.js';
import { createCameraController, PROJECTION_TWEEN_FOV_START_DEG } from './cameraController.js';
import { projectionTweenToFlatness, setProjectionFlatnessAll, createProjectionFlatRegistry } from './projectionFlatMaterial.js';
import { bindTouchExpandButtons } from './ui/touchExpandButtons.js';
import { createIconTooltip } from './ui/iconTooltip.js';
import { createVisibilityLifecycle } from './visibilityLifecycle.js';
import { createRulerColorObserver } from './rulerMutationObserver.js';
import { createLegend } from './ui/legend.js';
import {
  createFullscreen,
  isDiagramFullscreen as _isDiagramFullscreen,
} from './ui/fullscreen.js';
import { createPngExport } from './ui/pngExport.js';
import { createProjectionToggle } from './ui/projectionToggle.js';
import { createDataTableModal } from './ui/dataTableModal.js';
import {
  mountDiagramViewportCanvas,
  createDiagramViewportLifecycle,
} from './diagramViewportLifecycle.js';
import { createStageHeight } from './stageHeight.js';
import { CE_PD_SNAPSHOT_OVERLAY_CLASS } from './snapshotOverlayFinish.js';

/**
 * Hauptschleife: baut die Three.js-Szene samt Kontrollen, UI-Elementen und Pointer-/Tooltip-Input
 * für einen Diagramm-Wrapper auf.
 *
 * Signatur auf Options-Objekt umgestellt (vorher 10 Positionsargumente), damit Caller (view.js
 * beim Erst-Mount, viewerSort.js beim Re-Init) keine Parameter-Reihenfolge mehr einhalten müssen
 * und künftige Erweiterungen ohne API-Bruch gehen.
 *
 * @param {object} params
 * @param {object} params.THREE
 * @param {object} params.OrbitControls
 * @param {object} params.RoundedBoxGeometry
 * @param {object} params.Reflector
 * @param {HTMLElement} params.wrapper              - .ce-diagram-wrapper
 * @param {HTMLElement} params.container            - Renderer-Host (canvas/div)
 * @param {HTMLElement} params.tooltip              - Shared tooltip node (body-level)
 * @param {HTMLElement} params.iconEl               - Mouse-follow Icon-Element
 * @param {Array<object>} params.DATA               - Normalisierte Datenpunkte
 * @param {object} params.opts                      - Alle Render-Optionen (Farben, Labels, i18n, …)
 * @returns {Function} cleanup — idempotenter Tear-Down (gleich via wrapper._ceDiagramCleanup gesichert)
 */
export function initScene({ THREE, OrbitControls, RoundedBoxGeometry, Reflector, wrapper, container, tooltip, iconEl, DATA, opts }) {
  let viewerSortWrapEl = null;
  const ownerDoc = wrapper.ownerDocument || document;
  /* Layout-/Kamera-Fit-Boundingbox (layoutFrame.js): `window.__ceShowBBox` oder URL `?ce_bbox=1`.
   * Zeigt axis-aligned Wireframe am berechneten Frame + roten Mittelpunkt (Center). */
  try {
    const win = ownerDoc.defaultView;
    if (win?.location?.search) {
      const qp = new URLSearchParams(win.location.search);
      if (qp.has('ce_bbox')) {
        const raw = String(qp.get('ce_bbox') ?? '').trim().toLowerCase();
        window.__ceShowBBox = raw === '' || raw === '1' || raw === 'true' || raw === 'yes' || raw === 'on';
        if (raw === '0' || raw === 'false' || raw === 'no' || raw === 'off') window.__ceShowBBox = false;
      }
    }
  } catch (_) { /* opaque URL / no window */ }
  /** Resume-State nach Deep-Dispose: Camera-Position + Controls-Target wurden vor dem
   *  Deep-Dispose persistiert. Wenn vorhanden: Kamera später (nach Layout + recalcFit) auf
   *  den gecachten Zustand zurücksetzen. `consumedResumeState` bleibt in Scope, damit
   *  spätere Setup-Phasen die Daten anwenden können. */
  const consumedResumeState = wrapper._cePdResumeState || null;
  if (consumedResumeState) {
    wrapper._cePdResumeState = null;
  }
  const i18nDataTableColValue = i18nStr(opts.i18nDataTableColValue, 'Value');
  const i18nPngDownload = i18nStr(opts.i18nPngDownload, 'PNG Download');
  const i18nHibernateHint = i18nStr(opts.i18nHibernateHint, 'Hover over the chart to load the interactive view.');
  const i18nTapToInteract = i18nStr(opts.i18nTapToInteract, 'Tap to interact');
  const isDiagramFullscreen = () => _isDiagramFullscreen(ownerDoc, wrapper);
  const state = createState(opts);
  /* Scene-Erweiterungs-Registry (Pro) + Init-Layout-Deskriptor einmal aufloesen; beide
   * werden ab hier in der gesamten Orchestrierung statt hartcodierter Pro-Layout-Namen
   * abgefragt. Ohne Pro sind alle Pro-Flags false und sceneExt ist null. */
  const sceneExt = typeof window !== 'undefined' ? window.__CE_PD_SCENE_EXT__ : null;
  const initLayoutDesc = resolveInitLayoutDescriptor(state.layout);

  /* Resume aus Deep-Dispose: Wenn der User vor dem Dispose per 2D/3D-Button umgeschaltet
   * hat, weicht sein Soll-Zustand von opts.defaultProjection (= Redakteur-Default) ab.
   * `state.isOrthographic` hier VOR dem Kamera-/Layout-Setup aus dem Snapshot übernehmen,
   * damit FOV-Init (Pseudo-Ortho-Start-FOV) und applyPseudoOrthoPose() im Resume-Block
   * tatsächlich greifen und die Kamera nicht in die falsche Projektion rendert. Layouts
   * ohne 2D-Support (z. B. arc/surface) bleiben 3D hart forciert (kein toggle). */
  if (consumedResumeState && consumedResumeState.camera
      && typeof consumedResumeState.camera.isOrthographic === 'boolean'
      && layoutSupportsOrthographic(state.layout)) {
    state.isOrthographic = consumedResumeState.camera.isOrthographic;
  }

  /* ── Idle detection ── */
  const DIRTY_EXTRA_FRAMES = 3;
  const DAMPING_SETTLE_FRAMES = 120;
  let sceneDirtyFrames = DIRTY_EXTRA_FRAMES;
  let sceneInitComplete = false;
  function markDirty(frames) {
    if (state.disposed) return;
    sceneDirtyFrames = Math.max(sceneDirtyFrames, frames || DIRTY_EXTRA_FRAMES);
    if (sceneInitComplete && state.animId === null && !ownerDoc.hidden && state.blockVisible && !state.hibernated) animate(performance.now());
  }

  const legendPosition = (opts.legendPosition === 'bottom' || opts.legendPosition === 'hidden') ? opts.legendPosition : 'left';
  wrapper.classList.toggle('ce-diagram-wrapper--transparent-bg', opts.backgroundTransparent === true);
  wrapper.classList.toggle('ce-diagram-wrapper--legend-bottom', legendPosition === 'bottom');
  wrapper.style.setProperty('--ce-diagram-ruler-color', opts.rulerColor || '#858585');
  const seriesAxisXText = typeof opts.axisXLabel === 'string' ? opts.axisXLabel.trim().slice(0, 48) : '';
  const axisYLabelText = typeof opts.axisYLabel === 'string' ? opts.axisYLabel.trim().slice(0, 48) : '';
  const seriesAxisXLabelTf = mergeSeriesAxisXLabelTransform(opts.seriesAxisXLabelTransform);
  const scatterAxisXLabel = typeof opts.scatterAxisXLabel === 'string' ? opts.scatterAxisXLabel.trim().slice(0, 48) : '';
  const scatterAxisYLabel = typeof opts.scatterAxisYLabel === 'string' ? opts.scatterAxisYLabel.trim().slice(0, 48) : '';
  const scatterAxisZLabel = typeof opts.scatterAxisZLabel === 'string' ? opts.scatterAxisZLabel.trim().slice(0, 48) : '';
  const bubblePackLegendHint = i18nStr(opts.i18nBubblePackLegendHint, '');

  /* ── Axis ── */
  const axisYMin = Math.max(-1e8, Math.min(1e8, Number(opts.axisY?.min) || 0));
  const axisYMax = Math.max(-1e8, Math.min(1e8, Number(opts.axisY?.max) ?? 100));
  const axisYHasNewFields = opts.axisY?.unitPrefix != null || opts.axisY?.unitSuffix != null;
  const axisYPrefix = axisYHasNewFields ? String(opts.axisY?.unitPrefix ?? '') : (opts.axisY?.unitPosition === 'before' ? String(opts.axisY?.unit ?? '') : '');
  const axisYSuffix = axisYHasNewFields ? String(opts.axisY?.unitSuffix ?? '%') : (opts.axisY?.unitPosition === 'before' ? '' : String(opts.axisY?.unit ?? '%'));
  const axisYRange = Math.max(axisYMax - axisYMin, 1e-6);
  const valueToNorm = (v) => Math.max(0, Math.min(1, (Number(v) - axisYMin) / axisYRange));

  const formatChartNumber = typeof opts.formatChartNumber === 'function'
    ? opts.formatChartNumber
    : createChartNumberFormatter(opts.numberLocale).formatTick;

  /** Tooltip / data table / bubble labels: axis tick format unless the row has its own unit suffix. */
  function formatDataPointDisplayValue(d) {
    const uRaw = d.unit != null ? String(d.unit).trim() : '';
    if (!shouldFormatRowUnitSeparately(uRaw, axisYPrefix, axisYSuffix)) {
      const num = (axisYPrefix ? axisYPrefix + '\u200A' : '') + formatChartNumber(d.value);
      return joinHtmlTickValueAndSuffix(num, axisYSuffix);
    }
    return joinHtmlTickValueAndSuffix(formatChartNumber(d.value), uRaw);
  }

  const getCategoryLabel = (i, fallback) => fallback || '';

  const gridEnabled = opts.gridEnabled !== false;
  const rulerEnabled = opts.rulerEnabled !== false;
  const groupedBarLabelAboveMode = resolveGroupedBarLabelAbove(opts);
  const stackedBarSumAbove = resolveStackedBarSumAbove(opts);
  const arcBarLabelAboveMode = resolveArcBarLabelAbove(opts);
  const radialBarLabelMode = resolveRadialBarLabel(opts);
  const straightSlots = resolveStraightBarLabels(opts);
  const horizontalSlots = resolveHorizontalBarLabels(opts);
  const waveNodeMode = resolveWaveLabelAtNodes(opts);
  const waveFloorMode = resolveWaveLabelAlongFloor(opts);

  /* Layout-spezifische Optionen werden vom jeweiligen Layout-Treiber selbst
   * aus `opts` ausgelesen (siehe `__CE_PD_LAYOUTS__`). */
  const pieCalloutLineLength = resolvePieCalloutLineLength(opts);
  const pieCalloutFontScale = resolvePieCalloutFontScale(opts);
  let waveMultiSeriesFirstGroup = null;
  if (initLayoutDesc.isWave && opts.waveMultiSeries === true) {
    const firstKey = DATA.find(d => d.groupKey != null && String(d.groupKey).trim() !== '')?.groupKey;
    if (firstKey != null) {
      waveMultiSeriesFirstGroup = DATA.filter(d => d.groupKey === firstKey);
    }
  }

  const baseRulerColor = opts.rulerColor || '#858585';
  const rulerStyle = normalizeRulerStyle(opts.rulerStyle);
  const rulerColorAttr = (typeof opts.rulerColorAttr === 'string' && opts.rulerColorAttr.trim() !== '')
    ? opts.rulerColorAttr.trim()
    : '';
  const effectiveBarLabelColor = (typeof opts.barLabelColor === 'string' && opts.barLabelColor.trim() !== '') ? opts.barLabelColor.trim() : baseRulerColor;
  const effectiveGroupLabelColor = (typeof opts.groupLabelColor === 'string' && opts.groupLabelColor.trim() !== '') ? opts.groupLabelColor.trim() : baseRulerColor;

  const backgroundTransparent = opts.backgroundTransparent === true;
  /** Hintergrundfarbe hinter transparentem WebGL im Browser-Vollbild (sonst wirkt die Fläche schwarz). */
  const FULLSCREEN_TRANSPARENT_BACKDROP = '#ebebeb';
  const backgroundColor = opts.backgroundColor || '#f8f9fa';

  /* ── Header / Footnote / external legend: Höhen siehe createStageHeight (live query) ── */
  let externalLegendEl = null;
  {
    const userHeader = resolveHeaderTextColorAttr(opts.headerTextColor);
    if (userHeader) {
      wrapper.style.setProperty('--ce-header-color', userHeader);
    } else if (!backgroundTransparent) {
      const autoColor = contrastTextColor(backgroundColor);
      if (autoColor) wrapper.style.setProperty('--ce-header-color', autoColor);
    }
  }
  if (opts.headerAlign === 'center' || opts.headerAlign === 'right') {
    wrapper.style.setProperty('--ce-header-align', opts.headerAlign);
  }

  const alwaysColorful = opts.alwaysColorful === true;
  const interactionEnabled = opts.interactionEnabled !== false;
  /** Mobile: Snapshot + Pill, kein Auto-Wake beim Sichtbarwerden; WebGL erst nach Tipp. */
  const TAP_TO_INTERACT_MOBILE = IS_MOBILE_GLOBAL && interactionEnabled;
  /** Live-Session = Scene läuft interaktiv (nach Wake); Pill/Auto-Hibernate werden dann unterdrückt.
   *  Beim Resume aus Deep-Dispose hat der User bereits auf die Pill getippt → direkt Live-Session,
   *  sonst würde Init + IO-Handler ein zweites Mal hibernieren und die Pill erneut anzeigen. */
  let tapInteractLiveSession = TAP_TO_INTERACT_MOBILE && !!consumedResumeState;
  /** Tap-Mode: nicht mit blockVisible=true raten — verhindert, dass visibilityLifecycle vor dem
   *  ersten IO fälschlich „sichtbar“ annimmt. Der eigentliche erste Hibernate + Pill kommt aus dem
   *  Init-rAF (nach Troika-Sync), nicht von blockVisible — sonst fehlt die Pill bei spätem IO (Dev). */
  if (TAP_TO_INTERACT_MOBILE && !consumedResumeState) {
    state.blockVisible = false;
    state.intersectionRatio = 0;
    state.intersectionIsIntersecting = false;
  }
  /* Feature disabled: fixed heights per breakpoint cause portrait aspect ratios
     on narrow mobile containers, pushing the 3D camera too far back.
     Auto-calculation (w / stageAspectMin + padV) keeps proportions correct.
     Set to true to re-enable reading saved block values. */
  const ENABLE_DIAGRAM_MAX_HEIGHT = true;
  const diagramMaxByBp = ENABLE_DIAGRAM_MAX_HEIGHT && opts.diagramMaxHeightByBreakpoint && typeof opts.diagramMaxHeightByBreakpoint === 'object' ? opts.diagramMaxHeightByBreakpoint : null;
  const diagramMaxHeightLegacy = ENABLE_DIAGRAM_MAX_HEIGHT && typeof opts.diagramMaxHeightLegacy === 'string' ? opts.diagramMaxHeightLegacy.trim() : '';

  /* ── Dimensions ── */
  const count = DATA.length;
  const dataForLegacyCharts = initLayoutDesc.isSurface ? DATA.slice(0, MAX_BARS) : DATA;
  const effectiveBarCount = dataForLegacyCharts.length;
  state.width = container.clientWidth;
  state.height = container.clientHeight;
  /* Stacked zeichnet pro groupKey nur EINE Säule (Segmente stapeln vertikal). Breite/Abstände
   * daher aus der Spaltenzahl (G) ableiten, nicht aus der Zeilenzahl — sonst werden die Säulen
   * so eng dimensioniert wie bei N nebeneinanderstehenden Balken → Chart zu schmal, Wrapper zu hoch.
   * (Gleiche Keying-Logik wie buildStackedLayout: leerer groupKey = eigene Einzel-Spalte.) */
  const sizingBarCount = state.layout === 'stacked'
    ? Math.max(1, countStackedColumns(dataForLegacyCharts))
    : effectiveBarCount;
  const barScale = Math.min(1, Math.max(0.25, 5 / sizingBarCount));
  const rawThickness = Number(opts.barThicknessScale);
  const barThicknessScale = Number.isFinite(rawThickness)
    ? Math.min(1.7, Math.max(0.4, rawThickness))
    : 1;
  const dataLabelFontScale = clampDataLabelFontScale(opts.dataLabelFontScale);
  const barWidth = 0.45 * barScale * barThicknessScale;
  /* Cap 4/n so very few bars do not get excessive gap (tighter, more centered composition). */
  const straightSpacing = 0.75 * Math.max(0.2, Math.min(1.28, 4 / Math.max(sizingBarCount, 1)));
  const sceneYScale = (() => { const n = Number(opts.sceneYScale); return Number.isFinite(n) ? Math.min(3, Math.max(0.2, n)) : 1; })();
  const maxBarHeight = 4 * sceneYScale;
  let groupedLayout = null;
  let stackedLayout = null;
  if (initLayoutDesc.isGrouped) {
    groupedLayout = _laCallMethod('grouped', 'buildLayout', DATA, barWidth, barScale);
    _laCallMethod('grouped', 'scaleToStraightSpan', groupedLayout, barWidth, straightSpacing, effectiveBarCount);
    for (let i = 0; i < DATA.length; i++) {
      DATA[i]._paletteIndex = groupedLayout.slotInGroup[i];
    }
  } else if (state.layout === 'stacked') {
    stackedLayout = buildStackedLayout(DATA, barWidth, barScale, straightSpacing, valueToNorm, maxBarHeight);
    for (let i = 0; i < DATA.length; i++) {
      DATA[i]._paletteIndex = stackedLayout.slotInGroup[i];
    }
  }
  const clusterLayout = groupedLayout || stackedLayout;
  const paletteBarCount = clusterLayout ? Math.max(clusterLayout.Smax, 1) : count;
  const HOVER_PALETTE = (opts.colorScheme === 'theme'
      && Array.isArray(opts.themePalette) && opts.themePalette.length)
    ? opts.themePalette
    : getHoverPaletteForScheme(opts.colorScheme, paletteBarCount, opts.customPalette);
  const uniformDataColor = parseBarColor(typeof opts.defaultDataColor === 'string' ? opts.defaultDataColor : '');
  const barDepth = 0.38 * barScale;
  /** Same offset as rulerGroup.position.z uses via createRuler (world plane of ticks / Y-rail ≈ −this). */
  const arcRulerZOffset = 0.35;
  /** Entire bar depth must sit in front of that plane or grid lines cut through the columns (worse when barDepth is large, e.g. few bars). */
  const STRAIGHT_BAR_Z = -arcRulerZOffset + barDepth * 0.5 + 0.04;
  /** Horizontal: etwas geringere Tiefe (Z) + Z-Mitte wie Straight relativ zur Lineal-Ebene. */
  const horizontalBarDepthFactor = 0.87;
  const barDepthForMesh = initLayoutDesc.isHorizontal ? barDepth * horizontalBarDepthFactor : barDepth;
  /** Fast auf der Linealebene; minimale Lücke nur gegen Z-Fighting (Straight bleibt bei 0.04). */
  const HORIZONTAL_BAR_RULER_MARGIN = 0.008;
  /** Wave: etwas weiter als Balken, damit Punktkugeln (Radius) nicht ins Lineal schneiden. */
  const WAVE_RULER_PLANE_MARGIN = 0.044;
  const HORIZONTAL_BAR_Z = -arcRulerZOffset + barDepthForMesh * 0.5 + HORIZONTAL_BAR_RULER_MARGIN;
  const radius = 2.2;
  const horizontalLeftEdge = -2;
  const horizontalRulerPadding = 0.35;
  const horizontalBaseOffset = 0.22;
  const horizontalCenterY = ((effectiveBarCount - 1) / 2) * straightSpacing + barWidth / 2 + horizontalRulerPadding + horizontalBaseOffset;

  /* ── Layout calculations ── */
  function getBarLayout(layout, i, h) {
    /* Auf dem uebergebenen layout-Argument basieren (nicht state.layout): groupedBarLabels
     * ruft z. B. explizit getBarLayout('straight', ...). Add-on-Bar-Geometrien
     * (horizontal/grouped) werden ueber den Deskriptor erkannt, bleiben aber als
     * Free-Geometrie hier. Free-eigene Layouts (straight/stacked) direkt; arc = Default. */
    const d = resolveInitLayoutDescriptor(layout);
    if (d.isHorizontal) {
      const x = horizontalLeftEdge + h / 2;
      const y = ((effectiveBarCount - 1) / 2 - i) * straightSpacing + horizontalCenterY;
      return { x, y, z: HORIZONTAL_BAR_Z, rotationY: 0, rotationZ: -Math.PI / 2 };
    }
    if (layout === 'straight') {
      const x = (i - (effectiveBarCount - 1) / 2) * straightSpacing;
      return { x, y: h / 2, z: STRAIGHT_BAR_Z, rotationY: 0, rotationZ: 0 };
    }
    if (d.isGrouped && groupedLayout) {
      const x = groupedLayout.barX[i];
      return { x, y: h / 2, z: STRAIGHT_BAR_Z, rotationY: 0, rotationZ: 0 };
    }
    if (layout === 'stacked' && stackedLayout) {
      const x = stackedLayout.barX[i];
      const b = stackedLayout.stackBottomY[i];
      /* y = stack foot (wrapper origin); mesh is offset +h/2 so scale.y animates from bottom. */
      return { x, y: b, z: STRAIGHT_BAR_Z, rotationY: 0, rotationZ: 0 };
    }
    const angleStep = effectiveBarCount > 1 ? (Math.PI * 0.55) / (effectiveBarCount - 1) : 0;
    const startAngle = -Math.PI * 0.275;
    const angle = startAngle + angleStep * i;
    const x = Math.sin(angle) * radius;
    const z = -Math.cos(angle) * radius + radius * 0.4;
    return { x, y: h / 2, z, rotationY: -angle, rotationZ: 0 };
  }

  /* ── Scene ── */
  const scene = new THREE.Scene();
  if (backgroundTransparent) { scene.background = null; }
  else { scene.background = new THREE.Color(backgroundColor); }

  /** Frame-Compute wird via `createLayoutFrame` gebunden, sobald alle Chart-Groups finalisiert
   *  sind (Groups werden nach Erstzuweisung via Destructuring überschrieben — siehe waveGroup,
   *  radialGroup usw.). Bis dahin: Placeholder, die nichts tun. camCtl/stageHeight rufen
   *  getLayoutFrame erst in Resize/Animate auf → der Placeholder wird nie aktiv befragt. */
  let getLayoutFrame = () => null;
  let invalidateFrameCache = () => {};

  /* `bgHandle` wird erst unten nach Layout-Setup in `createBackground()` instanziiert.
   * Die Deklaration steht hier früh, damit der Projection-Tween-Progress-Callback des
   * Camera-Controllers (unten) per Closure late-bound darauf zugreifen kann. Solange
   * der Screen-Grid-BG noch nicht existiert (3D-only-Modes, gradient/reflector), ist
   * der Callback ein No-Op. */
  let bgHandle = null;

  const projectionFlatRegistry = createProjectionFlatRegistry();

  /* ── Camera ── */
  const camCtl = createCameraController({
    THREE,
    state,
    container,
    getLayoutFrame: (layout) => getLayoutFrame(layout),
    markDirty,
    DAMPING_SETTLE_FRAMES,
    interactionEnabled,
    /* Projection-Tween ↔ Screen-Grid-BG-Crossfade: t=0 (3D-Kulisse voll sichtbar) bis
     * t=1 (flaches Millimeter-Grid voll sichtbar). Beim Tween zu '2d' direkt t=t, bei
     * Tween zu '3d' invertiert (1-t), damit der BG-Quad ausfadet während die 3D-Kulisse
     * wieder einfadet. Balken-/Wave-Phong-Materialien: gleicher Zeitverlauf → flache Albedo. */
    onProjectionTweenProgress: (tEased, targetProjection) => {
      projectionTweenToFlatness(tEased, targetProjection, projectionFlatRegistry);
      if (bgHandle && bgHandle.setGridMorph) {
        bgHandle.setGridMorph(targetProjection === '2d' ? tEased : 1 - tEased);
      }
    },
  });
  const { perspectiveCamera } = camCtl;
  /* Seit dem Pseudo-Ortho-Refactor ist perspectiveCamera immer die aktive Kamera. Der
   * Getter camCtl.activeCamera ist ein API-Kompat-Alias (liefert dieselbe Referenz) und
   * wird an Stellen genutzt, die historisch camera-agnostisch geschrieben wurden. */

  /** TDZ-Vermeidung: Wird vom frühen `controls.addEventListener('change', ...)`-Handler
   *  geschrieben; dieser feuert ggf. schon beim Resume-`controls.update()` weit oberhalb
   *  der alten Deklarationsstelle. Deklaration hier ganz früh setzen. */
  const pickCache = { mouseX: NaN, mouseY: NaN };

  /* Für Wave-Area-Compositing: world-position scratch (Camera-Modul nutzt eigene Scratches). */
  const _waveCamZScratch = new THREE.Vector3();

  /* ── Layout settle via RAF (single source of truth for all resize paths) ── */
  let _settleTimer = null;
  let _pendingAfterSettle = null;
  function settleLayout(afterSettle) {
    if (afterSettle) _pendingAfterSettle = afterSettle;
    if (_settleTimer) cancelAnimationFrame(_settleTimer);
    function flush() {
      _settleTimer = null;
      recalcFit();
      markDirty();
      if (_pendingAfterSettle) { const cb = _pendingAfterSettle; _pendingAfterSettle = null; cb(); }
    }
    _settleTimer = requestAnimationFrame(flush);
  }

  /* ── Renderer ── */
  const isMobile = 'ontouchstart' in window || window.innerWidth < 768;
  /** Effektives Cap: WEBGL_PIXEL_RATIO_CAP in shared/config.js (Desktop vs. Mobile/Tablet). */
  function getWebGlPixelRatioCap() {
    return isMobile ? WEBGL_PIXEL_RATIO_CAP.mobileOrTablet : WEBGL_PIXEL_RATIO_CAP.desktop;
  }
  function applyWebGlPixelRatio(r) {
    const cap = getWebGlPixelRatioCap();
    r.setPixelRatio(Math.min(window.devicePixelRatio || 1, cap));
  }
  /** `preserveDrawingBuffer: false` explizit: spart unter iOS Safari einen kompletten
   *  Duplikat-Framebuffer pro Renderer (Default ist browserabhängig — Safari war früher
   *  implicit `true`). Der PNG-Export läuft über `ownCanvas.toDataURL()`, daher brauchen
   *  wir den erhalten-bleibenden WebGL-Buffer nicht. */
  const rendererOpts = { antialias: true, alpha: true, powerPreference: 'low-power', preserveDrawingBuffer: false };

  function createRenderer() {
    let r;
    /** Wenn WebGL2 nicht verfügbar ist, pre-erstellen wir einen WebGL1-
     *  Kontext und reichen ihn an Three weiter. Three überspringt damit
     *  seinen internen WebGL2-Versuch und Browser/Three loggen den Fehler
     *  nicht mehr pro Chart-Init. */
    if (!isWebGL2Available()) {
      const canvas = document.createElement('canvas');
      const ctxAttrs = {
        antialias: rendererOpts.antialias,
        alpha: rendererOpts.alpha,
        powerPreference: rendererOpts.powerPreference,
        preserveDrawingBuffer: rendererOpts.preserveDrawingBuffer,
      };
      const gl = canvas.getContext('webgl', ctxAttrs)
        || canvas.getContext('experimental-webgl', ctxAttrs);
      r = new THREE.WebGLRenderer({ ...rendererOpts, canvas, context: gl });
    } else {
      r = new THREE.WebGLRenderer(rendererOpts);
    }
    r.setSize(state.width, state.height);
    applyWebGlPixelRatio(r);
    r.setClearColor(0x000000, 0);
    /* Shadow-Map global aktiviert: kostet nichts, solange kein Licht `castShadow=true`
     * setzt (Three überspringt den Shadow-Pass ohne Caster). pieStairs/pyramid/pie/wave
     * schalten den keyLight-Schatten in lights.js scharf. PCF + shadow.radius = weiche
     * Ränder (PCFSoftShadowMap ist in dieser Three-Version deprecated). */
    r.shadowMap.enabled = true;
    r.shadowMap.type = THREE.PCFShadowMap;
    r.toneMapping = THREE.NoToneMapping;
    r.toneMappingExposure = 1;
    r.outputColorSpace = THREE.SRGBColorSpace;
    return r;
  }

  function acquireRenderer() {
    const r = poolAcquireRenderer(createRenderer, wrapper);
    /** Shared-Renderer-Pfad (Mobile/iOS oder Desktop ohne WebGL2): Canvas gehört ggf.
     *  noch dem zuletzt aktiven Chart mit anderer Größe — vor Render auf unsere
     *  Dimensionen umkonfigurieren. Per-Chart-Pfad: immer ein frisch erstellter
     *  Renderer; setSize dort ist redundant aber harmlos. */
    if (r) {
      const w = state.width > 0 ? state.width : container.clientWidth;
      const h = state.height > 0 ? state.height : container.clientHeight;
      if (w > 0 && h > 0) r.setSize(w, h);
      applyWebGlPixelRatio(r);
    }
    return r;
  }

  let renderer = acquireRenderer();
  if (!renderer) {
    scene.clear();
    wrapper.classList.add('loaded');
    const loader = wrapper.querySelector('.ce-diagram-loader');
    if (loader) {
      loader.classList.add('ce-diagram-loader-error');
      loader.setAttribute('aria-busy', 'false');
    }
    const noopCleanup = () => {};
    wrapper._ceDiagramCleanup = noopCleanup;
    return noopCleanup;
  }

  /** Sofort registrieren, damit Eviction beim nächsten acquireRenderer dieses Wrappers sieht
   *  (vor createDiagramViewportLifecycle existiert noch kein vollständiges hibernate — Delegation unten). */
  const activeRendererEntry = {
    isFullscreen: isDiagramFullscreen,
    isInteracting: () => state.isInteracting,
    ratio: 1,
    _hibernateImpl: null,
  };
  activeRendererEntry.hibernate = function hibernateForBudget(forceForBudget) {
    const impl = activeRendererEntry._hibernateImpl;
    if (typeof impl === 'function') return impl(forceForBudget);
    try {
      unregisterActiveRenderer(wrapper);
      releaseRenderer(renderer);
    } catch (_) { /* ignore */ }
  };
  registerActiveRenderer(wrapper, activeRendererEntry);

  if (state.width <= 0 || state.height <= 0) {
    state.width = container.clientWidth;
    state.height = container.clientHeight;
    if (state.width > 0 && state.height > 0) {
      renderer.setSize(state.width, state.height);
      applyWebGlPixelRatio(renderer);
    }
  }
  /* ── Own 2D canvas: permanent per-diagram display surface ── */
  const ownCanvas = ownerDoc.createElement('canvas');
  const ownCtx = ownCanvas.getContext('2d');
  Object.assign(ownCanvas.style, { position: 'absolute', inset: '0', width: '100%', height: '100%' });
  const { leftoverSnapshots } = mountDiagramViewportCanvas({ container, wrapper, ownCanvas });

  /* ── Controls ── */
  if (!interactionEnabled) wrapper.classList.add('ce-diagram-interaction-disabled');

  let controls = new OrbitControls(camCtl.activeCamera, ownCanvas);
  camCtl.configureControls(controls);
  camCtl.attachControls(controls);

  ownCanvas.addEventListener('wheel', camCtl.onPerspectiveWheel, { passive: false });
  camCtl.bindPerspectivePinch(ownCanvas);

  const attachControlsEventListeners = (c) => {
    c.addEventListener('start', () => {
      state.cameraCenterTarget.position = null;
      state.cameraCenterTarget.lookAt = null;
      markDirty();
    });
    c.addEventListener('change', () => { pickCache.mouseX = NaN; markDirty(); });
  };
  attachControlsEventListeners(controls);

  /* ── Renderer-Lifecycle: Hibernate/Wake + WebGL-Context-Loss ── */
  let _wakeSkipCameraRefit = false;
  let _fullscreenSkipCameraRefit = false;
  /** Resize/recalcFit: Layout-Refit (Pseudo-Ortho + Perspektiv) nach Troika-Idle — sonst LayoutFrame mit Pre-Sync-BBox. */
  let _pendingResizeRefit = false;
  /** Mobile (iOS + Android): kürzer off-screen bis Hibernate → schnelleres Freigeben des Renderer-
   *  Slots, weniger RAM-Akkumulation auf speicherengen Tabs. Vorher hielt Android 1.5 s länger
   *  fest als iOS — auf Pixel 4 wirkte sich das in OOM-Tab-Kills aus. */
  const HIBERNATE_DELAY_MS = isMobile ? 4500 : 3000;

  /** Mutables Objekt: `renderComposite` setzt `diagramViewportLifecycle.js`, später Background-Zweipass. */
  const renderLifecycleOps = {};

  const { blitToOwnCanvas, rendererLifecycle, bindRendererEvents } = createDiagramViewportLifecycle({
    leftoverSnapshots,
    ownCanvas,
    ownCtx,
    container,
    wrapper,
    renderLifecycleOps,
    scene,
    camCtl,
    markDirty,
    getRenderer: () => renderer,
    setRenderer: (v) => { renderer = v; },
    OrbitControls,
    ownerDoc,
    state,
    IS_IOS_GLOBAL,
    isDiagramFullscreen,
    acquireRenderer,
    releaseRenderer,
    registerActiveRenderer,
    unregisterActiveRenderer,
    getControls: () => controls,
    setControls: (v) => { controls = v; },
    getActiveCamera: () => camCtl.activeCamera,
    getAnimate: () => animate,
    getCleanup: () => cleanup,
    getRecalcFit: () => recalcFit,
    attachControlsEventListeners,
    onWakeSkipCameraRefit: (v) => { _wakeSkipCameraRefit = v; },
    tapToInteractMobile: TAP_TO_INTERACT_MOBILE,
    invalidateLayoutFrameCache: () => { invalidateFrameCache(); },
  });

  let tapHintEl = null;
  function tapInteractChartInViewport() {
    const r = typeof state.intersectionRatio === 'number' ? state.intersectionRatio : 0;
    return state.blockVisible || r > 0 || state.intersectionIsIntersecting === true;
  }
  function updateTapHintVisibility() {
    /** Pill = Hibernate; Top-Actions bündig dazu (IO bei Reinscrollen ruft diese Funktion —
     *  früher `if (!tapHintEl) return` ließ die Action-Leiste stehen, wenn z. B. der Callback-Reihenfolge
     *  nach das Pill-Element noch nicht existierte). */
    const show = TAP_TO_INTERACT_MOBILE && state.hibernated && !state.disposed;
    const topActionsEl = wrapper.querySelector('.ce-diagram-top-actions');
    if (TAP_TO_INTERACT_MOBILE && topActionsEl) {
      topActionsEl.classList.toggle('ce-diagram-top-actions--ce-suppressed', show);
      if (show) topActionsEl.setAttribute('aria-hidden', 'true');
      else topActionsEl.removeAttribute('aria-hidden');
    }
    if (!tapHintEl) return;
    tapHintEl.hidden = !show;
    tapHintEl.setAttribute('aria-hidden', show ? 'false' : 'true');
    /** Nach Hibernate/Wake wechselt die Kinder-Reihenfolge (insertBefore). Ohne letztes Kind + z-index
     *  kann das volle Canvas/Snapshot die Pill verdecken — Klick weckt noch (Bubble), Pill wirkt „unsichtbar“. */
    if (show && tapHintEl.parentNode === container) {
      container.appendChild(tapHintEl);
    }
  }

  function wake() {
    rendererLifecycle.wake();
    if (TAP_TO_INTERACT_MOBILE) tapInteractLiveSession = true;
    updateTapHintVisibility();
  }
  function hibernate(force) {
    if (TAP_TO_INTERACT_MOBILE) tapInteractLiveSession = false;
    rendererLifecycle.hibernate(force);
    updateTapHintVisibility();
  }

  /** Mobile: jeder Wake (Tap, Vollbild, PNG, …) über `_gpuSerialChain` — sonst parallel `acquireRenderer` trotz MAX=1 (iOS). */
  const wakeGpuSerial = IS_MOBILE_GLOBAL
    ? () => enqueueSerializedWake(() => { wake(); })
    : wake;

  if (TAP_TO_INTERACT_MOBILE) {
    wrapper.classList.add('ce-diagram-wrapper--tap-interact');
    try {
      const taPre = wrapper.querySelector('.ce-diagram-top-actions');
      if (taPre) {
        taPre.classList.remove('ce-diagram-top-actions--ce-suppressed');
        taPre.removeAttribute('aria-hidden');
      }
    } catch (_) { /* ignore */ }
    /** Nach Deep-Dispose bleibt die alte Pill im DOM (damit sie bei Re-Scroll sofort sichtbar ist).
     *  Beim Re-Init hier entfernen, bevor die neue Pill mit frischen Event-Handlern erzeugt wird. */
    const staleTapHint = container.querySelector('.ce-diagram-tap-hint');
    if (staleTapHint && staleTapHint.parentNode) staleTapHint.parentNode.removeChild(staleTapHint);
    tapHintEl = ownerDoc.createElement('div');
    tapHintEl.className = 'ce-diagram-tap-hint';
    tapHintEl.setAttribute('role', 'button');
    tapHintEl.setAttribute('tabindex', '0');
    tapHintEl.setAttribute('aria-label', i18nTapToInteract);
    const tapLabel = ownerDoc.createElement('span');
    tapLabel.className = 'ce-diagram-tap-hint__label';
    tapLabel.textContent = i18nTapToInteract;
    tapHintEl.appendChild(tapLabel);
    container.appendChild(tapHintEl);
    tapHintEl.hidden = true;
    tapHintEl.setAttribute('aria-hidden', 'true');
    tapHintEl.addEventListener('keydown', (ev) => {
      if (ev.key === 'Enter' || ev.key === ' ') {
        ev.preventDefault();
        try { wakeGpuSerial(); } catch (_) { /* ignore */ }
      }
    });
    updateTapHintVisibility();
  }

  bindRendererEvents(renderer, true);

  activeRendererEntry._hibernateImpl = hibernate;

  /* ── Meshes ── */
  const LAYOUT_LERP = 0.12;
  const diagramGroup = new THREE.Group();
  scene.add(diagramGroup);

  /* Box-Bar-Layouts teilen createBars()/barGroup. Free-eigene: straight/stacked.
   * Add-on-Box-Bars (arc/grouped/horizontal) melden sich ueber layoutHasBarThickness
   * an (exakt die ueber createBars gerenderten Pro-Layouts, siehe scene-pro). */
  const isBarLayout = state.layout === 'straight' || state.layout === 'stacked'
    || (sceneExt && typeof sceneExt.layoutHasBarThickness === 'function' && sceneExt.layoutHasBarThickness(state.layout) === true);
  const chartLeft = -((effectiveBarCount - 1) / 2) * straightSpacing;
  const chartRight = ((effectiveBarCount - 1) / 2) * straightSpacing;
  const chartTop = maxBarHeight;

  /* Only create meshes for the active layout; others get empty stubs. */
  let barGroup = new THREE.Group(); let bars = []; let barInstMeshes = []; let geoTemplateH = 1;
  let waveGroup = new THREE.Group(); let waveMesh = null; let waveMeshes = []; let waveSphereInstMesh = null; let waveSphereInstances = [];
  let wavePoints = [];
  let waveAreaMeshes = []; let waveShadowMeshes = []; let waveSeriesDepth = 0; let waveSeriesEndInfo = []; let waveSphereRadius = 0.05;
  let radialGroup = new THREE.Group(); let radialMeshes = []; let radialRingInfo = []; let radialMaxR = 0; let radialCenterY = 0;
  let pieGroup = new THREE.Group(); let pieMeshes = [];
  let surfaceGroup = new THREE.Group(); let surfaceMesh = null; let surfaceMeshes = [];
  let bubblePackGroup = new THREE.Group(); let bubblePackInstMesh = null; let bubblePackInstances = []; let bubblePackLabelMeshes = [];

  if (isBarLayout) {
    const barsCtx = { THREE, RoundedBoxGeometry, DATA: dataForLegacyCharts, valueToNorm, getBarLayout, layout: state.layout, barWidth, barDepth: barDepthForMesh, maxBarHeight, HOVER_PALETTE, GRAY_PALETTE, uniformDataColor, barCornerRadius: opts.barCornerRadius, barOpacity: opts.barOpacity, barGradientEnabled: opts.barGradientEnabled, reverseBarDrawOrder: initLayoutDesc.isHorizontal === true, projectionFlatRegistry };
    ({ barGroup, bars, barInstMeshes, geoTemplateH } = createBars(barsCtx));
  } else if (initLayoutDesc.isWave) {
    /* `opts` durchreichen, damit das Wave-Layout seine eigenen Konfigurationsfelder
     * auslesen kann, ohne dass init.js sie einzeln aufzaehlt. */
    const waveCtx = {
      THREE, DATA: dataForLegacyCharts, valueToNorm, chartLeft, chartRight, chartTop, count: effectiveBarCount,
      HOVER_PALETTE, GRAY_PALETTE, alwaysColorful, isMobile, uniformDataColor,
      projectionFlatRegistry,
      opts,
    };
    /* Dispatch ueber die aktuelle Layout-ID statt eines hartcodierten Namens:
     * z. B. 'wave' -> reg.wave. Add-on-Layouts, die dieselbe Wave-Ergebnisstruktur
     * liefern (nur mit anderer Grundlinie), werden ueber ihre eigene Layout-ID
     * dispatcht — Free kennt deren Namen nicht. */
    ({ waveGroup, waveMesh, waveMeshes, waveSphereInstMesh, waveSphereInstances, wavePoints, waveAreaMeshes, waveShadowMeshes, waveSeriesDepth, waveSeriesEndInfo, waveSphereRadius } = _laCall(state.layout, waveCtx));
  } else if (initLayoutDesc.isRadial) {
    const radialCtx = { THREE, DATA: dataForLegacyCharts, valueToNorm, count: effectiveBarCount, barDepth, HOVER_PALETTE, GRAY_PALETTE, alwaysColorful, uniformDataColor, barOpacity: opts.barOpacity, radialThickness: opts.radialThickness, projectionFlatRegistry };
    ({ radialGroup, radialMeshes, radialRingInfo, radialMaxR, radialCenterY } = _laCall('radial', radialCtx));
  } else if (isPyramidLayout(state.layout)) {
    const pyramidCtx = {
      THREE, DATA: dataForLegacyCharts, count: effectiveBarCount, HOVER_PALETTE, GRAY_PALETTE,
      alwaysColorful, uniformDataColor, projectionFlatRegistry,
      pyramidSegmentGap: resolvePyramidSegmentGap(opts),
      pyramidInverted: resolvePyramidInverted(opts),
      pyramidSliceLabelMode: resolvePyramidSliceLabelMode(opts),
      pyramidLabelAutoContrast: resolvePyramidLabelAutoContrast(opts),
      axisYPrefix, axisYSuffix, dataLabelFontScale,
      barLabelColor: effectiveBarLabelColor,
      rulerColor: baseRulerColor,
      formatChartNumber,
      pyramidAnnotation: resolvePyramidAnnotation(opts),
      pyramidAnnotationLineLength: resolvePyramidAnnotationLineLength(opts),
      pyramidAnnotationFontScale: resolvePyramidAnnotationFontScale(opts),
    };
    ({ pieGroup, pieMeshes } = createPyramid(pyramidCtx));
  } else if (isPieLayout(state.layout)) {
    const isStairs = state.layout === 'pieStairs';
    const pieCtx = {
      THREE, DATA: dataForLegacyCharts, count: effectiveBarCount, barDepth, HOVER_PALETTE, GRAY_PALETTE, alwaysColorful, uniformDataColor,
      axisYPrefix, axisYSuffix, dataLabelFontScale, canvasFontFamily: resolveCanvasFontFamily(wrapper), pieSliceLabelMode: resolvePieSliceLabelMode(opts), pieInnerRadius: resolvePieInnerRadius(opts),
      pieThickness: resolvePieThickness(opts),
      pieAngleBasis: resolvePieAngleBasis(opts),
      pieLabelAutoContrast: resolvePieLabelAutoContrast(opts),
      barLabelColor: effectiveBarLabelColor,
      rulerColor: baseRulerColor,
      pieCalloutLineLength,
      pieCalloutFontScale,
      pieCalloutColor: resolvePieCalloutColor(opts),
      backgroundColor: opts.backgroundColor || '#f8f9fa',
      backgroundTransparent: opts.backgroundTransparent === true,
      formatChartNumber,
      stairs: isStairs,
      stairStepHeight: resolveStairStepHeight(opts),
    };
    ({ pieGroup, pieMeshes } = createPie(pieCtx));
  } else if (initLayoutDesc.isSurface) {
    const surfaceCtx = {
      THREE, DATA, valueToNorm, rulerColor: opts.rulerColor || '#858585', rulerEnabled,
      scatterAxisXLabel, scatterAxisYLabel, scatterAxisZLabel, axisYMin, axisYMax, axisYPrefix, axisYSuffix, isMobile,
      surfaceColorScheme: opts.surfaceColorScheme || 'thermal',
      formatChartNumber,
      /* `opts` durchreichen, damit Add-on-Layouts wie Surface auf weitere
       * eigene Config-Felder zugreifen koennen, ohne dass init.js sie
       * einzeln kennen muss (z. B. Wertebereich-Overrides der X/Z-Achsen). */
      opts,
    };
    ({ surfaceGroup, surfaceMesh, surfaceMeshes } = _laCall('surface', surfaceCtx));
  } else if (initLayoutDesc.isBubblePack) {
    const formatBubbleValueLine = (d) => formatDataPointDisplayValue(d);
    const bubblePackCtx = {
      THREE, DATA: dataForLegacyCharts, HOVER_PALETTE, GRAY_PALETTE, alwaysColorful, isMobile, uniformDataColor,
      formatValueLine: formatBubbleValueLine, labelPixelRatio: renderer.getPixelRatio(), labelMaxAnisotropy: renderer.capabilities.getMaxAnisotropy(),
      bubbleLabelMode: resolveBubbleLabelMode(opts),
      bubbleLabelColor: resolveBubbleLabelColor(opts),
      bubbleLabelFontScale: dataLabelFontScale,
      barOpacity: opts.barOpacity,
      projectionFlatRegistry,
    };
    ({ bubblePackGroup, bubblePackInstMesh, bubblePackInstances, bubblePackLabelMeshes } = _laCall('bubblePack', bubblePackCtx));
  }

  diagramGroup.add(barGroup);
  barGroup.visible = isBarLayout;

  if (sceneExt && typeof sceneExt.runAfterBars === 'function' && isBarLayout) {
    sceneExt.runAfterBars({
      THREE,
      diagramGroup,
      barGroup,
      layout: state.layout,
      DATA: dataForLegacyCharts,
      groupedLayout,
      stackedLayout,
      valueToNorm,
      maxBarHeight,
      barWidth,
      barDepth: barDepthForMesh,
      straightSpacing,
      effectiveBarCount,
      STRAIGHT_BAR_Z,
      HORIZONTAL_BAR_Z,
      horizontalLeftEdge,
      horizontalCenterY,
      renderer,
      perspectiveCamera,
      container,
      HOVER_PALETTE,
      uniformDataColor,
      opts,
    });
  }

  /* Wave: erste Serie (lokal z=0) fast in der Linealebene; weitere Serien nur um seriesZGap nach +Z. */
  if (initLayoutDesc.isWave) waveGroup.position.z = -arcRulerZOffset + WAVE_RULER_PLANE_MARGIN;
  waveGroup.userData._waveNodeLabelMeshes = [];
  if (initLayoutDesc.isWave && waveNodeMode !== 'off') {
    const { group: waveNodeLabelGroup, meshes: waveNodeLabelMeshes } = _callExtLabel(THREE, 'createWaveNodeLabels', {
      THREE, DATA: dataForLegacyCharts, wavePoints, waveSphereInstances, waveSphereRadius,
      barLabelColor: effectiveBarLabelColor, rulerColor: baseRulerColor,
      mode: waveNodeMode === 'value' ? 'value' : 'headline', axisYPrefix, axisYSuffix, dataLabelFontScale,
      formatChartNumber,
    });
    waveGroup.userData._waveNodeLabelMeshes = waveNodeLabelMeshes;
    waveNodeLabelGroup.userData._excludeFromWaveFit = true;
    waveNodeLabelGroup.renderOrder = 1001;
    waveGroup.add(waveNodeLabelGroup);
  }
  waveGroup.userData._waveSeriesLabelMeshes = [];
  if (initLayoutDesc.isWave && waveSeriesEndInfo.length > 0) {
    const { group: waveSeriesLabelGroup, meshes: waveSeriesLabelMeshes } = _callExtLabel(THREE, 'createWaveSeriesLabels', {
      THREE, seriesEndInfo: waveSeriesEndInfo, dataLabelFontScale,
    });
    waveGroup.userData._waveSeriesLabelMeshes = waveSeriesLabelMeshes;
    waveSeriesLabelGroup.userData._excludeFromWaveFit = true;
    waveSeriesLabelGroup.renderOrder = 1001;
    waveGroup.add(waveSeriesLabelGroup);
  }
  waveGroup.userData._waveAnnotationMeshes = [];
  waveGroup.userData._waveAnnotationLines = [];
  if (initLayoutDesc.isWave) {
    const { group: waveAnnotationGroup, meshes: waveAnnotationMeshes, lineMeshes: waveAnnotationLines } = _callExtLabel(THREE, 'createWaveAnnotations', {
      THREE, DATA: dataForLegacyCharts, wavePoints, waveSphereInstances,
      barLabelColor: effectiveBarLabelColor, rulerColor: baseRulerColor, dataLabelFontScale,
      annotationLineLength: typeof opts.waveAnnotationLineLength === 'number' ? opts.waveAnnotationLineLength : 0.25,
      annotationFontScale: typeof opts.waveAnnotationFontScale === 'number' ? opts.waveAnnotationFontScale : 1,
    }, { lineMeshes: [] });
    if (waveAnnotationMeshes.length > 0) {
      waveGroup.userData._waveAnnotationMeshes = waveAnnotationMeshes;
      waveGroup.userData._waveAnnotationLines = waveAnnotationLines;
      waveAnnotationGroup.userData._excludeFromWaveFit = true;
      waveAnnotationGroup.renderOrder = 1002;
      waveGroup.add(waveAnnotationGroup);
    }
  }
  /* Initiale Gruppen-Sichtbarkeit: Free-eigenes pie direkt; die Pro-Nicht-Bar-
   * Layouts (wave/radial/surface/bubblePack) erst alle aus, dann die zustaendige
   * Gruppe via Hook an — identisch zur Logik in layoutApply.js. Ohne Pro bleibt
   * keine dieser Gruppen sichtbar (die Layouts existieren dann nicht). */
  waveGroup.visible = false;
  radialGroup.visible = false;
  surfaceGroup.visible = false;
  bubblePackGroup.visible = false;
  if (initLayoutDesc.isWave) {
    waveGroup.visible = true;
  } else if (sceneExt && typeof sceneExt.layoutGroupVisibility === 'function') {
    sceneExt.layoutGroupVisibility(state.layout, {
      barGroup, waveGroup, radialGroup, pieGroup, surfaceGroup, bubblePackGroup, rulerEnabled,
    });
  }
  diagramGroup.add(waveGroup);
  diagramGroup.add(radialGroup);

  pieGroup.visible = isPieLayout(state.layout) || isPyramidLayout(state.layout);
  diagramGroup.add(pieGroup);

  diagramGroup.add(surfaceGroup);
  diagramGroup.add(bubblePackGroup);

  /* ── Ruler ── */
  let leftmostBarX; let rightmostBarX;
  if (clusterLayout) {
    leftmostBarX = Infinity;
    rightmostBarX = -Infinity;
    for (let i = 0; i < count; i++) {
      const x = clusterLayout.barX[i];
      leftmostBarX = Math.min(leftmostBarX, x - barWidth / 2);
      rightmostBarX = Math.max(rightmostBarX, x + barWidth / 2);
    }
  } else {
    leftmostBarX = -((effectiveBarCount - 1) / 2) * straightSpacing;
    rightmostBarX = ((effectiveBarCount - 1) / 2) * straightSpacing;
  }
  /* Straight: leftmost/rightmost sind Balken-Mitten; gruppiert: bereits Kanten → andere Formel.
   * Tight-Ruler-Span (Add-on, z. B. Kurven-Layout): Lineal liegt mit minimalem Rand
   * direkt an der Geometrie statt mit Balken-Breiten-Puffer. Free-Layouts nutzen die
   * Default-Formel. */
  const usesTightRulerSpan = state.layout === 'wave'
    || !!(sceneExt && typeof sceneExt.layoutUsesTightRulerSpan === 'function'
    && sceneExt.layoutUsesTightRulerSpan(state.layout) === true);
  const wavePadX = 0.01;
  const diagramWidth = clusterLayout
    ? rightmostBarX - leftmostBarX + 0.5
    : usesTightRulerSpan
      ? rightmostBarX - leftmostBarX + wavePadX * 2
      : rightmostBarX - leftmostBarX + barWidth + 0.5;
  const rulerX = clusterLayout
    ? leftmostBarX - 0.25
    : usesTightRulerSpan
      ? leftmostBarX - wavePadX
      : leftmostBarX - barWidth / 2 - 0.25;
  const rulerLineWidth = 0.01;
  /* Arc X-label: anchor at last bar center X (getBarLayout). For count>1 last angle is always +π·0.275 — no arcEndAngle / barWidth. count===1 uses startAngle -π·0.275. */
  const ARC_XLABEL_OUTSET_X = 0.22;
  const arcLastBarAngle = effectiveBarCount > 1 ? Math.PI * 0.275 : -Math.PI * 0.275;
  const diagramRightEdgeX =
    initLayoutDesc.isArc
      ? Math.sin(arcLastBarAngle) * radius + ARC_XLABEL_OUTSET_X
      : rulerX + diagramWidth;
  const arcAngleMargin = barWidth / (2 * radius) + arcRulerZOffset * 0.28;
  const arcStartAngle = -Math.PI * 0.275 - arcAngleMargin;
  const arcEndAngle = Math.PI * 0.275 + arcAngleMargin;
  const arcZCenter = radius * 0.4;
  const arcRadius = radius + arcRulerZOffset * 0.45;
  const arcLeftX = Math.sin(arcStartAngle) * arcRadius;
  const arcLeftZ = -Math.cos(arcStartAngle) * arcRadius + arcZCenter;
  const arcRightX = Math.sin(arcEndAngle) * arcRadius;
  const arcRightZ = -Math.cos(arcEndAngle) * arcRadius + arcZCenter;
  const horizontalRulerBottom = horizontalCenterY - ((effectiveBarCount - 1) / 2) * straightSpacing - barWidth / 2 - horizontalRulerPadding;
  const horizontalRulerTop = horizontalCenterY + ((effectiveBarCount - 1) / 2) * straightSpacing + barWidth / 2 + horizontalRulerPadding;
  const horizontalRulerHeight = horizontalRulerTop - horizontalRulerBottom;

  const rulerCtx = {
    THREE, rulerHeight: maxBarHeight, rulerLineWidth, tickLineThickness: 0.01,
    diagramWidth, rulerX, diagramRightEdgeX, arcRadius, arcZCenter, arcStartAngle, arcEndAngle,
    arcLeftX, arcLeftZ, arcRightX, arcRightZ, arcRulerZOffset,
    count: effectiveBarCount, straightSpacing, horizontalCenterY, horizontalRulerBottom,
    horizontalLeftEdge, maxBarHeight, radius, getCategoryLabel, DATA,
    axisYMin, axisYMax, axisYPrefix, axisYSuffix, rulerEnabled, rulerColor: baseRulerColor,
    rulerStyle, backgroundColor, backgroundTransparent, rulerColorAttr,
    groupLabelColor: effectiveGroupLabelColor,
    layout: state.layout, horizontalRulerHeight,
    seriesAxisXLabel: seriesAxisXText,
    seriesAxisXLabelTransform: seriesAxisXLabelTf,
    axisYLabel: axisYLabelText,
    canvasFontFamily: resolveCanvasFontFamily(wrapper),
    labelsUnderBars: opts.labelsUnderBars === true,
    waveLabelAlongFloor: waveFloorMode,
    horizontalBarLabelLeft: horizontalSlots.left,
    horizontalBarLabelRight: horizontalSlots.right,
    horizontalBarLabelInside: horizontalSlots.inside,
    /* Pre-resolved bar colors (hex) fuer Auto-Contrast bei Inside-Labels.
     * Wird hier statisch berechnet, weil `barColorAtIndex` erst spaeter im
     * Scope deklariert wird und Surface-Cell-Colors fuer horizontal nicht
     * gebraucht werden. Format: '#rrggbb'. */
    barColorHexForRuler: initLayoutDesc.isHorizontal
      ? dataForLegacyCharts.map((d, i) => {
          const v = resolveDataItemColor(d, i, HOVER_PALETTE, uniformDataColor);
          const n = Number.isFinite(v) ? (v | 0) : 0;
          return '#' + (n >>> 0).toString(16).padStart(6, '0');
        })
      : null,
    straightBarLabelBelow: state.layout === 'straight' ? straightSlots.below : undefined,
    straightBarCenterZ: state.layout === 'straight' || state.layout === 'stacked' || initLayoutDesc.usesStraightBarPlane
      ? STRAIGHT_BAR_Z
      : initLayoutDesc.isHorizontal
        ? HORIZONTAL_BAR_Z
        : 0,
    barDepth,
    xCategoryCount: clusterLayout ? clusterLayout.G : waveMultiSeriesFirstGroup ? waveMultiSeriesFirstGroup.length : effectiveBarCount,
    groupedCategoryCenters: clusterLayout ? clusterLayout.groupCenterX : waveMultiSeriesFirstGroup ? waveMultiSeriesFirstGroup.map((_, j, arr) => {
      const t = arr.length > 1 ? j / (arr.length - 1) : 0.5;
      return chartLeft + t * (chartRight - chartLeft);
    }) : null,
    xCategoryLabels: clusterLayout ? clusterLayout.groupDisplayLabels : waveMultiSeriesFirstGroup ? waveMultiSeriesFirstGroup.map(d => d.label) : null,
    dataLabelFontScale,
    formatChartNumber,
  };
  /* Shared createRuler-Lineal: Free's eigene Bar-Layouts (in isBarLayout) plus die
   * Add-on-Layouts mit Ruler-Stil (arc/grouped/horizontal/wave). layoutHasRulerStyle
   * deckt exakt die createRuler-basierten Pro-Layouts ab (surface bringt eigenen Frame). */
  const needsSharedRuler = isBarLayout
    || state.layout === 'wave'
    || (sceneExt && typeof sceneExt.layoutHasRulerStyle === 'function' && sceneExt.layoutHasRulerStyle(state.layout) === true);
  let rulerGroup, mainLine, mainLineRight;
  if (needsSharedRuler) {
    ({ rulerGroup, mainLine, mainLineRight } = createRuler(rulerCtx));
  } else {
    rulerGroup = new THREE.Group();
  }
  diagramGroup.add(rulerGroup);

  /* Nur Wave: Boden-Labels vor die Wellen-Front schieben (inkl. Einzelserie: waveSeriesDepth=0). */
  if (rulerGroup?.userData?._xLabels && initLayoutDesc.isWave) {
    const frontWaveWorldZ = waveGroup.position.z + waveSeriesDepth;
    const margin = 0.08;
    const minLabelLocalZ = frontWaveWorldZ + margin + arcRulerZOffset;
    const allXLabels = (rulerGroup.userData._xLabels || []).concat(rulerGroup.userData._xLabelsHorizontalRight || []);
    /* Orientierung (lookAt) bleibt gueltig bei reiner Z-Verschiebung entlang der Blickachse. */
    allXLabels.forEach((xLabel) => {
      if (xLabel.position.z < minLabelLocalZ) xLabel.position.z = minLabelLocalZ;
    });
  }

  if (initLayoutDesc.isGrouped && clusterLayout && groupedBarLabelAboveMode !== 'off') {
    const { group: groupedBarHeadlineGroup, meshes: groupedBarHeadlineMeshes } = _callExtLabel(THREE, 'createGroupedBarHeadlineLabels', {
      THREE,
      DATA,
      valueToNorm,
      groupedLayout: clusterLayout,
      stackBottomY: undefined,
      barLabelColor: effectiveBarLabelColor,
      rulerColor: baseRulerColor,
      straightBarZ: STRAIGHT_BAR_Z,
      mode: groupedBarLabelAboveMode === 'value' ? 'value' : 'headline',
      axisYPrefix,
      axisYSuffix,
      dataLabelFontScale,
      formatChartNumber,
    });
    rulerGroup.userData._groupedBarHeadlineMeshes = groupedBarHeadlineMeshes;
    diagramGroup.add(groupedBarHeadlineGroup);
  }

  if (state.layout === 'stacked' && stackedLayout && stackedBarSumAbove) {
    const { group: stackedSumGroup, meshes: stackedBarSumMeshes } = createStackedBarSumLabels({
      THREE,
      DATA,
      valueToNorm,
      stackedLayout,
      barLabelColor: effectiveBarLabelColor,
      rulerColor: baseRulerColor,
      straightBarZ: STRAIGHT_BAR_Z,
      axisYPrefix,
      axisYSuffix,
      dataLabelFontScale,
      formatChartNumber,
    });
    rulerGroup.userData._stackedBarSumMeshes = stackedBarSumMeshes;
    diagramGroup.add(stackedSumGroup);
  }

  if (state.layout === 'straight' && straightSlots.above !== 'off') {
    const { group: straightTopGroup, meshes: straightBarTopMeshes } = createStraightBarTopLabels({
      THREE,
      DATA,
      valueToNorm,
      getBarLayout,
      layout: 'straight',
      mode: straightSlots.above === 'value' ? 'value' : 'headline',
      barLabelColor: effectiveBarLabelColor,
      rulerColor: baseRulerColor,
      straightBarZ: STRAIGHT_BAR_Z,
      axisYPrefix,
      axisYSuffix,
      dataLabelFontScale,
      formatChartNumber,
    });
    rulerGroup.userData._straightBarTopLabelMeshes = straightBarTopMeshes;
    diagramGroup.add(straightTopGroup);
  }

  if (initLayoutDesc.isArc && arcBarLabelAboveMode !== 'off') {
    const { group: arcTopGroup, meshes: arcBarTopMeshes } = _callExtLabel(THREE, 'createArcBarTopLabels', {
      THREE,
      DATA,
      valueToNorm,
      getBarLayout,
      mode: arcBarLabelAboveMode === 'value' ? 'value' : 'headline',
      barLabelColor: effectiveBarLabelColor,
      rulerColor: baseRulerColor,
      axisYPrefix,
      axisYSuffix,
      dataLabelFontScale,
      formatChartNumber,
    });
    rulerGroup.userData._arcBarTopLabelMeshes = arcBarTopMeshes;
    diagramGroup.add(arcTopGroup);
  }

  if (initLayoutDesc.isRadial && radialBarLabelMode !== 'off' && radialRingInfo.length) {
    const { group: radialLabelGroup, meshes: radialStartLabelMeshes } = _callExtLabel(THREE, 'createRadialBarStartLabels', {
      THREE,
      DATA,
      ringInfo: radialRingInfo,
      mode: radialBarLabelMode === 'value' ? 'value' : 'headline',
      barLabelColor: effectiveBarLabelColor,
      rulerColor: baseRulerColor,
      axisYPrefix,
      axisYSuffix,
      dataLabelFontScale,
      formatChartNumber,
    });
    rulerGroup.userData._radialBarStartLabelMeshes = radialStartLabelMeshes;
    /* Labels in die radialGroup hängen → automatische Y-Zentrierung + Sichtbarkeits-Toggle. */
    radialGroup.add(radialLabelGroup);
  }

  /* ── getLayoutFrame (aus ./layoutFrame.js) ── */
  ({ getLayoutFrame, invalidateFrameCache } = createLayoutFrame({
    THREE, scene,
    DATA, valueToNorm, getBarLayout,
    barWidth, barDepth: barDepthForMesh, maxBarHeight,
    rulerEnabled, arcRulerZOffset,
    rulerX, diagramWidth,
    arcLeftX, arcLeftZ, arcRightX, arcRightZ,
    arcStartAngle, arcEndAngle, arcRadius, arcZCenter,
    horizontalLeftEdge, horizontalCenterY, horizontalRulerBottom, horizontalRulerTop,
    rulerGroup, waveGroup, bubblePackGroup, radialGroup, pieGroup, surfaceGroup,
    radialMaxR, radialCenterY,
    /* Per-Legende versteckte Daten-Indizes: layoutFrame haelt die Bbox damit
     *  invariant zur Hide-Logik (sonst de-zentriert sich das Diagramm beim
     *  Wieder-Reinscrollen). bubblePackInstMesh wird ggf. spaeter neu erzeugt
     *  -> Getter; das Instances-Array ist nach Layout-Init stabil. */
    hiddenDataIndices: state.hiddenDataIndices,
    getBubblePackInstMesh: () => bubblePackInstMesh,
    bubblePackInstances,
  }));

  /* Pie-Troika: nach Sync Cache invalidieren — sonst falscher Fit zu Schätz-BBox.
   * Während Snapshot-Overlay: kein Invalidate (Freeze + gemeinsamer Overlay-Ende-Pfad). */
  if ((isPieLayout(state.layout) || isPyramidLayout(state.layout)) && pieGroup) {
    let pieCalloutLayoutRaf = null;
    pieGroup.userData._onPieCalloutLayoutDirty = () => {
      if (wrapper.classList.contains(CE_PD_SNAPSHOT_OVERLAY_CLASS)) return;
      pieGroup.userData._pieCalloutOpacityClean = false;
      if (pieCalloutLayoutRaf != null) return;
      pieCalloutLayoutRaf = requestAnimationFrame(() => {
        pieCalloutLayoutRaf = null;
        invalidateFrameCache();
        markDirty(2);
      });
    };
  }

  /* Nach erstem Troika-Idle (alle Syncs): Cache + Refit, damit Kamera zu echten Label-Bounds passt. */
  onAllLabelsSynced(() => {
    if (state.disposed) return;
    invalidateFrameCache();
    if (!state.isOrthographic && !state.isInteracting && !state.userHasOrbited && !consumedResumeState && !camCtl.isExternallyControlled()) {
      camCtl.refitPerspectiveCamera(true);
    }
    markDirty();
  }, 3000);

  /* ── Stage height (synchronous) ── */
  const stageHeightCtl = createStageHeight({
    wrapper,
    container,
    ownerDoc,
    state,
    getExternalLegendEl: () => externalLegendEl,
    diagramMaxByBp,
    diagramMaxHeightLegacy,
    isDiagramFullscreen,
    getLayoutFrame,
  });
  const calcStageHeight = stageHeightCtl.calcStageHeight;
  const measureHeaderFooter = stageHeightCtl.measureHeaderFooter;

  /* Wrapper height must be resolved BEFORE camera positioning — otherwise
     getCameraZ reads the CSS-only min-height (200 px) instead of the
     JS-computed responsive height, producing wrong layoutDistancePersp /
     layoutDistanceRefPersp on viewports that differ from the CSS fallback
     (most visible on mobile initial load). */
  calcStageHeight();
  {
    const cw = container.clientWidth, ch = container.clientHeight;
    if (cw > 0 && ch > 0) {
      state.width = cw;
      state.height = ch;
      perspectiveCamera.aspect = cw / ch;
      perspectiveCamera.updateProjectionMatrix();
      if (renderer) renderer.setSize(cw, ch);
    }
  }

  /* Kamera-Initial-Pose. Im Pseudo-Ortho-Modus muss der FOV zusätzlich auf den Low-Wert
   * gesetzt werden, sonst zeigte das erste gerenderte Frame die frontale Pose mit 50°
   * Standard-FOV (= sichtbar perspektivisch), statt mit PROJECTION_TWEEN_FOV_START_DEG. */
  {
    if (state.isOrthographic) {
      perspectiveCamera.fov = PROJECTION_TWEEN_FOV_START_DEG;
      perspectiveCamera.updateProjectionMatrix();
    }
    const cam0 = camCtl.setCameraTargetFromLayout(state.layout);
    controls.target.copy(cam0.lookAt);
    perspectiveCamera.position.copy(cam0.position);
    perspectiveCamera.lookAt(cam0.lookAt);
    perspectiveCamera.updateMatrixWorld(true);
  }

  /* ── applyLayout (aus ./layoutApply.js) ──
   *  `shadowInstMesh` wird erst weiter unten in `createLightsAndShadows` produziert,
   *  daher Getter-Pattern: syncShadowInstMesh() resolvet die Referenz lazy. */
  let shadowInstMesh = null;
  const { applyBarTargets, applyLayout, syncShadowInstMesh } = createLayoutApply({
    THREE, state, controls, markDirty,
    camCtl, invalidateFrameCache,
    bars, rulerEnabled, rulerGroup,
    barGroup, waveGroup, radialGroup, pieGroup, surfaceGroup, bubblePackGroup,
    seriesAxisXText, axisYLabelText,
    getBarLayout,
    getShadowInstMesh: () => shadowInstMesh,
    barScale,
  });

  /* ── Background ── */
  let reflector = null;
  if (gridEnabled) {
    const bgCtx = { THREE, Reflector, scene, opts, isMobile };
    bgHandle = createBackground(bgCtx);
    reflector = bgHandle.reflector;
    /* Screen-Grid-BG-Repeat initial auf aktuelles Aspect setzen (Millimeterpapier-
     * Zellen bleiben quadratisch, unabhängig von der Canvas-Geometrie). */
    if (bgHandle.handleResize) bgHandle.handleResize(state.width, state.height);
    /* Initial-2D-Zustand (entweder via opts.defaultProjection='2d' oder Resume aus
     * Deep-Dispose im 2D-State): direkt den Screen-Grid-Endzustand zeigen, damit das
     * erste Frame identisch zum Tween-Ende aussieht (kein „3D-Kulisse → BG"-Flicker). */
    if (state.isOrthographic && bgHandle.setGridMorph) bgHandle.setGridMorph(1);
    /* Hinweis: Die 3D-Kulisse (Boden, alle Wände, Reflector) wird im 3D-Modus
     * projection-agnostisch gerendert; der flache Screen-Space-Grid-BG (nur bei
     * backgroundType='grid') fadet über den Projection-Tween ein/aus. */
  }

  setProjectionFlatnessAll(state.isOrthographic ? 1 : 0, projectionFlatRegistry);

  renderLifecycleOps.renderComposite = (r) => {
    if (bgHandle && bgHandle.isScreenGridActive && bgHandle.isScreenGridActive()) {
      const prevAutoClear = r.autoClear;
      r.autoClear = false;
      r.clear();
      r.render(bgHandle.bgScene, bgHandle.bgCamera);
      r.render(scene, camCtl.activeCamera);
      r.autoClear = prevAutoClear;
    } else {
      r.render(scene, camCtl.activeCamera);
    }
    blitToOwnCanvas();
  };

  /* ── Apply initial layout ── */
  controls.target.copy(getLayoutFrame(state.layout).center);
  applyLayout(state.layout);
  recalcFit();

  /* ── Lights: stacked layout — soft floor shadow only under the bottom segment per stack (avoids layered blobs). ── */
  if (state.layout === 'stacked' && stackedLayout) {
    bars.forEach(({ wrapper }, i) => {
      wrapper.userData._stackedShadowBottomOnly = true;
      wrapper.userData._isStackBottomSegment = Math.abs(stackedLayout.stackBottomY[i]) < 1e-5;
    });
  } else {
    bars.forEach(({ wrapper }) => {
      wrapper.userData._stackedShadowBottomOnly = false;
    });
  }

  /* ── Lights ── */
  ({ shadowInstMesh } = createLightsAndShadows({ THREE, scene, bars, valueToNorm, barScale, layout: state.layout, isMobile, pieGroup }));
  if (shadowInstMesh) barGroup.add(shadowInstMesh);

  /* ── Tooltip ── */
  const raycaster = new THREE.Raycaster();
  const mouse = new THREE.Vector2();
  const lastMouse = { x: 0, y: 0 };

  /* ── Hit-Testing (aus ./hitTesting.js) ── */
  const { pickAtCurrentMouse, resetAllMeshColors, updateSurfaceHoverHighlight, layoutIsNonBar, applyHoverColors } = createHitTesting({
    THREE, state,
    getActiveCamera: () => camCtl.activeCamera, raycaster, mouse,
    alwaysColorful,
    bars, barInstMeshes,
    waveMeshes, waveAreaMeshes, waveSphereInstances,
    getWaveSphereInstMesh: () => waveSphereInstMesh,
    radialMeshes, pieMeshes,
    getBubblePackInstMesh: () => bubblePackInstMesh,
    bubblePackInstances,
    getSurfaceMesh: () => surfaceMesh,
  });

  /* Datenpunkt-Links navigieren nur auf der Website, NIE im Editor-Preview. */
  const enableLinkNavigation = !wrapper.classList.contains('ce-diagram-editor-preview');

  /* ── Tooltip-Controller (aus ./tooltipController.js) ── */
  const tooltipCtl = createTooltipController({
    tooltip, container, state,
    mouse, lastMouse, pickCache,
    pickAtCurrentMouse, resetAllMeshColors, updateSurfaceHoverHighlight,
    layoutIsNonBar, applyHoverColors,
    bars,
    alwaysColorful,
    enableLinkNavigation,
    getCategoryLabel, formatDataPointDisplayValue, escapeHtml,
    scatterAxisXLabel, scatterAxisZLabel,
    i18nDataTableColValue, i18nSource: opts.i18nSource || 'Source:',
  });
  const updateTooltip = tooltipCtl.updateTooltip;
  const hideTooltip = tooltipCtl.hideTooltip;

  /* ── Icon ── */
  const iconTooltip = createIconTooltip({ iconEl, ownerDoc });
  const iconTooltipEl = iconTooltip.tooltipEl;
  const showIcon = iconTooltip.showIcon;
  const scheduleIconHide = iconTooltip.scheduleIconHide;

  /* ── Fullscreen ── */
  let dataTableCtl = null;
  const fullscreen = createFullscreen({
    wrapper, ownerDoc, state, ownCanvas, opts,
    backgroundTransparent,
    fullscreenTransparentBackdrop: FULLSCREEN_TRANSPARENT_BACKDROP,
    isMobile,
    mountTooltipsForFullscreenState: () => mountTooltipsForFullscreenState(),
    settleLayout,
    wake: wakeGpuSerial,
    setFullscreenSkipCameraRefit: (v) => { _fullscreenSkipCameraRefit = v; },
  });
  const fullscreenBtn = fullscreen.fullscreenBtn;
  const syncFullscreenUi = fullscreen.syncFullscreenUi;

  const pngDownloadBtn = wrapper.querySelector('.ce-diagram-png-download-btn');
  function intToCssHex(c) {
    const n = (c >>> 0) & 0xffffff;
    return '#' + n.toString(16).padStart(6, '0');
  }
  const _surfaceCellColors = initLayoutDesc.isSurface
    ? _laCallMethod('surface', 'computeCellColors', THREE, DATA, valueToNorm, opts.surfaceColorScheme || 'thermal')
    : null;
  function barColorAtIndex(i) {
    if (_surfaceCellColors && i < _surfaceCellColors.length) return _surfaceCellColors[i];
    return resolveDataItemColor(DATA[i], i, HOVER_PALETTE, uniformDataColor);
  }
  /* Gradient-Balken: 3-Stopp-Triplett pro Index (gleicher Algorithmus wie bars.js) fuer
   * den Verlaufs-Swatch in Legende + Datentabelle; sonst null (einfarbiger Swatch). */
  function barGradientStopsAtIndex(i) {
    if (opts.barGradientEnabled !== true) return null;
    return gradientStopsForIndex(HOVER_PALETTE, i);
  }
  /* ── Data table (accessibility) ── */
  dataTableCtl = createDataTableModal({
    wrapper,
    ownerDoc,
    DATA,
    state,
    opts,
    dataTableBtn: wrapper.querySelector('.ce-diagram-data-table-btn'),
    backgroundTransparent,
    intToCssHex,
    barColorAtIndex,
    barGradientStopsAtIndex,
    formatDataPointDisplayValue,
    isDiagramFullscreen,
  });

  /* ── Pointer events ── */
  if (tooltip && tooltip.parentNode !== ownerDoc.body) ownerDoc.body.appendChild(tooltip);
  /** Im Browser-Vollbild gilt nur der Subtree des Fullscreen-Elements; Tooltips auf `body` wären unsichtbar. */
  function mountTooltipsForFullscreenState() {
    const mount = isDiagramFullscreen() ? wrapper : ownerDoc.body;
    if (tooltip && tooltip.parentNode !== mount) mount.appendChild(tooltip);
    if (iconTooltipEl && iconTooltipEl.parentNode !== mount) mount.appendChild(iconTooltipEl);
    if (dataTableCtl) dataTableCtl.mountDataTableModal();
  }
  mountTooltipsForFullscreenState();
  /* ── Pointer-Input (aus ./pointerInput.js) ── */
  const pointerInput = createPointerInput({
    state, container, ownerDoc, ownCanvas,
    mouse, lastMouse,
    showIcon, markDirty, wake: wakeGpuSerial,
    pickAtCurrentMouse, tooltipCtl,
    DAMPING_SETTLE_FRAMES,
    enableLinkNavigation,
    tapToInteractMobile: TAP_TO_INTERACT_MOBILE,
    tapWakeViewportCheck: TAP_TO_INTERACT_MOBILE ? () => tapInteractChartInViewport() : undefined,
  });

  /* ── Animation loop with delta-time ── */
  state.sceneClockMs = 0;

  const {
    syncAll: syncAllBarLabels,
    syncGroupedBarHeadlineLabelsFromBars,
    syncStackedBarSumLabelsFromBars,
    syncStraightBarTopLabelsFromBars,
    syncArcBarTopLabelsFromBars,
    syncWaveNodeLabelsFromSpheres,
    syncWaveAnnotations,
    syncPieCallouts,
  } = createBarLabelSync({
    THREE, state, rulerGroup, waveGroup, pieGroup, bars, waveSphereInstances,
  });

  /** Setzt die Szene initial in den End-Zustand (Bars/Ruler/Camera/Groups auf Ziel-Pose).
   *  Wird einmalig nach dem Scene-Build gerufen, bevor der Animate-Loop startet. */
  function setupRestState() {
    if (rulerGroup.userData._rulerMaterials) {
      const rt = rulerGroup.userData._rulerTargetOpacity ?? 0;
      rulerGroup.userData._rulerOpacity = rt;
      const displayOpacity = rt < 0.05 ? 0 : rt;
      rulerGroup.visible = displayOpacity > 0.001;
      const solidMul = rulerGroup.userData._rulerStyleSolidMul ?? 1;
      const interFactor = rulerGroup.userData._rulerStyleIntermediateFactor ?? 0.4;
      rulerGroup.userData._rulerMaterials.forEach((mat) => { mat.opacity = solidMul * displayOpacity; });
      (rulerGroup.userData._rulerMaterialsIntermediate || []).forEach((mat) => { mat.opacity = interFactor * displayOpacity; });
      (rulerGroup.userData._rulerBandMaterials || []).forEach((mat) => {
        const base = mat.userData._ceBandOpacityBase ?? 0.1;
        mat.opacity = base * displayOpacity;
      });
    }

    bars.forEach((bar) => {
      const { wrapper, meshUserData, mesh } = bar;
      if (!wrapper.userData._targetPos) return;
      wrapper.scale.set(1, 1, 1);
      wrapper.position.copy(wrapper.userData._targetPos);
      wrapper.rotation.y = wrapper.userData._targetRotY;
      wrapper.rotation.z = wrapper.userData._targetRotZ ?? 0;
      wrapper.userData._shadowOpacityCurrent = wrapper.userData._shadowTargetOpacity ?? 1;
      const sp = wrapper.userData._shadowPlane;
      if (sp && !wrapper.userData._isEmpty) sp.material.opacity = wrapper.userData._shadowTargetOpacity ?? 1;
      const ud = meshUserData || (mesh && mesh.userData);
      if (ud && !wrapper.userData._isEmpty) {
        const hex = alwaysColorful ? ud._defaultColor : ud.color;
        ud._targetColor = hex;
        if (ud._currentColor) ud._currentColor.set(hex);
        /* Gradient-Balken: instanceColor MUSS weiss bleiben, sonst mischt der Shader
         * Datenfarbe (vColor) mit der Verlaufsfarbe (ceGrad) → matschige Mischfarbe. */
        if (bar.instMesh && bar.instanceId >= 0 && !bar.instMesh.userData._ceGradientBars) {
          _scratchColor.set(hex);
          bar.instMesh.setColorAt(bar.instanceId, _scratchColor);
        }
        if (mesh && mesh.material) {
          const mats = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
          mats.forEach(mt => { mt.color.set(hex); if (mt.emissive) mt.emissive.set(hex); });
        }
      }
    });
    if (barInstMeshes.length) {
      const isStackedLayout = state.layout === 'stacked';
      bars.forEach((bar) => {
        if (!bar.instMesh || bar.instanceId < 0) return;
        const w = bar.wrapper;
        const py = isStackedLayout ? w.position.y + bar.hh / 2 : w.position.y;
        setInstanceTransform(THREE, bar.instMesh, bar.instanceId,
          w.position.x, py, w.position.z, w.rotation.y, w.rotation.z, 1, bar.hh / geoTemplateH, 1);
      });
      barInstMeshes.forEach(im => {
        im.instanceMatrix.needsUpdate = true;
        if (im.instanceColor) im.instanceColor.needsUpdate = true;
      });
    }
    /** Setup-Pfad push-t Matrix + instanceOpacity einmalig; der animate-Loop aktualisiert danach bei Änderungen. */
    syncShadowInstMesh();
    syncAllBarLabels();



    if (!state.isOrthographic) {
      controls.minPolarAngle = 0.1;
      controls.maxPolarAngle = camCtl.maxPolarForLayout(state.layout);
    }
    const cam = camCtl.setCameraTargetFromLayout(state.layout);
    camCtl.applyCameraResult(cam);
  }

  /** Render-Loop nur, wenn genug Diagrammfläche im (leicht verkleinerten) Viewport liegt — spart GPU bei Rand-Splittern / Fast-off-screen.
   *  Mobile: `wakeGpuSerial` — siehe dort. */
  const visibilityLifecycle = createVisibilityLifecycle({
    wrapper,
    ownerDoc,
    container,
    state,
    wake: wakeGpuSerial,
    hibernate,
    markDirty,
    hibernateDelayMs: HIBERNATE_DELAY_MS,
    tapToInteractMobile: TAP_TO_INTERACT_MOBILE,
    isDiagramFullscreen,
    /** Tap-Modus: Pill-Stacking nach jedem IO-Tick neu anwenden (show bleibt nur `hibernated`; verhindert verdeckte Pill). */
    onTapIntersectionChange: TAP_TO_INTERACT_MOBILE ? () => updateTapHintVisibility() : null,
    /** Tap-Modus: Während Live-Session (nach Wake oder Deep-Dispose-Resume) nicht automatisch
     *  snapshotten+hibernieren. Sonst würde die Pill trotz aktiver Interaktion wieder erscheinen. */
    isTapInteractLiveSession: TAP_TO_INTERACT_MOBILE ? () => tapInteractLiveSession : null,
  });

  let _lastWaveBehind = null;
  const COLOR_SETTLE_THRESHOLD = 0.003;
  const _scratchColor = new THREE.Color();
  const _scratchColor2 = new THREE.Color();
  function lerpMeshColors(meshes, speed) {
    meshes.forEach(m => {
      if (m.userData._targetColor === undefined) return;
      const mats = Array.isArray(m.material) ? m.material : [m.material];
      _scratchColor.set(m.userData._targetColor);
      mats.forEach((mat) => {
        if (!mat || !mat.color) return;
        const diffL1 =
          Math.abs(mat.color.r - _scratchColor.r) +
          Math.abs(mat.color.g - _scratchColor.g) +
          Math.abs(mat.color.b - _scratchColor.b);
        if (diffL1 <= COLOR_SETTLE_THRESHOLD) return;
        mat.color.lerp(_scratchColor, speed);
        if (mat.emissive) mat.emissive.lerp(_scratchColor, speed);
        if (
          Math.abs(mat.color.r - _scratchColor.r) +
          Math.abs(mat.color.g - _scratchColor.g) +
          Math.abs(mat.color.b - _scratchColor.b)
          > COLOR_SETTLE_THRESHOLD
        ) sceneDirtyFrames = DIRTY_EXTRA_FRAMES;
      });
    });
  }

  const ANIM_RUNNING_SENTINEL = -1;
  let _onNextRenderCb = null;
  function animate(timestamp) {
    if (state.disposed || ownerDoc.hidden || !state.blockVisible || state.hibernated) { state.animId = null; return; }
    if (isScrollFrozen() && !state.isInteracting && !camCtl.hasActiveInertia()) { state.animId = null; return; }
    state.animId = ANIM_RUNNING_SENTINEL;

    const dt = state.lastTimestamp > 0 ? Math.min((timestamp - state.lastTimestamp) / 1000, 0.1) : 1 / 60;
    state.lastTimestamp = timestamp;
    state.sceneClockMs += dt * 1000;
    const dtFactor = dt * 60;

    /* ── Ruler opacity fade ── */
    if (rulerGroup.userData._rulerMaterials) {
      const ro = rulerGroup.userData._rulerOpacity || 0;
      const rt = rulerGroup.userData._rulerTargetOpacity ?? 0;
      if (Math.abs(rt - ro) > 0.005) sceneDirtyFrames = DIRTY_EXTRA_FRAMES;
      rulerGroup.userData._rulerOpacity = ro + (rt - ro) * 0.12 * dtFactor;
      const opacity = rulerGroup.userData._rulerOpacity;
      const displayOpacity = opacity < 0.05 ? 0 : opacity;
      rulerGroup.visible = displayOpacity > 0.001;
      const solidMul = rulerGroup.userData._rulerStyleSolidMul ?? 1;
      const interFactor = rulerGroup.userData._rulerStyleIntermediateFactor ?? 0.4;
      rulerGroup.userData._rulerMaterials.forEach((mat) => { mat.opacity = solidMul * displayOpacity; });
      (rulerGroup.userData._rulerMaterialsIntermediate || []).forEach((mat) => { mat.opacity = interFactor * displayOpacity; });
      (rulerGroup.userData._rulerBandMaterials || []).forEach((mat) => {
        const base = mat.userData._ceBandOpacityBase ?? 0.1;
        mat.opacity = base * displayOpacity;
      });
      /* Horizontal-Rechts-Labels verfolgen das Bar-Ende (Balken skalieren sich beim Wake/Transition). */
      if (initLayoutDesc.isHorizontal) syncHorizontalRightLabels(rulerGroup, bars);
    }

    /* ── Bar layout transition + shadow settle ── */
    let barsMoving = false;
    /* Wenn nur die Schatten-Opacity lerpt (Legenden-Toggle: Position
     *  unver\u00e4ndert → `barsMoving=false`), wird `syncShadowInstMesh()` sonst
     *  nie aufgerufen. Folge: `_shadowOpacityCurrent` lerpt zwar in JS, das
     *  `instanceOpacity`-Attribut auf der GPU bleibt aber bei seinem alten Wert.
     *  Mit diesem Flag erzwingen wir die Sync, solange ein Schatten f\u00e4det. */
    let shadowFading = false;
    bars.forEach((bar) => {
      const { wrapper } = bar;
      if (bar.shadowInstanceId != null && bar.shadowInstanceId >= 0 && !wrapper.userData._isEmpty) {
        const target = wrapper.userData._shadowTargetOpacity ?? 1;
        const cur = wrapper.userData._shadowOpacityCurrent ?? 0;
        const delta = target - cur;
        if (Math.abs(delta) > 0.005) { sceneDirtyFrames = DIRTY_EXTRA_FRAMES; shadowFading = true; }
        wrapper.userData._shadowOpacityCurrent = cur + delta * (target === 0 ? 0.5 : 0.25) * dtFactor;
      }
      const shadowPlane = wrapper.userData._shadowPlane;
      if (shadowPlane && !wrapper.userData._isEmpty) {
        const target = wrapper.userData._shadowTargetOpacity ?? 1;
        const delta = target - shadowPlane.material.opacity;
        if (Math.abs(delta) > 0.005) sceneDirtyFrames = DIRTY_EXTRA_FRAMES;
        shadowPlane.material.opacity += delta * (target === 0 ? 0.5 : 0.25) * dtFactor;
      }
      if (wrapper.userData._targetPos) {
        const posDist = wrapper.position.distanceToSquared(wrapper.userData._targetPos);
        if (posDist > 1e-6) { barsMoving = true; sceneDirtyFrames = DIRTY_EXTRA_FRAMES; }
        wrapper.position.lerp(wrapper.userData._targetPos, LAYOUT_LERP * dtFactor);
        const dy = wrapper.userData._targetRotY - wrapper.rotation.y;
        wrapper.rotation.y += Math.atan2(Math.sin(dy), Math.cos(dy)) * LAYOUT_LERP * dtFactor;
        const dz = (wrapper.userData._targetRotZ ?? 0) - wrapper.rotation.z;
        wrapper.rotation.z += Math.atan2(Math.sin(dz), Math.cos(dz)) * LAYOUT_LERP * dtFactor;
      }
    });

    if (barInstMeshes.length && barsMoving) {
      const isStackedLayout = state.layout === 'stacked';
      bars.forEach((bar) => {
        if (!bar.instMesh || bar.instanceId < 0) return;
        /* Per-Legende versteckte Bar: Matrix steht bereits auf (0,0,0). Hier
         *  \u00fcberspringen, sonst zieht das `|| 1`-Fallback der X/Z-Skala die
         *  Bar visuell zur\u00fcck. */
        if (bar.wrapper.userData._cePdHidden) return;
        const w = bar.wrapper;
        const py = isStackedLayout ? w.position.y + bar.hh * w.scale.y / 2 : w.position.y;
        const sx = w.scale.x || 1;
        const sz = w.scale.z || 1;
        setInstanceTransform(THREE, bar.instMesh, bar.instanceId,
          w.position.x, py, w.position.z,
          w.rotation.y, w.rotation.z,
          sx, (w.scale.y || 0) * bar.hh / geoTemplateH, sz);
      });
      barInstMeshes.forEach(im => { im.instanceMatrix.needsUpdate = true; });
    }

    if (shadowInstMesh && shadowInstMesh.visible && (barsMoving || shadowFading)) syncShadowInstMesh();

    if (barsMoving) {
      syncGroupedBarHeadlineLabelsFromBars();
      syncStackedBarSumLabelsFromBars();
      syncStraightBarTopLabelsFromBars();
      syncArcBarTopLabelsFromBars();
    }
    if (barsMoving || initLayoutDesc.isWave) {
      syncWaveNodeLabelsFromSpheres();
      syncWaveAnnotations();
    }
    /* Pie-Callouts: beim Build Opacity 0 → setupRestState setzt 1; ohne Loop-Pfad können
     * nach Wake/Reinit kurz/in inkonsistent 0 bleiben (sichtbarer Pop vs. Snapshot). */
    if (isPieLayout(state.layout)) syncPieCallouts();

    if (waveAreaMeshes.length > 1) {
      const camZ = camCtl.activeCamera.position.z;
      const waveWorldZ = waveGroup.getWorldPosition(_waveCamZScratch).z;
      const behind = camZ < waveWorldZ;
      if (behind !== _lastWaveBehind) {
        _lastWaveBehind = behind;
        const N = waveAreaMeshes.length;
        const last = N - 1;
        for (let i = 0; i < N; i++) {
          const slot = behind ? last - i : i;
          waveAreaMeshes[i].renderOrder = slot;
        }
        const nodeLabelList = waveGroup.userData._waveNodeLabelMeshes;
        if (nodeLabelList) {
          for (let i = 0; i < nodeLabelList.length; i++) {
            const si = waveSphereInstances[nodeLabelList[i].userData._dataIndex]?.seriesIndex ?? 0;
            const slot = behind ? last - si : si;
            nodeLabelList[i].renderOrder = N + slot;
          }
        }
        const seriesLabelList = waveGroup.userData._waveSeriesLabelMeshes;
        if (seriesLabelList) {
          for (let i = 0; i < seriesLabelList.length; i++) {
            const slot = behind ? last - i : i;
            seriesLabelList[i].renderOrder = N * 2 + slot;
          }
        }
      }
    }

    /* ── Color lerping ── */
    const colorSpeed = 0.28 * dtFactor;
    if (barInstMeshes.length) {
      let barColorDirty = false;
      for (let bi = 0; bi < bars.length; bi++) {
        const bar = bars[bi];
        if (!bar.instMesh || bar.instanceId < 0 || !bar.meshUserData) continue;
        /* Gradient-Balken: instanceColor bleibt weiss, der Verlauf kommt aus dem Shader.
         * Per-Frame-Farb-Lerp wuerde den Verlauf uebertoenen → ueberspringen. */
        if (bar.instMesh.userData._ceGradientBars) continue;
        const ud = bar.meshUserData;
        if (ud._targetColor === undefined) continue;
        if (!ud._currentColor) ud._currentColor = new THREE.Color(ud._targetColor);
        _scratchColor.set(ud._targetColor);
        ud._currentColor.lerp(_scratchColor, colorSpeed);
        bar.instMesh.setColorAt(bar.instanceId, ud._currentColor);
        const diff = Math.abs(ud._currentColor.r - _scratchColor.r) + Math.abs(ud._currentColor.g - _scratchColor.g) + Math.abs(ud._currentColor.b - _scratchColor.b);
        if (diff > COLOR_SETTLE_THRESHOLD) barColorDirty = true;
      }
      if (barColorDirty) {
        barInstMeshes.forEach(im => { if (im.instanceColor) im.instanceColor.needsUpdate = true; });
        sceneDirtyFrames = DIRTY_EXTRA_FRAMES;
      }
    }
    if (initLayoutDesc.isBubblePack && bubblePackInstMesh && bubblePackInstances.length) {
      let bpDirty = false;
      const _bpTgt = _scratchColor;
      for (let bi = 0; bi < bubblePackInstances.length; bi++) {
        const inst = bubblePackInstances[bi];
        if (inst.targetColor === undefined) continue;
        _bpTgt.set(inst.targetColor);
        inst.currentColor.lerp(_bpTgt, colorSpeed);
        bubblePackInstMesh.setColorAt(bi, inst.currentColor);
        const diff = Math.abs(inst.currentColor.r - _bpTgt.r) + Math.abs(inst.currentColor.g - _bpTgt.g) + Math.abs(inst.currentColor.b - _bpTgt.b);
        if (diff > COLOR_SETTLE_THRESHOLD) bpDirty = true;
      }
      if (bpDirty) {
        if (bubblePackInstMesh.instanceColor) bubblePackInstMesh.instanceColor.needsUpdate = true;
        sceneDirtyFrames = DIRTY_EXTRA_FRAMES;
      }
    }
    if (initLayoutDesc.isWave && waveSphereInstMesh && waveSphereInstances.length) {
      if (lerpInstanceColors(waveSphereInstMesh, waveSphereInstances, colorSpeed, COLOR_SETTLE_THRESHOLD)) sceneDirtyFrames = DIRTY_EXTRA_FRAMES;
    }
    /* Bar-Layouts (straight/grouped/stacked/horizontal), bubblePack und wave nutzen InstancedMesh-
     * Color-Lerp (oben); nur radial + pie brauchen weiterhin den klassischen Pfad. */
    const activeMeshArrays =
      initLayoutDesc.isRadial ? [radialMeshes]
      : (isPieLayout(state.layout) || isPyramidLayout(state.layout)) ? [pieMeshes]
      : [];
    activeMeshArrays.forEach(arr => lerpMeshColors(arr, colorSpeed));
    if (waveSphereInstMesh && waveSphereInstances.length) {
      let sphereMatDirty = false;
      for (let i = 0; i < waveSphereInstances.length; i++) {
        const d = waveSphereInstances[i];
        const targetScale = d.targetScale ?? 1;
        const cur = d._currentScale ?? 1;
        const delta = targetScale - cur;
        if (Math.abs(delta) > 0.005) {
          sceneDirtyFrames = DIRTY_EXTRA_FRAMES;
          sphereMatDirty = true;
          d._currentScale = cur + delta * 0.2 * dtFactor;
          setInstancePosScale(THREE, waveSphereInstMesh, i, d.position.x, d.position.y, d.position.z, d._currentScale);
        }
      }
      if (sphereMatDirty) waveSphereInstMesh.instanceMatrix.needsUpdate = true;
    }
    [...waveMeshes, ...waveAreaMeshes].forEach(wm => {
      const u = wm.userData._waveSatUniform;
      const target = wm.userData._targetSaturation;
      if (u && target !== undefined) {
        const d = target - u.value;
        if (Math.abs(d) > COLOR_SETTLE_THRESHOLD) { u.value += d * colorSpeed; sceneDirtyFrames = DIRTY_EXTRA_FRAMES; }
      }
    });

    if (state.isOrthographic) {
      try {
        if (camCtl.stepOrthoWakeCameraBlend(dtFactor)) {
          sceneDirtyFrames = Math.max(sceneDirtyFrames, DIRTY_EXTRA_FRAMES);
        }
      } catch (_) { /* ignore */ }
      controls.update();
    } else if (wrapper.classList.contains(CE_PD_SNAPSHOT_OVERLAY_CLASS)) {
      /* Snapshot-Overlay: keine Perspective-PT1 — `PLAN-wake-render-path-vereinheitlichung.md`. */
      controls.update();
    } else {
      camCtl.stepPerspectiveCamera(dtFactor);
    }
    updateTooltip();
    renderLifecycleOps.renderComposite(renderer);
    state._renderedFrames = (state._renderedFrames || 0) + 1;

    if (_onNextRenderCb) {
      const cb = _onNextRenderCb;
      _onNextRenderCb = null;
      requestAnimationFrame(cb);
    }

    /* Schedule next frame only if scene still needs updates */
    if (state.isInteracting) sceneDirtyFrames = Math.max(sceneDirtyFrames, DIRTY_EXTRA_FRAMES);
    if (sceneDirtyFrames > 0) {
      sceneDirtyFrames--;
      state.animId = requestAnimationFrame(animate);
    } else {
      state.animId = null;
    }
  }

  setupRestState();
  /** Resume nach Deep-Dispose: Kamera-Position & Controls-Target hart setzen, damit
   *  stepPerspectiveCamera im ersten Frame die restaurierten Orbit-Winkel (theta, phi)
   *  daraus extrahiert und via computeDesiredCamera exakt zur Frame-zentrierten Pose
   *  snappt. `userHasOrbited = true` → recalcFit() refittet nicht auf Default. */
  if (consumedResumeState && consumedResumeState.camera) {
    const rc = consumedResumeState.camera;
    if (rc.target) controls.target.set(rc.target.x, rc.target.y, rc.target.z);
    if (rc.position) {
      perspectiveCamera.position.set(rc.position.x, rc.position.y, rc.position.z);
    }
    /* Seit dem Pseudo-Ortho-Refactor ist userZoomFactorPersp die einzige Zoom-Quelle —
     * sowohl im 2D- als auch im 3D-Modus. Der Resume-State kann aus einer älteren Version
     * noch `orthoZoom` enthalten; dann leiten wir userZoomFactorPersp = 1 / orthoZoom ab
     * (invertierte Semantik gegenüber der alten OrthographicCamera.zoom-Property). */
    if (typeof rc.userZoomFactorPersp === 'number' && Number.isFinite(rc.userZoomFactorPersp)) {
      state.userZoomFactorPersp = rc.userZoomFactorPersp;
    } else if (state.isOrthographic && typeof rc.orthoZoom === 'number' && Number.isFinite(rc.orthoZoom) && rc.orthoZoom > 1e-6) {
      state.userZoomFactorPersp = 1 / rc.orthoZoom;
    }
    if (state.isOrthographic) {
      /* Pseudo-Ortho-Pose neu aufbauen (FOV + frontale Position bei passendem Radius). */
      perspectiveCamera.fov = PROJECTION_TWEEN_FOV_START_DEG;
      perspectiveCamera.updateProjectionMatrix();
      try { camCtl.syncUserPanOffsetFromRestoredPose(); } catch (_) { /* ignore */ }
      camCtl.applyPseudoOrthoPose();
    }
    state.userHasOrbited = true;
    state.cameraCenterTarget.position = null;
    state.cameraCenterTarget.lookAt = null;
    controls.update();
    /* PT1-Filter mit der restaurierten Snapshot-Pose seeden (nicht auf neu berechnete
     * desired-Pose snappen): der layoutFrame kann nach Deep-Dispose minimal anders sein
     * (Label-Textures werden neu gemessen), wodurch desired leicht abweicht. Seed
     * sorgt dafür, dass der erste Frame exakt der Snapshot ist und der Filter sanft
     * über ~400ms zur neuen desired lerpt (statt sichtbar zu springen). */
    if (!state.isOrthographic && rc.target && typeof rc.fitDistPersp === 'number' && Number.isFinite(rc.fitDistPersp)) {
      camCtl.snapSmoothingTo({
        target: new THREE.Vector3(rc.target.x, rc.target.y, rc.target.z),
        fitDist: rc.fitDistPersp,
      });
      state.layoutDistancePersp = rc.fitDistPersp;
      state.layoutDistanceRefPersp = rc.fitDistPersp;
    }
    if (!state.isOrthographic) {
      try { camCtl.syncUserPanOffsetFromRestoredPose(); } catch (_) { /* ignore */ }
    }
  }
  sceneInitComplete = true;

  animate(performance.now());

  if (TAP_TO_INTERACT_MOBILE) {
    requestAnimationFrame(() => {
      if (state.disposed) return;
      /** Live-Session (z. B. Resume aus Deep-Dispose): Nicht automatisch hibernieren, sonst
       *  erscheint die Pill gleich nach dem User-Tap erneut und ein zweiter Tap wäre nötig. */
      if (tapInteractLiveSession) {
        updateTapHintVisibility();
        return;
      }
      if (state.hibernated) {
        updateTapHintVisibility();
        return;
      }
      /** Nicht an `blockVisible` koppeln: Tap-Mode setzt den Wert bis zum ersten IO künstlich
       *  auf false — der erste rAF landete sonst oft in „kein Hibernate“, und verzögertes IO
       *  (Dev-Grid, Layout) ließ die Pill aus. Troika-Sync bleibt über `onAllLabelsSynced`. */
      const takeSnapshot = () => {
        if (state.disposed) return;
        if (state.hibernated) return;
        hibernate();
        updateTapHintVisibility();
      };
      if (getTroikaDebugStats().pendingSyncs > 0) {
        onAllLabelsSynced(takeSnapshot, 5000);
      } else {
        takeSnapshot();
      }
    });
  }

  const onVisibilityChange = () => { if (!ownerDoc.hidden && state.blockVisible && !state.hibernated) markDirty(); };
  ownerDoc.addEventListener('visibilitychange', onVisibilityChange);

  const onScrollUnfreeze = () => { if (!state.disposed && state.blockVisible && !state.hibernated) markDirty(); };
  addScrollUnfreezeCallback(onScrollUnfreeze);

  /* ── Resize ── */
  function recalcFit() {
    calcStageHeight();
    const w = container.clientWidth, h = container.clientHeight;
    if (w <= 0 || h <= 0) return;
    state.width = w; state.height = h;
    perspectiveCamera.aspect = w / h;
    perspectiveCamera.updateProjectionMatrix();
    /* Screen-Grid-BG-Tiling an neues Canvas-Aspect anpassen (Zellen quadratisch). */
    if (bgHandle && bgHandle.handleResize) bgHandle.handleResize(w, h);
    if (!state.hibernated) {
      applyWebGlPixelRatio(renderer);
      renderer.setSize(w, h);
    }
    camCtl.setZoomLimits();
    if (!state.isInteracting && !_wakeSkipCameraRefit && !_fullscreenSkipCameraRefit && !camCtl.isExternallyControlled()) {
      const troikaBusy = getTroikaDebugStats().pendingSyncs > 0;
      const deferLayoutRefit =
        troikaBusy &&
        (state.isOrthographic ||
          !state.userHasOrbited);
      const runResizeLayoutRefit = () => {
        invalidateFrameCache();
        if (state.isOrthographic) {
          if (!camCtl.isOrthoWakeBlending?.()) {
            camCtl.applyPseudoOrthoPose();
            if (!state.userHasOrbited) {
              state.cameraCenterTarget.position = null;
              state.cameraCenterTarget.lookAt = null;
            }
          }
        } else if (!state.userHasOrbited) {
          camCtl.refitPerspectiveCamera(true);
        } else {
          state.cameraCenterTarget.position = null;
          state.cameraCenterTarget.lookAt = null;
        }
        markDirty();
      };
      if (deferLayoutRefit) {
        if (!_pendingResizeRefit) {
          _pendingResizeRefit = true;
          onAllLabelsSynced(() => {
            _pendingResizeRefit = false;
            if (state.disposed || state.hibernated) return;
            if (state.isInteracting || _wakeSkipCameraRefit || _fullscreenSkipCameraRefit) return;
            runResizeLayoutRefit();
          }, 0);
        }
      } else if (state.isOrthographic) {
        if (!camCtl.isOrthoWakeBlending?.()) {
          /* Pseudo-Ortho bei Resize neu aufbauen: sichtbare halfHeight am Target hängt
           * aspect-abhängig von getLayoutFrame ab — die Pose (Radius) muss deshalb bei
           * jeder Größenänderung nachgezogen werden, unabhängig vom User-Zoom-Status. */
          camCtl.applyPseudoOrthoPose();
          if (!state.userHasOrbited) {
            state.cameraCenterTarget.position = null; state.cameraCenterTarget.lookAt = null;
          }
        }
      } else if (!state.userHasOrbited) {
        invalidateFrameCache();
        camCtl.refitPerspectiveCamera(sceneInitComplete);
      } else {
        state.cameraCenterTarget.position = null; state.cameraCenterTarget.lookAt = null;
      }
    }
    markDirty();
  }

  /* ── Legend-Toggle: einzelnen DATA-Index zeigen/verstecken (Klick auf Legende) ──
   *  - Bars: scale.y → 0 (Bar verschwindet visuell; Instanz-Matrix wird sofort
   *    geschrieben, sodass nicht erst der animate-Loop nachziehen muss) und
   *    Schatten-Opacity = 0.
   *  - Pie: sliceMesh.visible = false; passende Callout-Linie + Callout-Label
   *    werden \u00fcber `_dataIndex` mit-versteckt.
   *  - Top-Labels: straight-Top-Labels per `_dataIndex` direkt, Stacked-Sum-Labels
   *    werden ausgeblendet, sobald irgendein Member im Stack versteckt ist
   *    (Summe w\u00e4re sonst irref\u00fchrend).
   *  - Pick-Cache wird invalidiert (sonst zeigt Hover noch alte Bar).
   */
  function setDataIndexHidden(idx, hidden) {
    if (!Number.isInteger(idx) || idx < 0) return false;
    const want = !!hidden;
    const cur = state.hiddenDataIndices.has(idx);
    if (cur === want) return false;
    if (want) state.hiddenDataIndices.add(idx);
    else state.hiddenDataIndices.delete(idx);
    /* Persistenz \u00fcber Re-Inits: Wrapper-DOM-Element \u00fcberlebt Hibernate +
     *  Deep-Dispose. initScene liest diese Liste am Ende und stellt den
     *  Zustand wieder her (Legenden-UI + Meshes). */
    wrapper._cePdHiddenDataIndices = Array.from(state.hiddenDataIndices);

    /* Hibernate-Wake: ohne Mausbewegung pausiert der Renderer-Loop. `markDirty()`
     *  ganz unten kickt animate() nur, wenn `!state.hibernated`. Auf Mobile +
     *  Desktop muss der Klick aber sofort eine neue Frame triggern, sonst sieht
     *  der User die \u00c4nderung erst beim n\u00e4chsten Hover. */
    try { wakeGpuSerial(); } catch (_) { /* ignore */ }

    const bar = bars[idx];
    if (bar && bar.wrapper) {
      /* Alle drei Skalen auf 0 → Cube kollabiert zum Punkt. scale.y=0 alleine
       *  l\u00e4sst Top- und Bottom-Face zu einem flachen Polygon zusammenfallen,
       *  das von der Kamera (Top-/Frosch-Perspektive) als d\u00fcnner Strich sichtbar
       *  bleibt. Wrapper-Marker `_cePdHidden` schliesst den animate-Loop aus,
       *  der sonst beim n\u00e4chsten barsMoving-Tick \u00fcber `w.scale.x || 1` versehentlich
       *  wieder auf 1 zur\u00fcckspringt. */
      bar.wrapper.userData._cePdHidden = want;
      const s = want ? 0 : 1;
      bar.wrapper.scale.set(s, s, s);
      bar.wrapper.userData._shadowTargetOpacity = want ? 0 : 1;
      if (bar.instMesh && bar.instanceId >= 0) {
        const isStackedLayout = state.layout === 'stacked';
        const w = bar.wrapper;
        const py = isStackedLayout ? w.position.y + (bar.hh * s) / 2 : w.position.y;
        setInstanceTransform(THREE, bar.instMesh, bar.instanceId,
          w.position.x, py, w.position.z,
          w.rotation.y, w.rotation.z,
          s, s * bar.hh / geoTemplateH, s);
        bar.instMesh.instanceMatrix.needsUpdate = true;
        bar.instMesh.boundingSphere = null;
      }
    }

    if (isPieLayout(state.layout) || isPyramidLayout(state.layout)) {
      const slice = pieMeshes[idx];
      if (slice) slice.visible = !want;
    }

    /* Radial: standalone Meshes pro Datenpunkt. Pro-Layouts setzen
     *  `_dataIndex` auf jeden Mesh; als Fallback nutzen wir die Array-Position
     *  (radialMeshes ist 1:1 zu DATA, sofern keine Filterung aktiv ist). */
    if (initLayoutDesc.isRadial && Array.isArray(radialMeshes) && radialMeshes.length) {
      let matched = false;
      for (let i = 0; i < radialMeshes.length; i++) {
        const m = radialMeshes[i];
        if (!m) continue;
        if (m.userData && m.userData._dataIndex === idx) {
          m.visible = !want;
          matched = true;
        }
      }
      if (!matched && radialMeshes[idx]) radialMeshes[idx].visible = !want;
    }

    /* BubblePack: InstancedMesh → Instance-Matrix auf scale(0,0,0) kollabieren.
     *  Rest-Matrix wird einmalig pro Instance gecached, damit wir sie beim
     *  Wieder-Einblenden punktgenau restaurieren k\u00f6nnen. */
    if (initLayoutDesc.isBubblePack && bubblePackInstMesh && Array.isArray(bubblePackInstances)) {
      let bi = -1;
      for (let i = 0; i < bubblePackInstances.length; i++) {
        if (bubblePackInstances[i] && bubblePackInstances[i]._dataIndex === idx) { bi = i; break; }
      }
      if (bi < 0 && bubblePackInstances[idx]) bi = idx;
      if (bi >= 0) {
        const inst = bubblePackInstances[bi];
        if (!inst._cePdRestMatrix) {
          inst._cePdRestMatrix = new THREE.Matrix4();
          bubblePackInstMesh.getMatrixAt(bi, inst._cePdRestMatrix);
        }
        if (want) {
          if (!inst._cePdZeroMatrix) inst._cePdZeroMatrix = new THREE.Matrix4().makeScale(0, 0, 0);
          bubblePackInstMesh.setMatrixAt(bi, inst._cePdZeroMatrix);
        } else {
          bubblePackInstMesh.setMatrixAt(bi, inst._cePdRestMatrix);
        }
        bubblePackInstMesh.instanceMatrix.needsUpdate = true;
        bubblePackInstMesh.boundingSphere = null;
      }
    }

    /* Wave/Roadmap: Roadmap baut pro Datenpunkt ein eigenes Band-Segment (Panel),
     *  jedes mit `_dataIndex`. Wie Bars/Slices/Bubbles per Legende ein-/ausblenden.
     *  (Die gerade Welle ist ein durchgaengiges Band ohne Segmente → keine Treffer.) */
    if (Array.isArray(waveMeshes) && waveMeshes.length) {
      for (let i = 0; i < waveMeshes.length; i++) {
        const m = waveMeshes[i];
        if (m && m.userData && m.userData._dataIndex === idx) m.visible = !want;
      }
    }

    /* Per-DATA-Index-Label-Arrays: jeder Eintrag mit `userData._dataIndex===idx`
     *  bekommt visible=!want. `mesh.visible=false` hat Vorrang vor den
     *  per-Frame-Sync-Aufrufen (barLabelSync), die nur Material-Opacity setzen
     *  und unabh\u00e4ngig von `.visible` operieren. */
    const hideLabelByDataIndex = (arr) => {
      if (!Array.isArray(arr)) return;
      for (let i = 0; i < arr.length; i++) {
        const m = arr[i];
        if (m && m.userData && m.userData._dataIndex === idx) m.visible = !want;
      }
    };
    hideLabelByDataIndex(rulerGroup?.userData?._straightBarTopLabelMeshes);
    hideLabelByDataIndex(rulerGroup?.userData?._arcBarTopLabelMeshes);
    hideLabelByDataIndex(rulerGroup?.userData?._xLabelsHorizontalRight);
    hideLabelByDataIndex(rulerGroup?.userData?._xLabelsHorizontalInside);
    /* Pie: Inner-Labels (Text im Slice/Ring) + Callouts (Linie + Text aussen). */
    hideLabelByDataIndex(pieGroup?.userData?._pieInnerLabels);
    hideLabelByDataIndex(pieGroup?.userData?._pieCalloutMeshes);
    hideLabelByDataIndex(pieGroup?.userData?._pieCalloutLines);
    hideLabelByDataIndex(pieGroup?.userData?._pyramidAnnotationMeshes);
    hideLabelByDataIndex(pieGroup?.userData?._pyramidAnnotationLines);
    /* Pro-Layouts (Add-on f\u00fcllt Liste, sonst leer/undefined). */
    hideLabelByDataIndex(rulerGroup?.userData?._groupedBarHeadlineMeshes);
    hideLabelByDataIndex(rulerGroup?.userData?._radialBarStartLabelMeshes);
    hideLabelByDataIndex(waveGroup?.userData?._waveNodeLabelMeshes);
    hideLabelByDataIndex(waveGroup?.userData?._waveAnnotationMeshes);
    hideLabelByDataIndex(waveGroup?.userData?._waveAnnotationLines);

    /* BubblePack-Labels: Pro-Layout setzt aktuell kein `_dataIndex` auf die
     *  Label-Meshes. `bubblePackLabelMeshes` ist aber 1:1 zu DATA gef\u00fcllt
     *  (eine Label-Gruppe pro Bubble) → Array-Index-Mapping als Fallback. */
    if (Array.isArray(bubblePackLabelMeshes) && bubblePackLabelMeshes.length) {
      let matched = false;
      for (let i = 0; i < bubblePackLabelMeshes.length; i++) {
        const m = bubblePackLabelMeshes[i];
        if (m && m.userData && m.userData._dataIndex === idx) { m.visible = !want; matched = true; }
      }
      if (!matched && bubblePackLabelMeshes[idx]) bubblePackLabelMeshes[idx].visible = !want;
    }

    /* Aggregierte Stacked-Sum-Labels: nur ausblenden, wenn ALLE Member-Indizes
     *  des Stacks versteckt sind (sonst zeigt die Summe einen verzerrten Wert). */
    const ssMeshes = rulerGroup?.userData?._stackedBarSumMeshes;
    if (Array.isArray(ssMeshes)) {
      for (let i = 0; i < ssMeshes.length; i++) {
        const m = ssMeshes[i];
        const members = m && m.userData && m.userData._stackedSumMemberIndices;
        if (!Array.isArray(members)) continue;
        let anyHidden = false;
        for (let k = 0; k < members.length; k++) {
          if (state.hiddenDataIndices.has(members[k])) { anyHidden = true; break; }
        }
        m.visible = !anyHidden;
      }
    }

    /* Schatten-InstancedMesh nachziehen, sonst bleibt der Schatten der Bar
     *  einen Frame lang sichtbar (Opacity wird im animate-Loop gelerpt, aber
     *  die Matrix muss sofort frisch sein). */
    syncShadowInstMesh();

    /* Pick-Cache invalidieren: ohne das bleibt ein bereits gehoverter
     *  (jetzt versteckter) Bar als „hovered" markiert und der Tooltip-Loop
     *  zeigt ihn weiterhin. */
    state.hovered = null;

    markDirty();
    return true;
  }
  wrapper._cePdSetDataIndexHidden = setDataIndexHidden;

  /* ── Legend (HTML, same colors as chart / data table) ── */
  const legend = createLegend({
    wrapper, opts, DATA, state, legendPosition,
    backgroundColor, backgroundTransparent,
    contrastTextColor, bubblePackLegendHint,
    clusterLayout,
    axisYMin, axisYMax, axisYPrefix, axisYSuffix, scatterAxisYLabel,
    intToCssHex, barColorAtIndex, barGradientStopsAtIndex,
    setDataIndexHidden,
    onExternalLegendMount: (el) => { externalLegendEl = el; },
    refreshHeaderFooterHeight: stageHeightCtl.refreshHeaderFooterHeight,
    markSkipHeightRecalc: stageHeightCtl.markSkipHeightRecalc,
    recalcFit,
  });

  /* Hidden-State nach Re-Init wiederherstellen.
   *  Quelle: `wrapper._cePdHiddenDataIndices` (\u00fcberlebt Deep-Dispose, da DOM-
   *  Element bestehen bleibt). Nur Indizes \u00fcbernehmen, die noch im aktuellen
   *  DATA-Bereich liegen (Datentabelle k\u00f6nnte sich ge\u00e4ndert haben). */
  if (Array.isArray(wrapper._cePdHiddenDataIndices) && wrapper._cePdHiddenDataIndices.length) {
    const savedHidden = wrapper._cePdHiddenDataIndices.filter(
      (n) => Number.isInteger(n) && n >= 0 && n < DATA.length
    );
    /* Liste zur\u00fccksetzen — setDataIndexHidden f\u00fcllt sie unten wieder. */
    wrapper._cePdHiddenDataIndices = [];
    for (let i = 0; i < savedHidden.length; i++) setDataIndexHidden(savedHidden[i], true);
    /* Legenden-UI nachsynchronisieren: button-Rows finden, deren
     *  `data-ce-pd-indices` einen jetzt-versteckten Index enth\u00e4lt. */
    const legendEl = wrapper.querySelector('.ce-diagram-legend');
    if (legendEl) {
      const rows = legendEl.querySelectorAll('.ce-diagram-legend__row[data-ce-pd-indices]');
      rows.forEach((row) => {
        const raw = row.getAttribute('data-ce-pd-indices') || '';
        const parts = raw.split(',');
        let hit = false;
        for (let i = 0; i < parts.length; i++) {
          const n = parseInt(parts[i], 10);
          if (Number.isInteger(n) && state.hiddenDataIndices.has(n)) { hit = true; break; }
        }
        if (hit) {
          row.setAttribute('aria-pressed', 'true');
          row.classList.add('ce-diagram-legend__row--hidden');
        }
      });
    }
  }

  /** Auf Mobile feuert der ResizeObserver bei jedem Address-Bar-Show/Hide durch alle Charts
   *  (dort ändert sich durch Viewport-Höhe die Container-Höhe). Ohne Debounce → N × settleLayout()
   *  → N × `renderer.setSize()` (teure WebGL-Reallocation) pro Scroll-Phase.
   *  Nur `wrapper` observen: `container` ist ein Kind von `wrapper` und dessen Layout-Resizes
   *  propagieren bereits über die Wrapper-Box. Singleton-Observer pro defaultView. */
  let _roTimer = null;
  const onWrapperResize = () => {
    if (_roTimer !== null) return;
    _roTimer = setTimeout(() => { _roTimer = null; settleLayout(); }, 150);
  };
  _ceObserveResize(ownerDoc.defaultView, wrapper, onWrapperResize);

  /* ── Ruler / label color observer (data-* für Editor-Live-Update) ──
   *  Nur im Gutenberg-Editor-Preview aktiv: dort ändert die Sidebar data-*-Attribute live.
   *  Im Frontend sind diese Attribute statisch → MutationObserver überflüssig. */
  const isEditorPreview = wrapper.classList.contains('ce-diagram-editor-preview');
  function buildRulerColorCtx(overrides = {}) {
    return {
      THREE,
      axisYMin,
      axisYMax,
      axisYPrefix,
      axisYSuffix,
      count,
      getCategoryLabel,
      DATA,
      seriesAxisXLabel: seriesAxisXText,
      axisYLabel: axisYLabelText,
      canvasFontFamily: resolveCanvasFontFamily(wrapper),
      diagramRightEdgeX,
      rulerX,
      diagramWidth,
      rulerHeight: maxBarHeight,
      seriesAxisLabelPlaneH: AXIS_PAIR_LABEL_PLANE_H,
      xCategoryLabels: (groupedLayout || stackedLayout) ? (groupedLayout || stackedLayout).groupDisplayLabels : null,
      dataLabelFontScale,
      rulerStyle,
      rulerColorAttr,
      backgroundColor,
      backgroundTransparent,
      groupLabelColor: effectiveGroupLabelColor,
      formatChartNumber,
      ...overrides,
    };
  }
  const rulerColorObserver = isEditorPreview ? createRulerColorObserver({
    wrapper, opts,
    baseRulerColor, effectiveBarLabelColor, effectiveGroupLabelColor,
    rulerEnabled, rulerGroup, waveGroup,
    THREE, DATA, axisYPrefix, axisYSuffix, dataLabelFontScale, formatChartNumber,
    buildRulerColorCtx,
    invalidateFrameCache,
    syncGroupedBarHeadlineLabelsFromBars,
    syncStackedBarSumLabelsFromBars,
    syncStraightBarTopLabelsFromBars,
    syncArcBarTopLabelsFromBars,
    syncWaveNodeLabelsFromSpheres,
  }) : null;

  /** Editor: axisX/axisY — Troika per updateRulerColor; Werte kommen aus Gutenberg-Attributen (kein race mit data-config). */
  if (isEditorPreview) {
    wrapper.__cePdEditorPreviewApplyAxisLabelsFromConfig = function cePdEditorPreviewApplyAxisLabelsFromConfig(axisXArg, axisYArg) {
      if (state.disposed || initLayoutDesc.isSurface) return;
      if (!(isBarLayout || initLayoutDesc.isWave) || rulerEnabled === false) return;
      const axX = typeof axisXArg === 'string' ? axisXArg.trim().slice(0, 48) : '';
      const axY = typeof axisYArg === 'string' ? axisYArg.trim().slice(0, 48) : '';
      const nextRuler = wrapper.getAttribute('data-ruler-color') || baseRulerColor;
      const nextGroupAttr = wrapper.getAttribute('data-group-label-color');
      const groupOverride = (nextGroupAttr != null && String(nextGroupAttr).trim() !== '')
        ? (String(nextGroupAttr).trim().startsWith('#') ? String(nextGroupAttr).trim() : '#' + String(nextGroupAttr).trim())
        : effectiveGroupLabelColor;
      const ctx = buildRulerColorCtx({
        seriesAxisXLabel: axX,
        axisYLabel: axY,
        groupLabelColor: groupOverride,
      });
      updateRulerColor(rulerGroup, nextRuler, ctx);
      invalidateFrameCache();
      settleLayout();
      markDirty();
      onAllLabelsSynced(() => {
        if (state.disposed) return;
        invalidateFrameCache();
        settleLayout();
        markDirty();
      }, 2000);
    };
    queueMicrotask(() => {
      if (state.disposed) return;
      try {
        wrapper.__cePdEditorPreviewApplyAxisLabelsFromConfig(seriesAxisXText, axisYLabelText);
      } catch (_) { /* ignore */ }
    });
  }

  /* ── Loaded ── */
  /* Ablauf:
     rAF1 — auf onAllLabelsSynced warten: erst wenn alle Troika-Labels gegen die
             echte Schrift gesynct sind, stehen die finalen Label-Bounds fest.
     reveal() — recalcFit() + refitPerspectiveCamera() während canvas noch opacity:0;
             renderer.setSize() bleibt so unsichtbar.
     Dann: _onNextRenderCb setzen → animate() feuert es nach dem nächsten
             renderer.render()-Aufruf (erster Frame mit korrekten Dimensionen).
     rAF danach — loaded setzen; Chart-Opacity faded per CSS (0.6s) von 0 auf 1
             auf dem bereits fertig gerenderten, korrekt dimensionierten Frame.
     Vor dem Sync-Gate wuerde auf geschaetzten Label-Bounds gefittet und der Chart
     „springt" in der Groesse, sobald die echte TTF geladen ist (v. a. langsame
     Server). Der Safety-Timeout enthuellt notfalls, falls ein Sync nie fertig wird. */
  requestAnimationFrame(() => {
    const reveal = () => {
      if (state.disposed) return;
      recalcFit();
      /* Legend is now populated and container dimensions have settled —
         refit the 3D perspective camera so layoutDistancePersp /
         layoutDistanceRefPersp match the final container size. Without
         this, the camera keeps the distance computed when the legend was
         still empty, making charts appear too small on narrow viewports. */
      /** Bei Resume aus Deep-Dispose: Kamera NICHT auf Layout-Default refitten — `userHasOrbited`
       *  wurde oben gesetzt, damit `recalcFit` den Refit überspringt; dasselbe gilt für diesen
       *  expliziten Call hier. Waehrend ein Add-on die Kamera steuert (Intro), ebenfalls nicht. */
      if (!state.isOrthographic && !state.isInteracting && !consumedResumeState && !camCtl.isExternallyControlled()) {
        camCtl.refitPerspectiveCamera(sceneInitComplete);
      }
      /* Generischer Post-Init-Hook: Add-ons (Pro) greifen hier ein (z. B. eine eigene
       * Kamera-Intro-Animation starten) — bewusst genau hier, kurz bevor der fertig
       * gelayoutete Chart eingeblendet wird: Labels sind gesynct, die Ruhepose ist final.
       * So laeuft eine Intro-Animation synchron zum Einblenden ab statt schon waehrend
       * des (ggf. langsamen) Ladens. Free selbst nutzt den Hook nicht. */
      try {
        window.__CE_PD_SCENE_EXT__?.runAfterInit?.({ camCtl, state, isMobile, markDirty, opts, wrapper });
      } catch (_) { /* defekten Add-on-Hook ignorieren */ }
      _onNextRenderCb = () => {
        if (!backgroundTransparent) wrapper.style.backgroundColor = backgroundColor;
        wrapper.classList.add('loaded');
        const loader = wrapper.querySelector('.ce-diagram-loader');
        if (loader) loader.setAttribute('aria-busy', 'false');
        scheduleIconHide();
      };
      markDirty();
    };
    onAllLabelsSynced(reveal, 4000);
  });

  /* ── Cleanup ── */
  /** @param {{ exitFullscreen?: boolean, keepSnapshot?: boolean }} [cleanupOpts]
   *  - `exitFullscreen: false` beim Viewer-Sort-Neuaufbau: Vollbild bleibt aktiv (sonst beendet
   *    Fullscreen-API das Vollbild beim Entfernen des fokussierten Elements o. Ä.).
   *  - `keepSnapshot: true` beim Deep-Dispose: Snapshot-`<img>` bleibt im Container als
   *    visueller Placeholder, damit beim Re-Scroll-in kein leerer Chart zu sehen ist. */
  function cleanup(cleanupOpts) {
    if (state.disposed) return;
    state.disposed = true;
    unregisterPendingDeepDispose(wrapper);
    const exitFullscreenAfterTeardown = !cleanupOpts || cleanupOpts.exitFullscreen !== false;
    const keepSnapshotAfterTeardown = !!(cleanupOpts && cleanupOpts.keepSnapshot === true);
    unregisterActiveRenderer(wrapper);
    visibilityLifecycle.unbind();
    if (state.hibernateTimer != null) { clearTimeout(state.hibernateTimer); state.hibernateTimer = null; }
    clearDiagramWakeSchedule(state);
    if (state.deepDisposeTimer != null) { clearTimeout(state.deepDisposeTimer); state.deepDisposeTimer = null; }
    if (ownCanvas.parentNode) ownCanvas.parentNode.removeChild(ownCanvas);
    /** Snapshot-Img nur entfernen, wenn nicht für Deep-Dispose erhalten werden soll.
     *  Alte Leftover-Snapshots (aus früherem Deep-Dispose) werden IMMER entfernt — nur
     *  der aktuelle, frische `snapshotImg` darf als Placeholder bleiben. */
    for (let i = 0; i < leftoverSnapshots.length; i++) {
      const el = leftoverSnapshots[i];
      if (el && el.parentNode) el.parentNode.removeChild(el);
    }
    leftoverSnapshots.length = 0;
    if (!keepSnapshotAfterTeardown) rendererLifecycle.removeSnapshotImg();
    try {
      const taClear = wrapper.querySelector('.ce-diagram-top-actions');
      if (taClear) {
        taClear.classList.remove('ce-diagram-top-actions--ce-suppressed');
        taClear.removeAttribute('aria-hidden');
      }
    } catch (_) { /* ignore */ }
    wrapper.classList.remove(
      'ce-diagram-wrapper--hibernating',
      CE_PD_SNAPSHOT_OVERLAY_CLASS,
      'ce-diagram-wrapper--tap-interact',
    );
    /** Bei Deep-Dispose (`keepSnapshot: true`) bleibt die Pill sichtbar — sonst sieht der User
     *  beim Wieder-Reinscrollen nur den Snapshot und muss zweimal tippen (einmal für Re-Init,
     *  dann erst erscheint „Tap to Interact“). Pill wird im nächsten Init-Lauf aufgeräumt. */
    if (!keepSnapshotAfterTeardown && tapHintEl && tapHintEl.parentNode) {
      tapHintEl.parentNode.removeChild(tapHintEl);
    }
    tapHintEl = null;
    if (viewerSortWrapEl && viewerSortWrapEl.parentNode) {
      viewerSortWrapEl.parentNode.removeChild(viewerSortWrapEl);
    }
    viewerSortWrapEl = null;
    fullscreen.unbind();
    pngExport.unbind();
    if (dataTableCtl) dataTableCtl.unbind();
    if (exitFullscreenAfterTeardown && isDiagramFullscreen()) {
      try {
        const ex = ownerDoc.exitFullscreen || ownerDoc.webkitExitFullscreen || ownerDoc.mozCancelFullScreen || ownerDoc.msExitFullscreen;
        if (ex) ex.call(ownerDoc);
      } catch (_) { /* ignore */ }
    }
    rendererLifecycle.clearCtxRecoverTimer();
    if (_settleTimer) {
      cancelAnimationFrame(_settleTimer);
      _settleTimer = null;
    }
    _pendingAfterSettle = null;
    if (state.animId != null) { cancelAnimationFrame(state.animId); state.animId = null; }
    ownerDoc.removeEventListener('visibilitychange', onVisibilityChange);
    removeScrollUnfreezeCallback(onScrollUnfreeze);
    pointerInput.dispose();
    iconTooltip.unbind();
    /** Tooltip-DIVs wandern beim Init in den body (damit sie nicht von `overflow: hidden`-
     *  Elternelementen abgeschnitten werden). Beim Deep-Dispose-Reinit würde
     *  `wrapper.querySelector('.ce-diagram-tooltip')` sonst null liefern → Hover-Tooltip
     *  und Icon-Tooltip bleiben nach dem Wake stumm. Deshalb nach Teardown (inkl.
     *  iconTooltip.unbind(), das den iconTooltipEl aus dem body entfernt) zurück in die
     *  ursprünglichen DOM-Positionen hängen, damit der Reinit sie wiederfindet. */
    if (tooltip && tooltip.parentNode !== wrapper) wrapper.appendChild(tooltip);
    if (iconTooltipEl && iconEl && iconTooltipEl.parentNode !== iconEl) iconEl.appendChild(iconTooltipEl);
    _ceUnobserveResize(ownerDoc.defaultView, wrapper);
    if (_roTimer !== null) { clearTimeout(_roTimer); _roTimer = null; }
    legend.unbind();
    touchExpand.unbind();
    if (projectionToggle) projectionToggle.destroy();
    if (rulerColorObserver) rulerColorObserver.disconnect();
    ownCanvas.removeEventListener('wheel', camCtl.onPerspectiveWheel, { passive: false });
    camCtl.unbindPerspectivePinch(ownCanvas);
    if (!state.hibernated) {
      bindRendererEvents(renderer, false);
      releaseRenderer(renderer);
      controls.dispose();
    }
    scene.environment = null;
    disposeSceneGeometriesAndMaterials(scene);
    disposeReflector(reflector);
    scene.clear();
  }

  viewerSortWrapEl = createViewerSortControl({
    wrapper,
    ownerDoc,
    opts,
    state,
    reinitCtx: { THREE, OrbitControls, RoundedBoxGeometry, Reflector, wrapper, container, tooltip, iconEl },
    cleanup,
    initScene: (ctx) => enqueueSerializedDiagramInit(() => initScene(ctx)),
  });

  const pngExport = createPngExport({
    THREE, wrapper, container, ownerDoc, state, opts,
    i18nPngDownload, pngDownloadBtn,
    renderer, scene, getActiveCamera: () => camCtl.activeCamera,
    backgroundTransparent, backgroundColor,
    wake: wakeGpuSerial,
    hibernate,
  });

  /* ── 2D/3D-Projektions-Toggle (Frontend) ──
   * Bindet den Top-Actions-Button an camCtl.tweenToProjection. Für nicht-toggelbare
   * Layouts (arc, surface) rendert PHP den Button erst gar nicht — der Controller
   * returnt dann früh null, kein Overhead. */
  const projectionToggle = createProjectionToggle({ wrapper, state, camCtl });

  /* ── Touch: Buttons kurz aufklappen, dann automatisch zuklappen ── */
  const touchExpand = bindTouchExpandButtons(wrapper);

  if (wrapper.classList.contains('ce-diagram-editor-preview')) {
    wrapper.__cePdEditorPreviewReflowLayout = function cePdEditorPreviewReflowLayout() {
      if (state.disposed) return;
      stageHeightCtl.refreshHeaderFooterHeight();
      settleLayout();
    };
  }

  const _prevCleanup = cleanup;
  wrapper._ceDiagramCleanup = function diagramCleanup() {
    delete wrapper.__cePdEditorPreviewReflowLayout;
    delete wrapper.__cePdEditorPreviewApplyAxisLabelsFromConfig;
    _prevCleanup();
  };
  return wrapper._ceDiagramCleanup;
}
