export const GRAY_PALETTE = [0x666666, 0x888888, 0xaaaaaa, 0x888888, 0x666666, 0xcccccc, 0xaaaaaa, 0x888888, 0x666666, 0xaaaaaa];

/**
 * WebGL-Renderer: effektives Pixel-Ratio = min(window.devicePixelRatio, cap).
 * Mobile/Tablet-Erkennung in scene/init.js entspricht rendererPool.IS_MOBILE_GLOBAL
 * (Touch vorhanden oder Viewport unter 768px).
 */
export const WEBGL_PIXEL_RATIO_CAP = {
  desktop: 2,
  mobileOrTablet: 3,
};

/**
 * Multiplier > 1 ⇒ etwas Luft zum Viewport-Rand beim Kamera-Fit (vs. maximal enges Framing).
 * Pseudo-Ortho (2D): frontales `computeDesiredCamera` in cameraController (+ optional Angleich
 * an die Default-3D-Distanz). Perspective (3D): bei Layouts ohne eigenes Pad
 * (`perspEffectivePad`). Surface nutzt denselben Wert in `padForLayout()` (cameraController.js).
 */
export const CAMERA_VIEWPORT_INSET_MULTIPLIER = 1.08;

/**
 * Welt-Rand beim Pannen: Das Orbit-/Pseudo-Ortho-Target darf nur so weit vom
 * Layout-Frame-Mittelpunkt abweichen, dass die Bounding Box (+ Padding) nicht
 * „beliebig“ aus dem Bild geschoben werden kann.
 */
export const DIAGRAM_PAN_PADDING_WORLD = 0.35;

/**
 * Entfernt führende normale/NBSP-Leerzeichen aus Achsen-unitSuffix (z. B. Legacy „ %“).
 * Zahl und Suffix werden ohne dazwischenliegenden Zeichen verbunden — Troika/Barlow
 * messen Hair/Thin-Space oft so breit wie U+0020.
 */
export function stripLeadingAxisUnitWhitespace(s) {
  if (s == null || s === '') return '';
  return String(s).replace(/^[ \u00A0]+/, '');
}

/** Tooltip / HTML-Legende: schmaler Abstand zwischen Zahl(en) und Einheit (Hair Space — im Browser meistenger als U+0020). */
export function joinHtmlTickValueAndSuffix(numberStr, suffixRaw) {
  const suf = stripLeadingAxisUnitWhitespace(suffixRaw);
  return suf !== '' ? String(numberStr) + '\u200A' + suf : String(numberStr);
}

/**
 * Hibernate-Snapshot (Placeholder-Bild nach WebGL-Freigabe): nur auf Mobile wird das
 * Canvas vor toDataURL auf max. Kantenlänge verkleinert (RAM/Data-URL-Größe).
 * Desktop: volle Canvas-Pixelgröße (width/height × Renderer-Pixel-Ratio).
 */
export const HIBERNATE_SNAPSHOT_MAX_SIDE_MOBILE = 2048;

/** WebP-Kodierung für Snapshots (0–1). Fallback PNG ohne Qualitätsparameter. */
export const HIBERNATE_SNAPSHOT_WEBP_QUALITY = 0.88;

/**
 * Standard-Paletten: festzyklische Paletten (vivid, oceanSunset, purpleRaindrops,
 * spectrum, magentaDream, twilight, accessible, material, corporate, monochrome, diverging).
 * Add-on-Plugins koennen weitere Paletten in `window.__CE_PD_PALETTES__` registrieren.
 */
export const COLOR_SCHEMES = {
  vivid: [0xe63939, 0xe68a2e, 0xe0c030, 0x7eb82e, 0x2eb85c, 0x2eb8a8, 0x2e5cb8, 0x5c2eb8, 0xa82eb8, 0xb82e5c],
  /* "oceanSunset": diskreter 10-Farben-Verlauf von Tiefsee-Teal bis Sonnenuntergang-Rot. */
  oceanSunset: [0x001219, 0x005f73, 0x0a9396, 0x94d2bd, 0xe9d8a6, 0xee9b00, 0xca6702, 0xbb3e03, 0xae2012, 0x9b2226],
  /* "purpleRaindrops": diskreter 10-Farben-Verlauf von Neon-Pink ueber Indigo bis Himmel-Aqua. */
  purpleRaindrops: [0xf72585, 0xb5179e, 0x7209b7, 0x560bad, 0x480ca8, 0x3a0ca3, 0x3f37c9, 0x4361ee, 0x4895ef, 0x4cc9f0],
  /* "spectrum": lebendiges 12-Farben-Vollspektrum von Blau ueber Gruen/Gelb/Orange bis Magenta. */
  spectrum: [0x3a86ff, 0x4895ef, 0x4cc9f0, 0x06d6a0, 0x2dc653, 0x80b918, 0xffd000, 0xff9e00, 0xff7b00, 0xff5400, 0xf72585, 0xb5179e],
  /* "magentaDream": weicher 9-Farben-Verlauf von Kirsch-Magenta ueber Lila bis Pazifik-Cyan. */
  magentaDream: [0xb7094c, 0xa01a58, 0x892b64, 0x723c70, 0x5c4d7d, 0x455e89, 0x2e6f95, 0x1780a1, 0x0091ad],
  /* "twilight": 30-Farben Neon-Palette, bewusst in zehn analoge 3er-Gruppen geordnet,
   * damit jeder Gradient-Balken (Triplett P[3n..3n+2]) einen eigenen, in sich harmonischen
   * Neon-Verlauf bekommt (kein matschiges Quer-Mischen). Hue-Bogen ueber das ganze Spektrum:
   * Gruen->Cyan->Blau->Violett->Magenta->Pink->Rot->Orange->Gelb->Gruen. */
  twilight: [
    0x4fff9c, 0x1fd6e0, 0x2f5cf0, // 1: Gruen -> Cyan -> Blau
    0x4ff7e0, 0x2f8af0, 0x3322c0, // 2: Aqua -> Blau -> Indigo
    0x6ec8ff, 0x4f66f0, 0x7a26d0, // 3: Himmelblau -> Blau -> Violett
    0xc89cff, 0x9b3cf0, 0xb01fa0, // 4: Lavendel -> Purpur -> Magenta
    0xff9ce6, 0xe83cd6, 0x7a1fb0, // 5: Pink -> Magenta -> Purpur
    0xffaad0, 0xff5a7a, 0xa81f3a, // 6: Hellpink -> Rose -> Karmin
    0xffc09c, 0xff6a4a, 0xb01f24, // 7: Pfirsich -> Koralle -> Tiefrot
    0xffe85c, 0xff9e2e, 0xc81f2e, // 8: Gelb -> Orange -> Rot
    0xeaff8a, 0xa6e63d, 0x2e8a2e, // 9: Hellgruen -> Limette -> Gruen
    0x8affc0, 0x3dd67a, 0x1f7a6a, // 10: Mint -> Gruen -> Petrol
  ],
  accessible: [0xe69f00, 0x56b4e9, 0x009e73, 0xf0e442, 0x0072b2, 0xd55e00, 0xcc79a7, 0x332288, 0x44aa99, 0x882255],
  material: [0xf44336, 0x2196f3, 0x4caf50, 0xff9800, 0x9c27b0, 0x009688, 0xffc107, 0xe91e63, 0x3f51b5, 0x8bc34a],
  corporate: [0x1f3a5f, 0x7a2e3a, 0x3a5c3a, 0xa8851a, 0x546e7a, 0x8a5a3a, 0x4a6b8a, 0x6b3a5c, 0x8a7355, 0x3a6b6b],
  /* "monochrome": neutrale 10-stufige Graustufenskala von dunkel nach hell (s = 0). */
  monochrome: [0x222222, 0x393939, 0x515151, 0x696969, 0x818181, 0x999999, 0xb0b0b0, 0xc8c8c8, 0xd6d6d6, 0xe4e4e4],
  /* "diverging": RdBu-Diverging-Skala (Rot -> helle Mitte -> Blau) fuer +/- Daten;
   * reinweisses Zentrum bewusst weggelassen, damit kein Balken auf Weiss verschwindet. */
  diverging: [0x67001f, 0xb2182b, 0xd6604d, 0xf4a582, 0xfddbc7, 0xd1e5f0, 0x92c5de, 0x4393c3, 0x2166ac, 0x053061],
};

export const DEFAULT_COLOR_SCHEME = 'vivid';

/**
 * Palette-Lookup: schaut zuerst in den Standard-Schemes, dann im globalen
 * Add-on-Registry `window.__CE_PD_PALETTES__`.
 *
 * @param {string} schemeId
 * @param {number} [barCount]
 * @param {Array}  [paletteOverride]  Optionales explizites Paletten-Array,
 *                                    das Add-ons als dynamische Palette
 *                                    nutzen koennen.
 */
export function getHoverPaletteForScheme(schemeId, barCount = 10, paletteOverride) {
  const raw = typeof schemeId === 'string' ? schemeId : DEFAULT_COLOR_SCHEME;
  if (COLOR_SCHEMES[raw]) return COLOR_SCHEMES[raw];
  const reg = (typeof window !== 'undefined' && window.__CE_PD_PALETTES__) ? window.__CE_PD_PALETTES__ : null;
  if (reg && reg[raw]) {
    const entry = reg[raw];
    if (Array.isArray(entry)) return entry;
    if (typeof entry === 'function') {
      try {
        const out = entry(barCount, paletteOverride);
        if (Array.isArray(out) && out.length > 0) return out;
      } catch (_) { /* fall through */ }
    } else if (entry && Array.isArray(entry.palette)) {
      return entry.palette;
    } else if (entry && typeof entry.build === 'function') {
      try {
        const out = entry.build(barCount, paletteOverride);
        if (Array.isArray(out) && out.length > 0) return out;
      } catch (_) { /* fall through */ }
    }
  }
  return COLOR_SCHEMES.vivid;
}

/** @deprecated Use getHoverPaletteForScheme */
export const HOVER_PALETTE = COLOR_SCHEMES.vivid;
export const LAYOUT_ORDER = ['straight', 'stacked', 'pie', 'pieStairs'];
export const MAX_BARS = 1000;

/** Pie-artige Layouts: aufrechtes Pie/Donut + liegendes „Stairs"-Donut. Teilen
 *  sich denselben `createPie`-Renderer und dieselbe Pie-Konfiguration. */
export const PIE_LAYOUTS = ['pie', 'pieStairs'];
export function isPieLayout(layout) {
  return layout === 'pie' || layout === 'pieStairs';
}

/** Line/Area (Wave) — Free-eigenes Layout (nicht Roadmap). */
export const WAVE_LAYOUTS = ['wave'];
export function isWaveLayout(layout) {
  return layout === 'wave';
}

/** Pyramid-Infografik — Free-eigenes Layout (2D/3D wie Pie). */
export const PYRAMID_LAYOUTS = ['pyramid'];
export function isPyramidLayout(layout) {
  return layout === 'pyramid';
}

const WAVE_LABEL_MODES = ['off', 'value', 'headline'];

export function resolveWaveLabelAtNodes(config) {
  return WAVE_LABEL_MODES.includes(config?.waveLabelAtNodes) ? config.waveLabelAtNodes : 'off';
}

export function resolveWaveLabelAlongFloor(config) {
  return WAVE_LABEL_MODES.includes(config?.waveLabelAlongFloor) ? config.waveLabelAlongFloor : 'off';
}

export function mergeSeriesAxisXLabelTransform(override) {
  const base = { position: { x: 0, y: 0, z: 0 }, rotationDeg: { x: 0, y: 0, z: 0 } };
  if (!override || typeof override !== 'object') return base;
  const pos = override.position && typeof override.position === 'object' ? override.position : {};
  const rot = override.rotationDeg && typeof override.rotationDeg === 'object' ? override.rotationDeg : {};
  return {
    position: { x: Number(pos.x) || 0, y: Number(pos.y) || 0, z: Number(pos.z) || 0 },
    rotationDeg: { x: Number(rot.x) || 0, y: Number(rot.y) || 0, z: Number(rot.z) || 0 },
  };
}

export function isEmptyBar(v) { return Math.abs(Number(v)) < 1e-9; }

export function parseBarColor(hex) {
  if (!hex || typeof hex !== 'string') return null;
  const m = String(hex).replace(/^#/, '').match(/^([0-9a-fA-F]{6})$/);
  return m ? parseInt(m[1], 16) : null;
}

/**
 * Per-row color: customColor wins; then optional uniform default for all rows; else scheme palette.
 * @param {number|null} uniformColor - parsed default for rows without customColor (block-level)
 */
export function resolveDataItemColor(item, displayIndex, hoverPalette, uniformColor) {
  if (item.customColor != null) return item.customColor;
  if (uniformColor != null) return uniformColor;
  const palIdx = paletteIndexForDataItem(item, displayIndex);
  return hoverPalette[palIdx % hoverPalette.length];
}

/** Stable palette slot when rows are reordered (see _ceBarIndex from author row order). */
export function paletteIndexForDataItem(item, displayIndex) {
  if (item._paletteIndex != null) return item._paletteIndex;
  if (item._origPaletteIndex != null) return item._origPaletteIndex;
  return displayIndex;
}

export function toInternalData(bars, layout) {
  void layout;
  return (bars || []).map((b, i) => {
    const value = (() => { const v = Number(b.value); return isNaN(v) ? 1 : v; })();
    const rawX = Number(b.scatterX);
    const rawY = Number(b.scatterY);
    const scatterX = Number.isFinite(rawX) ? rawX : i;
    const scatterY = Number.isFinite(rawY) ? rawY : value;
    const origPi = Number.isFinite(Number(b._ceBarIndex)) ? Number(b._ceBarIndex) : i;
    return {
      label: b.headline || '',
      value,
      unit: b.unit || '',
      desc: b.paragraph || '',
      source: b.smallPrint || '',
      groupKey: b.groupKey != null && String(b.groupKey).trim() !== '' ? String(b.groupKey).trim() : '',
      color: GRAY_PALETTE[origPi % GRAY_PALETTE.length],
      _origPaletteIndex: origPi,
      customColor: parseBarColor(b.color),
      scatterX,
      scatterY,
      annotation: typeof b.annotation === 'string' ? b.annotation.trim() : '',
      annotationDesc: typeof b.annotationDesc === 'string' ? b.annotationDesc.trim() : '',
      link: typeof b.link === 'string' ? b.link : '',
      linkNewTab: b.linkNewTab === true,
    };
  });
}

export function sortBars(bars, sortOrder) {
  if (!sortOrder || sortOrder === 'custom') return [...bars];
  const arr = [...bars];
  if (sortOrder === 'value_asc') arr.sort((a, b) => (Number(a.value) || 0) - (Number(b.value) || 0));
  else if (sortOrder === 'value_desc') arr.sort((a, b) => (Number(b.value) || 0) - (Number(a.value) || 0));
  else if (sortOrder === 'headline_asc') arr.sort((a, b) => String(a.headline || '').localeCompare(String(b.headline || ''), undefined, { sensitivity: 'base' }));
  return arr;
}

/** Same rule as groupedLayout.js for DATA index i. */
export function groupedBarStorageKey(bar, index) {
  const raw = bar.groupKey;
  return raw != null && String(raw).trim() !== '' ? String(raw).trim() : `__singleton_${index}`;
}

/**
 * Grouped layout: sort **clusters** on the X axis, never individual bars across groups.
 * - custom: storage order
 * - value_asc / value_desc: order groups by sum of row values in each group; within a group, keep table order
 * - headline_asc: order groups by X group key (singleton groups by first row label); within a group, by row label
 * @param {{ bar: object, originalIndex: number }[]} indexed rows in saved order
 */
export function sortGroupedBarRowsWithIndices(indexed, sortOrder) {
  if (!indexed.length) return [];
  if (!sortOrder || sortOrder === 'custom') return [...indexed];

  const n = indexed.length;
  const keyOrder = [];
  const keyToItems = new Map();
  for (let i = 0; i < n; i++) {
    const item = indexed[i];
    const key = groupedBarStorageKey(item.bar, i);
    if (!keyToItems.has(key)) {
      keyToItems.set(key, []);
      keyOrder.push(key);
    }
    keyToItems.get(key).push(item);
  }

  if (sortOrder === 'value_asc' || sortOrder === 'value_desc') {
    const mul = sortOrder === 'value_asc' ? 1 : -1;
    const keysSorted = [...keyOrder].sort((ka, kb) => {
      const sumA = keyToItems.get(ka).reduce((s, x) => s + (Number(x.bar.value) || 0), 0);
      const sumB = keyToItems.get(kb).reduce((s, x) => s + (Number(x.bar.value) || 0), 0);
      if (sumA !== sumB) return mul * (sumA - sumB);
      return String(ka).localeCompare(String(kb), undefined, { sensitivity: 'base' });
    });
    return keysSorted.flatMap((k) => keyToItems.get(k));
  }

  if (sortOrder === 'headline_asc') {
    const groupSortKey = (k) => {
      if (k.startsWith('__singleton_')) {
        const first = keyToItems.get(k)[0];
        return String(first?.bar?.headline || '');
      }
      return k;
    };
    const keysSorted = [...keyOrder].sort((ka, kb) =>
      groupSortKey(ka).localeCompare(groupSortKey(kb), undefined, { sensitivity: 'base' }));
    return keysSorted.flatMap((k) => {
      const rows = [...keyToItems.get(k)];
      rows.sort((a, b) => String(a.bar.headline || '').localeCompare(String(b.bar.headline || ''), undefined, { sensitivity: 'base' }));
      return rows;
    });
  }

  return [...indexed];
}

export function sortBarsGrouped(bars, sortOrder) {
  const indexed = bars.map((bar, originalIndex) => ({ bar, originalIndex }));
  return sortGroupedBarRowsWithIndices(indexed, sortOrder).map((x) => x.bar);
}

export function i18nStr(val, fallback = '') {
  return typeof val === 'string' ? val.trim() : fallback;
}

export function escapeHtml(s) {
  if (!s) return '';
  const div = document.createElement('div');
  div.textContent = s;
  return div.innerHTML;
}

/** Für dynamische Werte in HTML-Attributen (aria-label, title, …). */
export function escapeAttr(s) {
  if (s == null || s === '') return '';
  return String(s)
    .replace(/&/g, '&amp;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#39;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;');
}

const STRAIGHT_BAR_LABEL_SLOTS = ['off', 'value', 'headline'];

/**
 * Straight layout: Beschriftung oberhalb/unterhalb der Balken.
 * Leere Strings = noch nicht gespeichert → Migration über labelsUnderBars (alt: nur „Bezeichnung unten“).
 */
export function resolveStraightBarLabels(config) {
  const c = config || {};
  const unset = (v) => v === '' || v === undefined || v === null;
  let above = c.straightBarLabelAbove;
  let below = c.straightBarLabelBelow;
  if (unset(above) && unset(below)) {
    if (c.labelsUnderBars === true) return { above: 'off', below: 'headline' };
    return { above: 'off', below: 'off' };
  }
  const norm = (v) => (STRAIGHT_BAR_LABEL_SLOTS.includes(v) ? v : 'off');
  above = norm(above);
  below = norm(below);
  if (above === below && above !== 'off') {
    below = 'off';
  }
  return { above, below };
}

/**
 * Stacked layout: eine Summen-Beschriftung über der gesamten Säule.
 * `stackedBarSumAbove` fehlt in alten Speicherständen → Default false.
 */
export function resolveStackedBarSumAbove(config) {
  const c = config || {};
  if (c.defaultLayout !== 'stacked') return false;
  return c.stackedBarSumAbove === true;
}

const CIRCLE_LABEL_STYLES = ['shadow', 'pill', 'simple'];

/** Pie: Label-Stil ('shadow' | 'pill' | 'simple'). BubblePack nutzt nur shadow/pill. */
export function resolveCircleLabelStyle(config) {
  const raw = (config || {}).circleLabelStyle;
  if (raw === '' || raw === undefined || raw === null) return 'simple';
  return CIRCLE_LABEL_STYLES.includes(raw) ? raw : 'simple';
}

/** Pie: Anteil des Innenradius am Außenradius (0 = Vollkreis/Pie, 0.85 = dünner Ring). Default 0.5. */
export function resolvePieInnerRadius(config) {
  const n = (config || {}).pieInnerRadius;
  if (typeof n !== 'number' || !Number.isFinite(n)) return 0.5;
  return Math.max(0, Math.min(0.85, n));
}

/** Pie/Donut: Multiplikator für die Extrusionstiefe (Dicke) der Segmente. 1 = Default. */
export function resolvePieThickness(config) {
  const n = Number((config || {}).pieThickness);
  if (!Number.isFinite(n)) return 1;
  return Math.max(0.5, Math.min(5, n));
}

const PIE_ANGLE_BASIS_MODES = ['sum', 'hundred'];

/**
 * Pie/Donut: Winkelbasis. sum = Anteil an Datensumme (füllt den Ring).
 * hundred = Anteil von 100 % als Vollkreis (Lücke wenn Summe unter 100; bei Summe über 100 proportional verkleinern).
 */
export function resolvePieAngleBasis(config) {
  const raw = (config || {}).pieAngleBasis;
  if (raw === '' || raw === undefined || raw === null) return 'sum';
  return PIE_ANGLE_BASIS_MODES.includes(raw) ? raw : 'sum';
}

const PIE_SLICE_LABEL_MODES = ['off', 'value', 'headline', 'value_callout'];

/** Pie: Beschriftung in den Segmenten (aus / Wert+Einheit / Zeilen-Titel). Leer → „value“ (wie bisher ohne Schalter). */
export function resolvePieSliceLabelMode(config) {
  const c = config || {};
  const raw = c.pieSliceLabel;
  const unset = (v) => v === '' || v === undefined || v === null;
  if (unset(raw)) return 'value';
  return PIE_SLICE_LABEL_MODES.includes(raw) ? raw : 'value';
}

/** Pie: Länge der Führungslinie für Außen-Bezeichnung (value_callout). Wie Wave-Annotation, Default 0.25. */
export function resolvePieCalloutLineLength(config) {
  const n = Number((config || {}).pieCalloutLineLength);
  if (!Number.isFinite(n)) return 0.25;
  return Math.max(0.1, Math.min(0.6, n));
}

/** Pie: Schriftgrad der Außenbeschriftung bei value_callout (wie RangeControl im Editor). */
export function resolvePieCalloutFontScale(config) {
  const n = Number((config || {}).pieCalloutFontScale);
  if (!Number.isFinite(n)) return 1;
  return Math.max(0.3, Math.min(3, n));
}

/** Pie: optionale #rrggbb für Führungslinie + Außenbeschriftung (value_callout). Leer → Kontrast zum Diagrammhintergrund. */
export function resolvePieCalloutColor(config) {
  const raw = (config || {}).pieCalloutColor;
  if (typeof raw !== 'string') return '';
  const t = raw.trim();
  return /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(t) ? t : '';
}

/** Pie: Segmenttext schwarz/weiß nach Füllfarbe (neben Shadow/Pill). Default true; nur explizit false schaltet ab. */
export function resolvePieLabelAutoContrast(config) {
  const v = (config || {}).pieLabelAutoContrast;
  if (v === false) return false;
  return true;
}

const PYRAMID_SLICE_LABEL_MODES = ['off', 'value', 'headline'];

/** Pyramid: Beschriftung in den Segmenten. Leer → „off“. value_callout (entfernt) → „value“. */
export function resolvePyramidSliceLabelMode(config) {
  const c = config || {};
  const raw = c.pyramidSliceLabel;
  const unset = (v) => v === '' || v === undefined || v === null;
  if (unset(raw)) return 'off';
  if (raw === 'value_callout') return 'value';
  return PYRAMID_SLICE_LABEL_MODES.includes(raw) ? raw : 'off';
}

/** Pyramid: Abstand zwischen Segmenten in Welt-Einheiten (0 = flush). Default 0. */
export function resolvePyramidSegmentGap(config) {
  const n = Number((config || {}).pyramidSegmentGap);
  if (!Number.isFinite(n)) return 0;
  return Math.max(0, Math.min(0.08, n));
}

/** Pyramid: klassisch (Spitze oben) vs. invertiert. Default false. */
export function resolvePyramidInverted(config) {
  return (config || {}).pyramidInverted === true;
}

/** Pyramid: Länge der Führungslinie für value_callout. Default 0.25. */
export function resolvePyramidCalloutLineLength(config) {
  const n = Number((config || {}).pyramidCalloutLineLength);
  if (!Number.isFinite(n)) return 0.25;
  return Math.max(0.1, Math.min(0.6, n));
}

/** Pyramid: Schriftgrad der Außenbeschriftung bei value_callout. */
export function resolvePyramidCalloutFontScale(config) {
  const n = Number((config || {}).pyramidCalloutFontScale);
  if (!Number.isFinite(n)) return 1;
  return Math.max(0.5, Math.min(2, n));
}

/** Pyramid: optionale #rrggbb für Führungslinie + Außenbeschriftung. Leer → Kontrast zum Hintergrund. */
export function resolvePyramidCalloutColor(config) {
  const raw = (config || {}).pyramidCalloutColor;
  if (typeof raw !== 'string') return '';
  const t = raw.trim();
  return /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(t) ? t : '';
}

/** Pyramid: Segmenttext schwarz/weiß nach Füllfarbe. Default true. */
export function resolvePyramidLabelAutoContrast(config) {
  const v = (config || {}).pyramidLabelAutoContrast;
  if (v === false) return false;
  return true;
}

/** Pyramid: seitliche Anmerkungen aus Diagrammdaten (label/desc/source), alternierend links/rechts. */
export function resolvePyramidAnnotation(config) {
  const c = config || {};
  if (c.pyramidAnnotation === true) return true;
  return c.pyramidAnnotationLeft === true || c.pyramidAnnotationRight === true;
}

/** Pyramid: Länge der seitlichen Annotations-Führungslinien. Default 0.25. */
export function resolvePyramidAnnotationLineLength(config) {
  const n = Number((config || {}).pyramidAnnotationLineLength);
  if (!Number.isFinite(n)) return 0.25;
  return Math.max(0.1, Math.min(0.6, n));
}

/** Pyramid: Schriftgrad der seitlichen Annotationen. */
export function resolvePyramidAnnotationFontScale(config) {
  const n = Number((config || {}).pyramidAnnotationFontScale);
  if (!Number.isFinite(n)) return 1;
  return Math.max(0.5, Math.min(2, n));
}

/** Stairs-Pie/Donut: Höhenzuwachs pro Segment in Welt-Einheiten (Treppenstufe). Default 0.18. */
export function resolveStairStepHeight(config) {
  const n = Number((config || {}).stairStepHeight);
  if (!Number.isFinite(n)) return 0.18;
  return Math.max(0, Math.min(0.8, n));
}

/* ── pieStairs Schatten-Regler — hier zentral justieren ──────────────────────
 *  Werte werden direkt in scene/layouts/pie.js (Opacity, Kontakt) und
 *  scene/lights.js (Weichheit) importiert. Nur pieStairs wirft Schatten. */
/** Deckkraft des weichen Schlagschattens auf dem Boden. 0 = aus, sinnvoll ~0–0.4. */
export const PIE_STAIRS_SHADOW_OPACITY = 0.18;
/** Weichheit der Schattenränder (keyLight.shadow.radius). Höher = weicher, ~0–20. */
export const PIE_STAIRS_SHADOW_SOFTNESS = 10;
/** pieStairs: Stärke des Kontakt-AO-Saums am Scheibenrand. 0 = aus, sinnvoll ~0–0.4. */
export const PIE_STAIRS_CONTACT_STRENGTH = 0.1;

/**
 * WCAG relative luminance → '#000' on light backgrounds, '#fff' on dark ones.
 * Accepts '#rgb', '#rrggbb', 'rgb', 'rrggbb'. Returns '' for invalid input.
 * @param {string} hexBg
 * @returns {string}
 */
export function contrastTextColor(hexBg) {
  let s = String(hexBg || '').replace(/^#/, '').trim();
  if (s.length === 3) s = s[0] + s[0] + s[1] + s[1] + s[2] + s[2];
  if (!/^[0-9a-fA-F]{6}$/.test(s)) return '';
  const lin = (c) => (c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4));
  const L = 0.2126 * lin(parseInt(s.slice(0, 2), 16) / 255)
          + 0.7152 * lin(parseInt(s.slice(2, 4), 16) / 255)
          + 0.0722 * lin(parseInt(s.slice(4, 6), 16) / 255);
  return L > 0.179 ? '#000' : '#fff';
}

/** Titel, Untertitel, Fußnote: optionales #rgb/#rrggbb. Leer → Autokontrast (nicht transparent) bzw. CSS-Default. */
export function resolveHeaderTextColorAttr(raw) {
  if (typeof raw !== 'string') return '';
  const t = raw.trim();
  return /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(t) ? t : '';
}
