@myrmidon/gve-snapshot-rendition 2.0.9 → 2.0.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -12,6 +12,8 @@ const DEFAULT_SETTINGS = {
12
12
  underline: undefined,
13
13
  overline: undefined,
14
14
  strike: undefined,
15
+ textLineStyle: undefined,
16
+ textLineColor: undefined,
15
17
  // Spacing settings
16
18
  spaceWidthFraction: 0.33,
17
19
  charSpacing: 2,
@@ -695,9 +697,13 @@ function applyTextStyle(element, style) {
695
697
  attrs["font-style"] = "italic";
696
698
  if (style.bold)
697
699
  attrs["font-weight"] = "bold";
698
- if (style.rotate)
699
- attrs["transform"] = `rotate(${style.rotate})`;
700
- // Text decorations
700
+ setAttributes(element, attrs);
701
+ // Text decorations. Applied via inline CSS longhands (not the SVG
702
+ // text-decoration attribute) so that thickness, line style and color are
703
+ // honored by the text layout engine and follow the glyphs when the
704
+ // character is rotated. NOTE: per-character rotation is NOT handled here,
705
+ // because a plain rotate() would spin the glyph around the SVG origin;
706
+ // renderers apply rotate(deg, x, y) around each character's own anchor.
701
707
  const decorations = [];
702
708
  if (style.underline)
703
709
  decorations.push("underline");
@@ -706,9 +712,20 @@ function applyTextStyle(element, style) {
706
712
  if (style.strike)
707
713
  decorations.push("line-through");
708
714
  if (decorations.length > 0) {
709
- attrs["text-decoration"] = decorations.join(" ");
715
+ element.style.textDecorationLine = decorations.join(" ");
716
+ // CSS has a single thickness for all decoration lines of an element:
717
+ // use the first non-zero one (they are rarely combined).
718
+ const thickness = style.underline || style.overline || style.strike;
719
+ if (thickness && thickness > 0) {
720
+ element.style.textDecorationThickness = `${thickness}px`;
721
+ }
722
+ if (style.textLineStyle) {
723
+ element.style.textDecorationStyle = style.textLineStyle;
724
+ }
725
+ if (style.textLineColor) {
726
+ element.style.textDecorationColor = style.textLineColor;
727
+ }
710
728
  }
711
- setAttributes(element, attrs);
712
729
  }
713
730
  /**
714
731
  * Parse SVG from string and return the root element.
@@ -803,6 +820,32 @@ function getTransformedBBox(element) {
803
820
  return new DOMRect(0, 0, 0, 0);
804
821
  }
805
822
  }
823
+ /**
824
+ * Compute the axis-aligned bounding box of a rectangle rotated around a
825
+ * point, using SVG rotation direction (positive = clockwise, y-axis down).
826
+ *
827
+ * Used to cache correct bounds for per-character rotated text: getBBox()
828
+ * returns LOCAL coordinates which ignore the rotate(deg, x, y) transform
829
+ * applied to the element, while getTransformedBBox() would also bake in
830
+ * ancestor transforms (e.g. the pan/zoom viewport), which must stay out
831
+ * of the bounds cache.
832
+ */
833
+ function rotateRectAroundPoint(rect, degrees, cx, cy) {
834
+ const rad = (degrees * Math.PI) / 180;
835
+ const cos = Math.cos(rad);
836
+ const sin = Math.sin(rad);
837
+ const corners = [
838
+ [rect.x, rect.y],
839
+ [rect.x + rect.width, rect.y],
840
+ [rect.x, rect.y + rect.height],
841
+ [rect.x + rect.width, rect.y + rect.height],
842
+ ];
843
+ const xs = corners.map(([x, y]) => cx + (x - cx) * cos - (y - cy) * sin);
844
+ const ys = corners.map(([x, y]) => cy + (x - cx) * sin + (y - cy) * cos);
845
+ const minX = Math.min(...xs);
846
+ const minY = Math.min(...ys);
847
+ return new DOMRect(minX, minY, Math.max(...xs) - minX, Math.max(...ys) - minY);
848
+ }
806
849
  /**
807
850
  * Get computed text width for a given text and style.
808
851
  * Creates a temporary SVG element to measure.
@@ -921,16 +964,30 @@ function parseOffset(value, textHeight, textWidth) {
921
964
  }
922
965
  /**
923
966
  * Replace placeholders in SVG content.
924
- * Placeholders are in the format {{featureName}}.
967
+ * Placeholders are in the format {{featureName}}, or a fallback chain
968
+ * {{name ?? other ?? ...}}: names are tried left to right and the first one
969
+ * having a variable wins. There is no limit to the number of fallbacks.
970
+ * For instance {{r_fore-color ?? r_color}} resolves to the value of
971
+ * r_fore-color when defined, otherwise to the value of r_color.
925
972
  *
926
973
  * @param svgContent - The SVG content with placeholders
927
974
  * @param variables - Map of variable names to values
928
975
  * @returns SVG content with placeholders replaced
929
976
  */
930
977
  function resolvePlaceholders(svgContent, variables) {
931
- // Match {{placeholder}} where placeholder can contain letters, numbers, hyphens, and underscores
932
- return svgContent.replace(/\{\{([\w\-]+)\}\}/g, (match, name) => {
933
- return variables.get(name) || match; // Keep placeholder if not found
978
+ // Match {{name}} or {{name ?? name ?? ...}} where each name can contain
979
+ // letters, numbers, hyphens, and underscores
980
+ return svgContent.replace(/\{\{\s*([\w\-]+(?:\s*\?\?\s*[\w\-]+)*)\s*\}\}/g, (match, chain) => {
981
+ const names = chain.split("??").map((n) => n.trim());
982
+ for (const name of names) {
983
+ const value = variables.get(name);
984
+ // An empty string is a defined value; only a missing variable
985
+ // falls through to the next name in the chain
986
+ if (value !== undefined) {
987
+ return value;
988
+ }
989
+ }
990
+ return match; // Keep placeholder if no name in the chain resolves
934
991
  });
935
992
  }
936
993
 
@@ -1028,12 +1085,17 @@ function parseDisplacedSpan(value) {
1028
1085
  */
1029
1086
  function parseHintVars(value) {
1030
1087
  const result = new Map();
1031
- // Match pattern name=value
1088
+ // Match pattern name=value, splitting at the first "=" only so that
1089
+ // values containing "=" (e.g. URLs with query strings) survive intact
1032
1090
  const pairs = value.trim().split(/\s+/);
1033
1091
  for (const pair of pairs) {
1034
- const [name, val] = pair.split("=");
1092
+ const sepIndex = pair.indexOf("=");
1093
+ if (sepIndex <= 0)
1094
+ continue;
1095
+ const name = pair.substring(0, sepIndex).trim();
1096
+ const val = pair.substring(sepIndex + 1).trim();
1035
1097
  if (name && val) {
1036
- result.set(name.trim(), val.trim());
1098
+ result.set(name, val);
1037
1099
  }
1038
1100
  }
1039
1101
  return result;
@@ -1300,9 +1362,11 @@ class TextRenderer {
1300
1362
  const node = nodes[i];
1301
1363
  const pos = positions[i];
1302
1364
  // Cache bounds for spaces even though they're not rendered
1303
- // This is needed for RBR calculations that include spaces
1365
+ // This is needed for RBR calculations that include spaces.
1366
+ // Use the layout's space width (reference char width × fraction) so the
1367
+ // cached bounds match the horizontal advance actually applied.
1304
1368
  if (isSpace(node)) {
1305
- const spaceWidth = this._settings.fontSize * this._settings.spaceWidthFraction;
1369
+ const spaceWidth = this._textLayout.getSpaceWidth();
1306
1370
  const spaceY = pos.y - this._settings.fontSize; // Approximate top of character
1307
1371
  // Create a DOMRect-like object for the space bounds
1308
1372
  const spaceBounds = {
@@ -1352,6 +1416,11 @@ class TextRenderer {
1352
1416
  backColor: config.backColor,
1353
1417
  italic: config.italic,
1354
1418
  bold: config.bold,
1419
+ underline: config.underline,
1420
+ overline: config.overline,
1421
+ strike: config.strike,
1422
+ textLineStyle: config.textLineStyle,
1423
+ textLineColor: config.textLineColor,
1355
1424
  });
1356
1425
  }
1357
1426
  else {
@@ -1362,12 +1431,24 @@ class TextRenderer {
1362
1431
  backColor: this._settings.backColor,
1363
1432
  italic: this._settings.italic,
1364
1433
  bold: this._settings.bold,
1434
+ underline: this._settings.underline,
1435
+ overline: this._settings.overline,
1436
+ strike: this._settings.strike,
1437
+ textLineStyle: this._settings.textLineStyle,
1438
+ textLineColor: this._settings.textLineColor,
1365
1439
  });
1366
1440
  }
1441
+ // Per-character rotation (r_rotate): rotate each glyph around its own
1442
+ // anchor point (the character's baseline position), not the SVG origin.
1443
+ const rotate = config?.rotate;
1444
+ if (rotate) {
1445
+ textEl.setAttribute("transform", `rotate(${rotate}, ${position.x}, ${position.y})`);
1446
+ }
1367
1447
  rootSvg.appendChild(textEl);
1368
- // Cache bounds
1448
+ // Cache bounds. getBBox() ignores the element's own transform, so for
1449
+ // rotated characters the cached rect is recomputed around the anchor.
1369
1450
  const bbox = getSafeBBox(textEl);
1370
- this._boundsCache.set(`n_${node.id}`, bbox);
1451
+ this._boundsCache.set(`n_${node.id}`, rotate ? rotateRectAroundPoint(bbox, rotate, position.x, position.y) : bbox);
1371
1452
  // Animate if function provided
1372
1453
  if (animationFn) {
1373
1454
  // Set initial opacity to 0 for animation via CSS style (GSAP animates CSS)
@@ -1387,13 +1468,17 @@ class TextRenderer {
1387
1468
  * @param panZoomInstance - The svg-pan-zoom instance (for prolog)
1388
1469
  * @param viewportWidth - Width of the visible viewport (for prolog)
1389
1470
  * @param viewportHeight - Height of the visible viewport (for prolog)
1471
+ * @param instant - When true (compressed time mode), skip the per-character
1472
+ * entrance animation and any prolog panning animation, applying final
1473
+ * state immediately instead.
1390
1474
  * @returns Promise that resolves when rendering completes
1391
1475
  */
1392
- async renderAdditionalText(nodes, rootSvg, config, versionTag, referenceNodes, allBaseNodes, panZoomInstance, viewportWidth, viewportHeight) {
1476
+ async renderAdditionalText(nodes, rootSvg, config, versionTag, referenceNodes, allBaseNodes, panZoomInstance, viewportWidth, viewportHeight, instant) {
1393
1477
  this._logger.time(`renderAdditionalText-${versionTag}`);
1394
1478
  this._logger.info(`Rendering ${nodes.length} additional text characters for ${versionTag}`);
1395
1479
  if (nodes.length === 0) {
1396
1480
  this._logger.warn("No nodes to render");
1481
+ this._logger.timeEnd(`renderAdditionalText-${versionTag}`);
1397
1482
  return;
1398
1483
  }
1399
1484
  // Apply r_t-value override if specified
@@ -1410,8 +1495,8 @@ class TextRenderer {
1410
1495
  }
1411
1496
  // If r_t-value has fewer characters, we need to only render those nodes
1412
1497
  if (overrideChars.length < nodes.length) {
1413
- nodes = nodes.slice(0, overrideChars.length);
1414
1498
  this._logger.debug("TextRenderer", `r_t-value has fewer characters (${overrideChars.length}) than nodes (${nodes.length}), only rendering first ${overrideChars.length} characters`);
1499
+ nodes = nodes.slice(0, overrideChars.length);
1415
1500
  }
1416
1501
  }
1417
1502
  // 1. Calculate RBR (or use displaced span if specified)
@@ -1425,6 +1510,7 @@ class TextRenderer {
1425
1510
  }
1426
1511
  if (rbrs.length === 0) {
1427
1512
  this._logger.warn("No RBRs calculated for additional text, skipping");
1513
+ this._logger.timeEnd(`renderAdditionalText-${versionTag}`);
1428
1514
  return;
1429
1515
  }
1430
1516
  // Use first RBR for positioning (additional text doesn't repeat per RBR)
@@ -1465,7 +1551,17 @@ class TextRenderer {
1465
1551
  backColor: config.backColor,
1466
1552
  italic: config.italic,
1467
1553
  bold: config.bold,
1554
+ underline: config.underline,
1555
+ overline: config.overline,
1556
+ strike: config.strike,
1557
+ textLineStyle: config.textLineStyle,
1558
+ textLineColor: config.textLineColor,
1468
1559
  });
1560
+ // Per-character rotation (r_rotate) is a size transform: apply it
1561
+ // before measuring so the EBR accounts for the rotated glyphs.
1562
+ if (config.rotate) {
1563
+ textEl.setAttribute("transform", `rotate(${config.rotate}, ${pos.x}, ${pos.y})`);
1564
+ }
1469
1565
  tempGroup.appendChild(textEl);
1470
1566
  measuredElements.push({ el: textEl, node, initX: pos.x, initY: pos.y });
1471
1567
  }
@@ -1511,22 +1607,34 @@ class TextRenderer {
1511
1607
  const isVisible = this._animationEngine.isElementVisible(textBounds, panZoomInstance, viewportWidth, viewportHeight);
1512
1608
  if (!isVisible) {
1513
1609
  this._logger.debug("TextRenderer", `Additional text for ${versionTag} would be outside viewport, executing prolog`);
1514
- await this._animationEngine.animateProlog(panZoomInstance, textBounds, viewportWidth, viewportHeight, this._settings.prologDuration);
1610
+ await this._animationEngine.animateProlog(panZoomInstance, textBounds, viewportWidth, viewportHeight, instant ? 0 : this._settings.prologDuration);
1515
1611
  }
1516
1612
  }
1517
- // 8. Get animation function if specified
1518
- const animationFn = this._settings.charAnimationId
1613
+ // 8. Get animation function if specified. In instant mode (compressed
1614
+ // time), skip resolving it entirely so characters appear immediately.
1615
+ const animationFn = !instant && this._settings.charAnimationId
1519
1616
  ? this._animationEngine
1520
1617
  .getFactory()
1521
1618
  .resolveAnimation(`#${this._settings.charAnimationId}`, this._settings.animations, "char")
1522
1619
  : undefined;
1523
1620
  // 9. Move each element to its final position and add to the SVG
1524
1621
  for (const { el, node, initX, initY } of measuredElements) {
1525
- el.setAttribute("x", String(initX + dx));
1526
- el.setAttribute("y", String(initY + dy));
1622
+ const finalX = initX + dx;
1623
+ const finalY = initY + dy;
1624
+ el.setAttribute("x", String(finalX));
1625
+ el.setAttribute("y", String(finalY));
1626
+ // Re-anchor the per-character rotation to the moved position, or the
1627
+ // glyph would rotate around the pre-translation measuring anchor.
1628
+ if (config.rotate) {
1629
+ el.setAttribute("transform", `rotate(${config.rotate}, ${finalX}, ${finalY})`);
1630
+ }
1527
1631
  rootSvg.appendChild(el);
1632
+ // getBBox() ignores the element's own transform, so for rotated
1633
+ // characters the cached rect is recomputed around the anchor.
1528
1634
  const bbox = getSafeBBox(el);
1529
- this._boundsCache.set(`n_${node.id}`, bbox);
1635
+ this._boundsCache.set(`n_${node.id}`, config.rotate
1636
+ ? rotateRectAroundPoint(bbox, config.rotate, finalX, finalY)
1637
+ : bbox);
1530
1638
  if (animationFn) {
1531
1639
  el.style.opacity = "0";
1532
1640
  await this._animationEngine.animate(el, animationFn, rootSvg);
@@ -1543,23 +1651,34 @@ class TextRenderer {
1543
1651
  calculateAdditionalTextPositions(nodes, baseX, baseY, config) {
1544
1652
  const positions = [];
1545
1653
  let currentX = baseX;
1546
- const currentY = baseY;
1654
+ let currentY = baseY;
1655
+ let lineNumber = 0;
1547
1656
  const fontSize = config?.fontSize ?? this._settings.fontSize;
1548
1657
  const fontFamily = config?.fontFamily ?? this._settings.fontFamily;
1549
1658
  const bold = config?.bold ?? this._settings.bold;
1550
1659
  const italic = config?.italic ?? this._settings.italic;
1660
+ // Space width follows the same rule as base text: a fraction
1661
+ // (spaceWidthFraction) of the reference character's width in the
1662
+ // effective font, not of the font size.
1663
+ const spaceWidth = getAverageCharWidth(fontFamily, fontSize, bold, italic, this._measurementRoot) *
1664
+ this._settings.spaceWidthFraction;
1551
1665
  for (let i = 0; i < nodes.length; i++) {
1552
1666
  const node = nodes[i];
1553
1667
  if (isLineBreak(node)) {
1554
- positions.push({ x: currentX, y: currentY, nodeId: node.id, lineNumber: 0 });
1668
+ // Line break: start a new line, mirroring base text layout. Uses the
1669
+ // effective font size so smaller/larger added text wraps accordingly.
1670
+ currentX = baseX;
1671
+ currentY +=
1672
+ fontSize * this._settings.lineHeight + this._settings.lineSpacing;
1673
+ lineNumber++;
1674
+ positions.push({ x: currentX, y: currentY, nodeId: node.id, lineNumber });
1555
1675
  }
1556
1676
  else if (isSpace(node)) {
1557
- const spaceWidth = fontSize * 0.33;
1558
- positions.push({ x: currentX, y: currentY, nodeId: node.id, lineNumber: 0 });
1677
+ positions.push({ x: currentX, y: currentY, nodeId: node.id, lineNumber });
1559
1678
  currentX += spaceWidth + this._settings.charSpacing;
1560
1679
  }
1561
1680
  else {
1562
- positions.push({ x: currentX, y: currentY, nodeId: node.id, lineNumber: 0 });
1681
+ positions.push({ x: currentX, y: currentY, nodeId: node.id, lineNumber });
1563
1682
  const charWidth = getTextWidth(node.data, fontFamily, fontSize, bold, italic, this._measurementRoot);
1564
1683
  currentX += charWidth + this._settings.charSpacing;
1565
1684
  }
@@ -1625,8 +1744,15 @@ class TextRenderer {
1625
1744
  if (nodes.length === 0)
1626
1745
  return [];
1627
1746
  const rbrs = [];
1628
- const nodeElementIds = nodes.map((n) => `n_${n.id}`);
1629
1747
  // Group nodes by line by checking their Y coordinates.
1748
+ // IMPORTANT: spaces and line breaks are skipped, exactly as in
1749
+ // HintRenderer.calculateRBRs. They have no SVG element, so the baseline
1750
+ // read below would fall back to their cached synthetic bounds top
1751
+ // (baseline - fontSize), which differs from neighboring characters'
1752
+ // baseline by ~fontSize — far beyond the 5px tolerance — falsely
1753
+ // splitting a single line into one group per word. Interior spaces
1754
+ // don't affect the union bounds anyway, since the flanking characters
1755
+ // cover their x-range.
1630
1756
  // IMPORTANT: this compares the character's laid-out baseline Y (the `y`
1631
1757
  // attribute TextLayout assigned when positioning it), NOT its cached
1632
1758
  // getBBox().y. The tight ink bbox top varies per glyph shape (ascenders,
@@ -1636,7 +1762,11 @@ class TextRenderer {
1636
1762
  const lineGroups = [];
1637
1763
  let currentLineGroup = [];
1638
1764
  let lastBaselineY = null;
1639
- for (const elementId of nodeElementIds) {
1765
+ for (const node of nodes) {
1766
+ if (isSpace(node) || isLineBreak(node)) {
1767
+ continue;
1768
+ }
1769
+ const elementId = `n_${node.id}`;
1640
1770
  const bounds = this._boundsCache.get(elementId);
1641
1771
  if (!bounds) {
1642
1772
  this._logger.warn(`Bounds not found for node element ${elementId}`);
@@ -1659,6 +1789,17 @@ class TextRenderer {
1659
1789
  if (currentLineGroup.length > 0) {
1660
1790
  lineGroups.push(currentLineGroup);
1661
1791
  }
1792
+ // Fallback: if every reference node is invisible (e.g. the operation's
1793
+ // reference span is a lone space), use the cached synthetic bounds of
1794
+ // all nodes as a single group so the added text still gets an RBR.
1795
+ if (lineGroups.length === 0) {
1796
+ const cachedIds = nodes
1797
+ .map((n) => `n_${n.id}`)
1798
+ .filter((id) => this._boundsCache.get(id) !== undefined);
1799
+ if (cachedIds.length > 0) {
1800
+ lineGroups.push(cachedIds);
1801
+ }
1802
+ }
1662
1803
  // Calculate RBR for each line group
1663
1804
  for (const group of lineGroups) {
1664
1805
  const rbr = this._boundsCache.getUnionBounds(group);
@@ -1704,9 +1845,12 @@ class HintRenderer {
1704
1845
  * @param panZoomInstance - The svg-pan-zoom instance (for prolog)
1705
1846
  * @param viewportWidth - Width of the visible viewport (for prolog)
1706
1847
  * @param viewportHeight - Height of the visible viewport (for prolog)
1848
+ * @param instant - When true (compressed time mode), skip the entrance
1849
+ * animation and any prolog panning animation, applying final state
1850
+ * immediately instead.
1707
1851
  * @returns Promise that resolves when rendering completes
1708
1852
  */
1709
- async renderHint(hintId, hint, referenceNodes, rootSvg, variables, operationId, hintOrdinal, allBaseNodes, versionTag, hintOverrides, panZoomInstance, viewportWidth, viewportHeight) {
1853
+ async renderHint(hintId, hint, referenceNodes, rootSvg, variables, operationId, hintOrdinal, allBaseNodes, versionTag, hintOverrides, panZoomInstance, viewportWidth, viewportHeight, instant) {
1710
1854
  this._logger.time(`renderHint-${hintId}`);
1711
1855
  this._logger.info(`Rendering hint: ${hintId} for operation ${operationId}`);
1712
1856
  try {
@@ -1800,19 +1944,21 @@ class HintRenderer {
1800
1944
  const isVisible = this._animationEngine.isElementVisible(finalBbox, panZoomInstance, viewportWidth, viewportHeight);
1801
1945
  if (!isVisible) {
1802
1946
  this._logger.debug("HintRenderer", `Element ${currentHintGroup.getAttribute("id")} would be outside viewport, executing prolog`);
1803
- await this._animationEngine.animateProlog(panZoomInstance, finalBbox, viewportWidth, viewportHeight, this._settings.prologDuration);
1947
+ await this._animationEngine.animateProlog(panZoomInstance, finalBbox, viewportWidth, viewportHeight, instant ? 0 : this._settings.prologDuration);
1804
1948
  }
1805
1949
  }
1806
- // 12. Make visible (set initial opacity to 0 if animation present)
1950
+ // 12. Make visible (set initial opacity to 0 if animation present).
1951
+ // In instant mode (compressed time), skip the entrance animation
1952
+ // entirely so the hint appears in its final state right away.
1807
1953
  currentHintGroup.removeAttribute("visibility");
1808
- if (effectiveHint.animation) {
1954
+ if (effectiveHint.animation && !instant) {
1809
1955
  currentHintGroup.style.opacity = "0";
1810
1956
  }
1811
1957
  // 13. Cache bounds
1812
1958
  const bbox = getSafeBBox(currentHintGroup);
1813
1959
  this._boundsCache.set(currentHintGroup.getAttribute("id"), bbox);
1814
1960
  // 14. Animate entrance
1815
- if (effectiveHint.animation) {
1961
+ if (effectiveHint.animation && !instant) {
1816
1962
  const animation = this._animationEngine
1817
1963
  .getFactory()
1818
1964
  .resolveAnimation(effectiveHint.animation, this._settings.animations, `hint-${hintId}`);
@@ -2241,6 +2387,8 @@ class FeatureResolver {
2241
2387
  underline: baseSettings.underline,
2242
2388
  overline: baseSettings.overline,
2243
2389
  strike: baseSettings.strike,
2390
+ textLineStyle: baseSettings.textLineStyle,
2391
+ textLineColor: baseSettings.textLineColor,
2244
2392
  textOffsetX: 0,
2245
2393
  textOffsetY: 0,
2246
2394
  };
@@ -2423,9 +2571,13 @@ class FeatureResolver {
2423
2571
  return;
2424
2572
  }
2425
2573
  }
2426
- // If no hints defined yet in config, we'll still store the override.
2427
- // It will be applied when hints are processed.
2428
- const hints = targetHints ?? (config.hints ?? ["*"]);
2574
+ // Untargeted overrides (no "@" prefix) are always stored under the "*"
2575
+ // wildcard key. Storing them under the hint keys from config.hints would
2576
+ // (a) depend on whether r_hints was parsed before this feature, and
2577
+ // (b) give them key-level priority, letting them overwrite an explicit
2578
+ // "@key:" override for the same property — the documented precedence is
2579
+ // ordinal > key > wildcard (see applyHintOverrides in HintRenderer).
2580
+ const hints = targetHints ?? ["*"];
2429
2581
  for (const hintKey of hints) {
2430
2582
  // Get or create override object for this hint key / ordinal
2431
2583
  let override = config.hintOverrides.get(hintKey);
@@ -4892,6 +5044,7 @@ class Toolbar {
4892
5044
  this._disabledButtonIds = [];
4893
5045
  this._slideshowReverse = false;
4894
5046
  this._autoForwardEnabled = false;
5047
+ this._compressedTimeModeEnabled = false;
4895
5048
  this.container = container;
4896
5049
  this.render();
4897
5050
  }
@@ -4937,6 +5090,9 @@ class Toolbar {
4937
5090
  <button id="auto-forward-btn" class="toolbar-btn" title="Auto-Forward on Group: Continue through grouped operations automatically">
4938
5091
  <i data-feather="fast-forward"></i>
4939
5092
  </button>
5093
+ <button id="compressed-time-btn" class="toolbar-btn" title="Compressed Time: jump between versions instantly, without animations or delays">
5094
+ <i data-feather="zap"></i>
5095
+ </button>
4940
5096
  </div>
4941
5097
 
4942
5098
  <!-- Display Group -->
@@ -4990,6 +5146,7 @@ class Toolbar {
4990
5146
  this.slideshowStopBtn = this.container.querySelector("#slideshow-stop-btn");
4991
5147
  this.slideshowReverseBtn = this.container.querySelector("#slideshow-reverse-btn");
4992
5148
  this.autoForwardBtn = this.container.querySelector("#auto-forward-btn");
5149
+ this.compressedTimeBtn = this.container.querySelector("#compressed-time-btn");
4993
5150
  this.zoomInBtn = this.container.querySelector("#zoom-in-btn");
4994
5151
  this.zoomOutBtn = this.container.querySelector("#zoom-out-btn");
4995
5152
  this.fitBtn = this.container.querySelector("#fit-btn");
@@ -5043,6 +5200,11 @@ class Toolbar {
5043
5200
  this.updateAutoForwardButton();
5044
5201
  this.dispatchAutoForwardEvent(this._autoForwardEnabled);
5045
5202
  });
5203
+ this.compressedTimeBtn.addEventListener("click", () => {
5204
+ this._compressedTimeModeEnabled = !this._compressedTimeModeEnabled;
5205
+ this.updateCompressedTimeButton();
5206
+ this.dispatchCompressedTimeEvent(this._compressedTimeModeEnabled);
5207
+ });
5046
5208
  // Display buttons
5047
5209
  this.zoomInBtn.addEventListener("click", () => this.dispatchDisplayEvent("zoom-in"));
5048
5210
  this.zoomOutBtn.addEventListener("click", () => this.dispatchDisplayEvent("zoom-out"));
@@ -5263,6 +5425,17 @@ class Toolbar {
5263
5425
  this.autoForwardBtn.classList.remove("active");
5264
5426
  }
5265
5427
  }
5428
+ /**
5429
+ * Update compressed time mode button visual state.
5430
+ */
5431
+ updateCompressedTimeButton() {
5432
+ if (this._compressedTimeModeEnabled) {
5433
+ this.compressedTimeBtn.classList.add("active");
5434
+ }
5435
+ else {
5436
+ this.compressedTimeBtn.classList.remove("active");
5437
+ }
5438
+ }
5266
5439
  /**
5267
5440
  * Apply disabled state to buttons from settings.
5268
5441
  */
@@ -5277,6 +5450,7 @@ class Toolbar {
5277
5450
  "slideshow-start-btn": this.slideshowStartBtn,
5278
5451
  "slideshow-stop-btn": this.slideshowStopBtn,
5279
5452
  "slideshow-reverse-btn": this.slideshowReverseBtn,
5453
+ "compressed-time-btn": this.compressedTimeBtn,
5280
5454
  "zoom-in-btn": this.zoomInBtn,
5281
5455
  "zoom-out-btn": this.zoomOutBtn,
5282
5456
  "fit-btn": this.fitBtn,
@@ -5316,6 +5490,15 @@ class Toolbar {
5316
5490
  bubbles: true,
5317
5491
  }));
5318
5492
  }
5493
+ /**
5494
+ * Dispatch compressed time event for parent component to handle.
5495
+ */
5496
+ dispatchCompressedTimeEvent(enabled) {
5497
+ this.container.dispatchEvent(new CustomEvent("toolbar-compressed-time", {
5498
+ detail: { enabled },
5499
+ bubbles: true,
5500
+ }));
5501
+ }
5319
5502
  /**
5320
5503
  * Dispatch display event for parent component to handle.
5321
5504
  */
@@ -5399,6 +5582,13 @@ class Toolbar {
5399
5582
  this._autoForwardEnabled = enabled;
5400
5583
  this.updateAutoForwardButton();
5401
5584
  }
5585
+ /**
5586
+ * Set compressed time mode enabled state and update button appearance.
5587
+ */
5588
+ setCompressedTimeModeEnabled(enabled) {
5589
+ this._compressedTimeModeEnabled = enabled;
5590
+ this.updateCompressedTimeButton();
5591
+ }
5402
5592
  }
5403
5593
 
5404
5594
  /**
@@ -25588,375 +25778,375 @@ class GoldenLayout extends VirtualLayout {
25588
25778
  * Source: golden-layout@2.6.0 dist/css/goldenlayout-base.css + adapted light theme.
25589
25779
  * Update this file when upgrading golden-layout.
25590
25780
  */
25591
- const GOLDEN_LAYOUT_CSS = `
25592
- /* ── Golden Layout base ──────────────────────────────────────────────── */
25593
- .lm_root {
25594
- position: relative;
25595
- }
25596
- .lm_row > .lm_item {
25597
- float: left;
25598
- }
25599
- .lm_content {
25600
- overflow: hidden;
25601
- position: relative;
25602
- }
25603
- .lm_dragging,
25604
- .lm_dragging * {
25605
- cursor: move !important;
25606
- user-select: none;
25607
- }
25608
- .lm_maximised {
25609
- position: absolute;
25610
- top: 0;
25611
- left: 0;
25612
- z-index: 40;
25613
- }
25614
- .lm_maximise_placeholder {
25615
- display: none;
25616
- }
25617
- .lm_splitter {
25618
- position: relative;
25619
- z-index: 2;
25620
- touch-action: none;
25621
- }
25622
- .lm_splitter.lm_vertical .lm_drag_handle {
25623
- width: 100%;
25624
- position: absolute;
25625
- cursor: ns-resize;
25626
- touch-action: none;
25627
- user-select: none;
25628
- }
25629
- .lm_splitter.lm_horizontal {
25630
- float: left;
25631
- height: 100%;
25632
- }
25633
- .lm_splitter.lm_horizontal .lm_drag_handle {
25634
- height: 100%;
25635
- position: absolute;
25636
- cursor: ew-resize;
25637
- touch-action: none;
25638
- user-select: none;
25639
- }
25640
- .lm_header {
25641
- overflow: visible;
25642
- position: relative;
25643
- z-index: 1;
25644
- user-select: none;
25645
- }
25646
- .lm_header [class^=lm_] {
25647
- box-sizing: content-box !important;
25648
- }
25649
- .lm_header .lm_controls {
25650
- position: absolute;
25651
- right: 3px;
25652
- display: flex;
25653
- }
25654
- .lm_header .lm_controls > * {
25655
- cursor: pointer;
25656
- float: left;
25657
- width: 18px;
25658
- height: 18px;
25659
- text-align: center;
25660
- }
25661
- .lm_header .lm_tabs {
25662
- position: absolute;
25663
- display: flex;
25664
- }
25665
- .lm_header .lm_tab {
25666
- cursor: pointer;
25667
- float: left;
25668
- height: 14px;
25669
- margin-top: 1px;
25670
- padding: 0px 10px 5px;
25671
- padding-right: 25px;
25672
- position: relative;
25673
- touch-action: none;
25674
- }
25675
- .lm_header .lm_tab i {
25676
- width: 2px;
25677
- height: 19px;
25678
- position: absolute;
25679
- }
25680
- .lm_header .lm_tab i.lm_left {
25681
- top: 0;
25682
- left: -2px;
25683
- }
25684
- .lm_header .lm_tab i.lm_right {
25685
- top: 0;
25686
- right: -2px;
25687
- }
25688
- .lm_header .lm_tab .lm_title {
25689
- display: inline-block;
25690
- overflow: hidden;
25691
- text-overflow: ellipsis;
25692
- }
25693
- .lm_header .lm_tab .lm_close_tab {
25694
- width: 14px;
25695
- height: 14px;
25696
- position: absolute;
25697
- top: 0;
25698
- right: 0;
25699
- text-align: center;
25700
- }
25701
- .lm_stack {
25702
- position: relative;
25703
- }
25704
- .lm_stack > .lm_items {
25705
- overflow: hidden;
25706
- }
25707
- .lm_stack.lm_left > .lm_items {
25708
- position: absolute;
25709
- left: 20px;
25710
- top: 0;
25711
- }
25712
- .lm_stack.lm_right > .lm_items {
25713
- position: absolute;
25714
- right: 20px;
25715
- top: 0;
25716
- }
25717
- .lm_stack.lm_right > .lm_header {
25718
- position: absolute;
25719
- right: 0;
25720
- top: 0;
25721
- }
25722
- .lm_stack.lm_bottom > .lm_items {
25723
- position: absolute;
25724
- bottom: 20px;
25725
- }
25726
- .lm_stack.lm_bottom > .lm_header {
25727
- position: absolute;
25728
- bottom: 0;
25729
- }
25730
- .lm_left.lm_stack .lm_header,
25731
- .lm_right.lm_stack .lm_header {
25732
- height: 100%;
25733
- }
25734
- .lm_left.lm_dragProxy .lm_header,
25735
- .lm_right.lm_dragProxy .lm_header,
25736
- .lm_left.lm_dragProxy .lm_items,
25737
- .lm_right.lm_dragProxy .lm_items {
25738
- float: left;
25739
- }
25740
- .lm_left.lm_dragProxy .lm_header,
25741
- .lm_right.lm_dragProxy .lm_header,
25742
- .lm_left.lm_stack .lm_header,
25743
- .lm_right.lm_stack .lm_header {
25744
- width: 20px;
25745
- vertical-align: top;
25746
- }
25747
- .lm_left.lm_dragProxy .lm_header .lm_tabs,
25748
- .lm_right.lm_dragProxy .lm_header .lm_tabs,
25749
- .lm_left.lm_stack .lm_header .lm_tabs,
25750
- .lm_right.lm_stack .lm_header .lm_tabs {
25751
- transform-origin: left top;
25752
- top: 0;
25753
- width: 1000px;
25754
- }
25755
- .lm_left.lm_dragProxy .lm_header .lm_controls,
25756
- .lm_right.lm_dragProxy .lm_header .lm_controls,
25757
- .lm_left.lm_stack .lm_header .lm_controls,
25758
- .lm_right.lm_stack .lm_header .lm_controls {
25759
- bottom: 0;
25760
- flex-flow: column;
25761
- }
25762
- .lm_dragProxy.lm_left .lm_header .lm_tabs,
25763
- .lm_stack.lm_left .lm_header .lm_tabs {
25764
- transform: rotate(-90deg) scaleX(-1);
25765
- left: 0;
25766
- }
25767
- .lm_dragProxy.lm_left .lm_header .lm_tabs .lm_tab,
25768
- .lm_stack.lm_left .lm_header .lm_tabs .lm_tab {
25769
- transform: scaleX(-1);
25770
- margin-top: 1px;
25771
- }
25772
- .lm_dragProxy.lm_right .lm_content {
25773
- float: left;
25774
- }
25775
- .lm_dragProxy.lm_right .lm_header .lm_tabs,
25776
- .lm_stack.lm_right .lm_header .lm_tabs {
25777
- transform: rotate(90deg) scaleX(1);
25778
- left: 100%;
25779
- margin-left: 0;
25780
- }
25781
- .lm_dragProxy.lm_right .lm_header .lm_controls,
25782
- .lm_stack.lm_right .lm_header .lm_controls {
25783
- left: 3px;
25784
- }
25785
- .lm_dragProxy.lm_bottom .lm_header,
25786
- .lm_stack.lm_bottom .lm_header {
25787
- width: 100%;
25788
- }
25789
- .lm_dragProxy.lm_bottom .lm_header .lm_tab,
25790
- .lm_stack.lm_bottom .lm_header .lm_tab {
25791
- margin-top: 0;
25792
- border-top: none;
25793
- }
25794
- .lm_dragProxy.lm_bottom .lm_header .lm_controls,
25795
- .lm_stack.lm_bottom .lm_header .lm_controls {
25796
- top: 3px;
25797
- }
25798
- .lm_drop_tab_placeholder {
25799
- float: left;
25800
- width: 100px;
25801
- visibility: hidden;
25802
- }
25803
- .lm_header .lm_controls .lm_tabdropdown:before {
25804
- content: '';
25805
- width: 0;
25806
- height: 0;
25807
- vertical-align: middle;
25808
- display: inline-block;
25809
- border-top: 5px dashed;
25810
- border-right: 5px solid transparent;
25811
- border-left: 5px solid transparent;
25812
- color: white;
25813
- }
25814
- .lm_header .lm_tabdropdown_list {
25815
- position: absolute;
25816
- top: 20px;
25817
- right: 0;
25818
- z-index: 5;
25819
- overflow: hidden;
25820
- }
25821
- .lm_header .lm_tabdropdown_list .lm_tab {
25822
- clear: both;
25823
- padding-right: 10px;
25824
- margin: 0;
25825
- }
25826
- .lm_header .lm_tabdropdown_list .lm_tab .lm_title {
25827
- width: 100px;
25828
- }
25829
- .lm_header .lm_tabdropdown_list .lm_close_tab {
25830
- display: none !important;
25831
- }
25832
- .lm_dragProxy {
25833
- position: absolute;
25834
- top: 0;
25835
- left: 0;
25836
- z-index: 30;
25837
- }
25838
- .lm_dragProxy .lm_header {
25839
- background: transparent;
25840
- }
25841
- .lm_dragProxy .lm_content {
25842
- border-top: none;
25843
- overflow: hidden;
25844
- }
25845
- .lm_dropTargetIndicator {
25846
- display: none;
25847
- position: absolute;
25848
- z-index: 35;
25849
- transition: all 200ms ease;
25850
- }
25851
- .lm_dropTargetIndicator .lm_inner {
25852
- width: 100%;
25853
- height: 100%;
25854
- position: relative;
25855
- top: 0;
25856
- left: 0;
25857
- }
25858
- .lm_transition_indicator {
25859
- display: none;
25860
- width: 20px;
25861
- height: 20px;
25862
- position: absolute;
25863
- top: 0;
25864
- left: 0;
25865
- z-index: 20;
25866
- }
25867
- .lm_popin {
25868
- width: 20px;
25869
- height: 20px;
25870
- position: absolute;
25871
- bottom: 0;
25872
- right: 0;
25873
- z-index: 9999;
25874
- }
25875
- .lm_popin > * {
25876
- width: 100%;
25877
- height: 100%;
25878
- position: absolute;
25879
- top: 0;
25880
- left: 0;
25881
- }
25882
- .lm_popin > .lm_bg {
25883
- z-index: 10;
25884
- }
25885
- .lm_popin > .lm_icon {
25886
- z-index: 20;
25887
- }
25888
-
25889
- /* ── Adapted theme (using --gve- CSS variables) ──────────────────────── */
25890
- .lm_goldenlayout {
25891
- background: transparent;
25892
- }
25893
- .lm_content {
25894
- background: var(--gve-bg-color, #fafafa);
25895
- border: none;
25896
- }
25897
- .lm_dragProxy .lm_content {
25898
- box-shadow: 2px 2px 4px rgba(0,0,0,0.2);
25899
- }
25900
- .lm_dropTargetIndicator {
25901
- box-shadow: inset 0 0 30px rgba(0,0,0,0.3);
25902
- outline: 1px dashed var(--gve-border-color, #ccc);
25903
- }
25904
- .lm_dropTargetIndicator .lm_inner {
25905
- background: #000;
25906
- opacity: 0.08;
25907
- }
25908
- .lm_splitter {
25909
- background: var(--gve-border-color, #ccc);
25910
- opacity: 0.4;
25911
- transition: opacity 200ms ease;
25912
- }
25913
- .lm_splitter:hover,
25914
- .lm_splitter.lm_dragging {
25915
- background: var(--gve-border-color, #aaa);
25916
- opacity: 1;
25917
- }
25918
- .lm_header {
25919
- height: 26px;
25920
- background: var(--gve-toolbar-bg, #f5f5f5);
25921
- border-bottom: 1px solid var(--gve-border-color, #ddd);
25922
- }
25923
- .lm_header .lm_tab {
25924
- font-family: Arial, sans-serif;
25925
- font-size: 12px;
25926
- font-weight: 600;
25927
- color: var(--gve-text-secondary, #777);
25928
- background: transparent;
25929
- margin-right: 2px;
25930
- padding: 5px 10px 4px;
25931
- padding-right: 14px;
25932
- border: none;
25933
- }
25934
- .lm_header .lm_tab .lm_title {
25935
- padding-top: 1px;
25936
- }
25937
- .lm_header .lm_tab.lm_active {
25938
- background: var(--gve-bg-color, #fff);
25939
- color: var(--gve-text-color, #333);
25940
- border-bottom: 2px solid var(--gve-primary-color, #007bff);
25941
- padding-bottom: 3px;
25942
- }
25943
- .lm_header .lm_tab:hover {
25944
- color: var(--gve-text-color, #333);
25945
- }
25946
- .lm_header .lm_controls .lm_tabdropdown:before {
25947
- color: var(--gve-text-color, #333);
25948
- }
25949
- .lm_transition_indicator {
25950
- background: #000;
25951
- border: 1px dashed #555;
25952
- }
25953
- .lm_popin {
25954
- cursor: pointer;
25955
- }
25956
- .lm_popin .lm_bg {
25957
- background: #000;
25958
- opacity: 0.7;
25959
- }
25781
+ const GOLDEN_LAYOUT_CSS = `
25782
+ /* ── Golden Layout base ──────────────────────────────────────────────── */
25783
+ .lm_root {
25784
+ position: relative;
25785
+ }
25786
+ .lm_row > .lm_item {
25787
+ float: left;
25788
+ }
25789
+ .lm_content {
25790
+ overflow: hidden;
25791
+ position: relative;
25792
+ }
25793
+ .lm_dragging,
25794
+ .lm_dragging * {
25795
+ cursor: move !important;
25796
+ user-select: none;
25797
+ }
25798
+ .lm_maximised {
25799
+ position: absolute;
25800
+ top: 0;
25801
+ left: 0;
25802
+ z-index: 40;
25803
+ }
25804
+ .lm_maximise_placeholder {
25805
+ display: none;
25806
+ }
25807
+ .lm_splitter {
25808
+ position: relative;
25809
+ z-index: 2;
25810
+ touch-action: none;
25811
+ }
25812
+ .lm_splitter.lm_vertical .lm_drag_handle {
25813
+ width: 100%;
25814
+ position: absolute;
25815
+ cursor: ns-resize;
25816
+ touch-action: none;
25817
+ user-select: none;
25818
+ }
25819
+ .lm_splitter.lm_horizontal {
25820
+ float: left;
25821
+ height: 100%;
25822
+ }
25823
+ .lm_splitter.lm_horizontal .lm_drag_handle {
25824
+ height: 100%;
25825
+ position: absolute;
25826
+ cursor: ew-resize;
25827
+ touch-action: none;
25828
+ user-select: none;
25829
+ }
25830
+ .lm_header {
25831
+ overflow: visible;
25832
+ position: relative;
25833
+ z-index: 1;
25834
+ user-select: none;
25835
+ }
25836
+ .lm_header [class^=lm_] {
25837
+ box-sizing: content-box !important;
25838
+ }
25839
+ .lm_header .lm_controls {
25840
+ position: absolute;
25841
+ right: 3px;
25842
+ display: flex;
25843
+ }
25844
+ .lm_header .lm_controls > * {
25845
+ cursor: pointer;
25846
+ float: left;
25847
+ width: 18px;
25848
+ height: 18px;
25849
+ text-align: center;
25850
+ }
25851
+ .lm_header .lm_tabs {
25852
+ position: absolute;
25853
+ display: flex;
25854
+ }
25855
+ .lm_header .lm_tab {
25856
+ cursor: pointer;
25857
+ float: left;
25858
+ height: 14px;
25859
+ margin-top: 1px;
25860
+ padding: 0px 10px 5px;
25861
+ padding-right: 25px;
25862
+ position: relative;
25863
+ touch-action: none;
25864
+ }
25865
+ .lm_header .lm_tab i {
25866
+ width: 2px;
25867
+ height: 19px;
25868
+ position: absolute;
25869
+ }
25870
+ .lm_header .lm_tab i.lm_left {
25871
+ top: 0;
25872
+ left: -2px;
25873
+ }
25874
+ .lm_header .lm_tab i.lm_right {
25875
+ top: 0;
25876
+ right: -2px;
25877
+ }
25878
+ .lm_header .lm_tab .lm_title {
25879
+ display: inline-block;
25880
+ overflow: hidden;
25881
+ text-overflow: ellipsis;
25882
+ }
25883
+ .lm_header .lm_tab .lm_close_tab {
25884
+ width: 14px;
25885
+ height: 14px;
25886
+ position: absolute;
25887
+ top: 0;
25888
+ right: 0;
25889
+ text-align: center;
25890
+ }
25891
+ .lm_stack {
25892
+ position: relative;
25893
+ }
25894
+ .lm_stack > .lm_items {
25895
+ overflow: hidden;
25896
+ }
25897
+ .lm_stack.lm_left > .lm_items {
25898
+ position: absolute;
25899
+ left: 20px;
25900
+ top: 0;
25901
+ }
25902
+ .lm_stack.lm_right > .lm_items {
25903
+ position: absolute;
25904
+ right: 20px;
25905
+ top: 0;
25906
+ }
25907
+ .lm_stack.lm_right > .lm_header {
25908
+ position: absolute;
25909
+ right: 0;
25910
+ top: 0;
25911
+ }
25912
+ .lm_stack.lm_bottom > .lm_items {
25913
+ position: absolute;
25914
+ bottom: 20px;
25915
+ }
25916
+ .lm_stack.lm_bottom > .lm_header {
25917
+ position: absolute;
25918
+ bottom: 0;
25919
+ }
25920
+ .lm_left.lm_stack .lm_header,
25921
+ .lm_right.lm_stack .lm_header {
25922
+ height: 100%;
25923
+ }
25924
+ .lm_left.lm_dragProxy .lm_header,
25925
+ .lm_right.lm_dragProxy .lm_header,
25926
+ .lm_left.lm_dragProxy .lm_items,
25927
+ .lm_right.lm_dragProxy .lm_items {
25928
+ float: left;
25929
+ }
25930
+ .lm_left.lm_dragProxy .lm_header,
25931
+ .lm_right.lm_dragProxy .lm_header,
25932
+ .lm_left.lm_stack .lm_header,
25933
+ .lm_right.lm_stack .lm_header {
25934
+ width: 20px;
25935
+ vertical-align: top;
25936
+ }
25937
+ .lm_left.lm_dragProxy .lm_header .lm_tabs,
25938
+ .lm_right.lm_dragProxy .lm_header .lm_tabs,
25939
+ .lm_left.lm_stack .lm_header .lm_tabs,
25940
+ .lm_right.lm_stack .lm_header .lm_tabs {
25941
+ transform-origin: left top;
25942
+ top: 0;
25943
+ width: 1000px;
25944
+ }
25945
+ .lm_left.lm_dragProxy .lm_header .lm_controls,
25946
+ .lm_right.lm_dragProxy .lm_header .lm_controls,
25947
+ .lm_left.lm_stack .lm_header .lm_controls,
25948
+ .lm_right.lm_stack .lm_header .lm_controls {
25949
+ bottom: 0;
25950
+ flex-flow: column;
25951
+ }
25952
+ .lm_dragProxy.lm_left .lm_header .lm_tabs,
25953
+ .lm_stack.lm_left .lm_header .lm_tabs {
25954
+ transform: rotate(-90deg) scaleX(-1);
25955
+ left: 0;
25956
+ }
25957
+ .lm_dragProxy.lm_left .lm_header .lm_tabs .lm_tab,
25958
+ .lm_stack.lm_left .lm_header .lm_tabs .lm_tab {
25959
+ transform: scaleX(-1);
25960
+ margin-top: 1px;
25961
+ }
25962
+ .lm_dragProxy.lm_right .lm_content {
25963
+ float: left;
25964
+ }
25965
+ .lm_dragProxy.lm_right .lm_header .lm_tabs,
25966
+ .lm_stack.lm_right .lm_header .lm_tabs {
25967
+ transform: rotate(90deg) scaleX(1);
25968
+ left: 100%;
25969
+ margin-left: 0;
25970
+ }
25971
+ .lm_dragProxy.lm_right .lm_header .lm_controls,
25972
+ .lm_stack.lm_right .lm_header .lm_controls {
25973
+ left: 3px;
25974
+ }
25975
+ .lm_dragProxy.lm_bottom .lm_header,
25976
+ .lm_stack.lm_bottom .lm_header {
25977
+ width: 100%;
25978
+ }
25979
+ .lm_dragProxy.lm_bottom .lm_header .lm_tab,
25980
+ .lm_stack.lm_bottom .lm_header .lm_tab {
25981
+ margin-top: 0;
25982
+ border-top: none;
25983
+ }
25984
+ .lm_dragProxy.lm_bottom .lm_header .lm_controls,
25985
+ .lm_stack.lm_bottom .lm_header .lm_controls {
25986
+ top: 3px;
25987
+ }
25988
+ .lm_drop_tab_placeholder {
25989
+ float: left;
25990
+ width: 100px;
25991
+ visibility: hidden;
25992
+ }
25993
+ .lm_header .lm_controls .lm_tabdropdown:before {
25994
+ content: '';
25995
+ width: 0;
25996
+ height: 0;
25997
+ vertical-align: middle;
25998
+ display: inline-block;
25999
+ border-top: 5px dashed;
26000
+ border-right: 5px solid transparent;
26001
+ border-left: 5px solid transparent;
26002
+ color: white;
26003
+ }
26004
+ .lm_header .lm_tabdropdown_list {
26005
+ position: absolute;
26006
+ top: 20px;
26007
+ right: 0;
26008
+ z-index: 5;
26009
+ overflow: hidden;
26010
+ }
26011
+ .lm_header .lm_tabdropdown_list .lm_tab {
26012
+ clear: both;
26013
+ padding-right: 10px;
26014
+ margin: 0;
26015
+ }
26016
+ .lm_header .lm_tabdropdown_list .lm_tab .lm_title {
26017
+ width: 100px;
26018
+ }
26019
+ .lm_header .lm_tabdropdown_list .lm_close_tab {
26020
+ display: none !important;
26021
+ }
26022
+ .lm_dragProxy {
26023
+ position: absolute;
26024
+ top: 0;
26025
+ left: 0;
26026
+ z-index: 30;
26027
+ }
26028
+ .lm_dragProxy .lm_header {
26029
+ background: transparent;
26030
+ }
26031
+ .lm_dragProxy .lm_content {
26032
+ border-top: none;
26033
+ overflow: hidden;
26034
+ }
26035
+ .lm_dropTargetIndicator {
26036
+ display: none;
26037
+ position: absolute;
26038
+ z-index: 35;
26039
+ transition: all 200ms ease;
26040
+ }
26041
+ .lm_dropTargetIndicator .lm_inner {
26042
+ width: 100%;
26043
+ height: 100%;
26044
+ position: relative;
26045
+ top: 0;
26046
+ left: 0;
26047
+ }
26048
+ .lm_transition_indicator {
26049
+ display: none;
26050
+ width: 20px;
26051
+ height: 20px;
26052
+ position: absolute;
26053
+ top: 0;
26054
+ left: 0;
26055
+ z-index: 20;
26056
+ }
26057
+ .lm_popin {
26058
+ width: 20px;
26059
+ height: 20px;
26060
+ position: absolute;
26061
+ bottom: 0;
26062
+ right: 0;
26063
+ z-index: 9999;
26064
+ }
26065
+ .lm_popin > * {
26066
+ width: 100%;
26067
+ height: 100%;
26068
+ position: absolute;
26069
+ top: 0;
26070
+ left: 0;
26071
+ }
26072
+ .lm_popin > .lm_bg {
26073
+ z-index: 10;
26074
+ }
26075
+ .lm_popin > .lm_icon {
26076
+ z-index: 20;
26077
+ }
26078
+
26079
+ /* ── Adapted theme (using --gve- CSS variables) ──────────────────────── */
26080
+ .lm_goldenlayout {
26081
+ background: transparent;
26082
+ }
26083
+ .lm_content {
26084
+ background: var(--gve-bg-color, #fafafa);
26085
+ border: none;
26086
+ }
26087
+ .lm_dragProxy .lm_content {
26088
+ box-shadow: 2px 2px 4px rgba(0,0,0,0.2);
26089
+ }
26090
+ .lm_dropTargetIndicator {
26091
+ box-shadow: inset 0 0 30px rgba(0,0,0,0.3);
26092
+ outline: 1px dashed var(--gve-border-color, #ccc);
26093
+ }
26094
+ .lm_dropTargetIndicator .lm_inner {
26095
+ background: #000;
26096
+ opacity: 0.08;
26097
+ }
26098
+ .lm_splitter {
26099
+ background: var(--gve-border-color, #ccc);
26100
+ opacity: 0.4;
26101
+ transition: opacity 200ms ease;
26102
+ }
26103
+ .lm_splitter:hover,
26104
+ .lm_splitter.lm_dragging {
26105
+ background: var(--gve-border-color, #aaa);
26106
+ opacity: 1;
26107
+ }
26108
+ .lm_header {
26109
+ height: 26px;
26110
+ background: var(--gve-toolbar-bg, #f5f5f5);
26111
+ border-bottom: 1px solid var(--gve-border-color, #ddd);
26112
+ }
26113
+ .lm_header .lm_tab {
26114
+ font-family: Arial, sans-serif;
26115
+ font-size: 12px;
26116
+ font-weight: 600;
26117
+ color: var(--gve-text-secondary, #777);
26118
+ background: transparent;
26119
+ margin-right: 2px;
26120
+ padding: 5px 10px 4px;
26121
+ padding-right: 14px;
26122
+ border: none;
26123
+ }
26124
+ .lm_header .lm_tab .lm_title {
26125
+ padding-top: 1px;
26126
+ }
26127
+ .lm_header .lm_tab.lm_active {
26128
+ background: var(--gve-bg-color, #fff);
26129
+ color: var(--gve-text-color, #333);
26130
+ border-bottom: 2px solid var(--gve-primary-color, #007bff);
26131
+ padding-bottom: 3px;
26132
+ }
26133
+ .lm_header .lm_tab:hover {
26134
+ color: var(--gve-text-color, #333);
26135
+ }
26136
+ .lm_header .lm_controls .lm_tabdropdown:before {
26137
+ color: var(--gve-text-color, #333);
26138
+ }
26139
+ .lm_transition_indicator {
26140
+ background: #000;
26141
+ border: 1px dashed #555;
26142
+ }
26143
+ .lm_popin {
26144
+ cursor: pointer;
26145
+ }
26146
+ .lm_popin .lm_bg {
26147
+ background: #000;
26148
+ opacity: 0.7;
26149
+ }
25960
26150
  `;
25961
26151
 
25962
26152
  /**
@@ -25971,7 +26161,7 @@ class GveSnapshotRendition extends HTMLElement {
25971
26161
  * of the web component is loaded.
25972
26162
  */
25973
26163
  static get version() {
25974
- return "2.0.9";
26164
+ return "2.0.11";
25975
26165
  }
25976
26166
  constructor() {
25977
26167
  super();
@@ -25983,6 +26173,12 @@ class GveSnapshotRendition extends HTMLElement {
25983
26173
  this._currentVersionIndex = 0;
25984
26174
  this._autoForwardEnabled = false;
25985
26175
  this._autoForwardTimerId = null;
26176
+ // When true, version navigation skips hint/text entrance animations,
26177
+ // prolog panning animation, and the backward fade-out, applying each
26178
+ // version's final state immediately. Lets users jump to any version
26179
+ // without waiting for the (potentially many) animations/delays that
26180
+ // would otherwise play for every operation in between.
26181
+ this._compressedTimeMode = false;
25986
26182
  this._renderScheduled = false;
25987
26183
  // Guards against re-entrant navigation: a slideshow tick (or a rapid
25988
26184
  // double-click/keypress) firing while a previous goToVersionIndex() call
@@ -26148,6 +26344,7 @@ class GveSnapshotRendition extends HTMLElement {
26148
26344
  this._toolbar.setCustomButtons(this._settings.customButtons || []);
26149
26345
  this._toolbar.setDisabledButtons(this._settings.disabledButtonIds || []);
26150
26346
  this._toolbar.setAutoForwardEnabled(this._autoForwardEnabled);
26347
+ this._toolbar.setCompressedTimeModeEnabled(this._compressedTimeMode);
26151
26348
  // Listen for toolbar navigation events
26152
26349
  toolbarContainer.addEventListener("toolbar-navigate", (e) => {
26153
26350
  const customEvent = e;
@@ -26163,6 +26360,11 @@ class GveSnapshotRendition extends HTMLElement {
26163
26360
  const customEvent = e;
26164
26361
  this.setAutoForwardEnabled(customEvent.detail.enabled);
26165
26362
  });
26363
+ // Listen for toolbar compressed-time-mode events
26364
+ toolbarContainer.addEventListener("toolbar-compressed-time", (e) => {
26365
+ const customEvent = e;
26366
+ this.setCompressedTimeModeEnabled(customEvent.detail.enabled);
26367
+ });
26166
26368
  // Listen for toolbar display events
26167
26369
  toolbarContainer.addEventListener("toolbar-display", (e) => {
26168
26370
  const customEvent = e;
@@ -26297,6 +26499,20 @@ class GveSnapshotRendition extends HTMLElement {
26297
26499
  * The SVG rendition panel has no header and acts as the fixed background.
26298
26500
  */
26299
26501
  initializeGoldenLayout() {
26502
+ // Guard against stale rAF callbacks: render() can run more than once
26503
+ // before the first scheduled callback fires (e.g. a consumer sets
26504
+ // settings/data before the element is connected, then connectedCallback
26505
+ // renders again). Without these checks two GoldenLayout instances would
26506
+ // be created on the same container, duplicating panels and leaking the
26507
+ // first instance's ResizeObserver.
26508
+ if (!this.isConnected) {
26509
+ this._logger.debug("GL", "Skipping GL init - component not connected");
26510
+ return;
26511
+ }
26512
+ if (this._goldenLayout) {
26513
+ this._goldenLayout.destroy();
26514
+ this._goldenLayout = undefined;
26515
+ }
26300
26516
  const glContainer = this._shadow.getElementById("gl-container");
26301
26517
  if (!glContainer) {
26302
26518
  this._logger.error("GL", "gl-container not found in Shadow DOM");
@@ -26368,6 +26584,18 @@ class GveSnapshotRendition extends HTMLElement {
26368
26584
  showPopoutIcon: false,
26369
26585
  showMaximiseIcon: false,
26370
26586
  showCloseIcon: false,
26587
+ // Tab dragging/docking is disabled: the four panels are fixed
26588
+ // functional areas, and users only need the splitters to
26589
+ // redistribute space. This prevents dragging a panel into another
26590
+ // stack (or into limbo) and losing the intended layout. The
26591
+ // "reset layout" toolbar button remains the escape hatch.
26592
+ reorderEnabled: false,
26593
+ },
26594
+ dimensions: {
26595
+ // Prevent panels from being collapsed to zero size via the
26596
+ // splitters, which would make them hard to grab back.
26597
+ defaultMinItemWidth: "80px",
26598
+ defaultMinItemHeight: "60px",
26371
26599
  },
26372
26600
  root: {
26373
26601
  type: "column",
@@ -26569,12 +26797,13 @@ class GveSnapshotRendition extends HTMLElement {
26569
26797
  if (refNodes.length === 0) {
26570
26798
  this._logger.warn(`No reference nodes found for operation ${operation.id}`);
26571
26799
  // Still fire event even if no reference nodes
26800
+ const previousVersion = this._currentVersion;
26572
26801
  this._currentVersion = versionTag;
26573
26802
  // Extract version index from tag (e.g., "v2" → 2)
26574
26803
  const versionMatch = versionTag.match(/v(\d+)/);
26575
26804
  this._currentVersionIndex = versionMatch ? parseInt(versionMatch[1]) : 0;
26576
26805
  this.dispatchEvent(new CustomEvent("versionTagChange", {
26577
- detail: { from: this._currentVersion, to: versionTag },
26806
+ detail: { from: previousVersion, to: versionTag },
26578
26807
  }));
26579
26808
  return;
26580
26809
  }
@@ -26598,7 +26827,7 @@ class GveSnapshotRendition extends HTMLElement {
26598
26827
  const viewportWidth = this._rootSvg?.clientWidth || 0;
26599
26828
  const viewportHeight = this._rootSvg?.clientHeight || 0;
26600
26829
  await this._textRenderer.renderAdditionalText(addedNodes, this.getSvgContainer(), // Use viewport container, not root SVG
26601
- config, versionTag, refNodes, this._baseNodes, this._panZoomInstance, viewportWidth, viewportHeight);
26830
+ config, versionTag, refNodes, this._baseNodes, this._panZoomInstance, viewportWidth, viewportHeight, this._compressedTimeMode);
26602
26831
  }
26603
26832
  // Update current version
26604
26833
  this._currentVersion = versionTag;
@@ -26684,7 +26913,7 @@ class GveSnapshotRendition extends HTMLElement {
26684
26913
  const viewportHeight = this._rootSvg?.clientHeight || 0;
26685
26914
  await this._hintRenderer.renderHint(hintId, hint, refNodes, this.getSvgContainer(), // Use viewport container, not root SVG
26686
26915
  hintVars, operationId, i + 1, // ordinal (1-based)
26687
- this._baseNodes, versionTag, hintOverrides, this._panZoomInstance, viewportWidth, viewportHeight);
26916
+ this._baseNodes, versionTag, hintOverrides, this._panZoomInstance, viewportWidth, viewportHeight, this._compressedTimeMode);
26688
26917
  }
26689
26918
  }
26690
26919
  /**
@@ -26933,8 +27162,9 @@ class GveSnapshotRendition extends HTMLElement {
26933
27162
  return;
26934
27163
  }
26935
27164
  this._logger.info(`Removing ${elements.length} elements from v${versionIndex}`);
26936
- // Step 1: If backwardFadeOutTime > 0, animate fadeout
26937
- if (this._settings.backwardFadeOutTime > 0) {
27165
+ // Step 1: If backwardFadeOutTime > 0, animate fadeout (skipped in
27166
+ // compressed time mode, where elements are removed immediately)
27167
+ if (this._settings.backwardFadeOutTime > 0 && !this._compressedTimeMode) {
26938
27168
  const fadeOutPromises = [];
26939
27169
  elements.forEach((el) => {
26940
27170
  const promise = gsapWithCSS.to(el, {
@@ -27396,6 +27626,23 @@ class GveSnapshotRendition extends HTMLElement {
27396
27626
  getAutoForwardEnabled() {
27397
27627
  return this._autoForwardEnabled;
27398
27628
  }
27629
+ /**
27630
+ * Toggle compressed time mode. When enabled, navigating to any version
27631
+ * skips hint/text entrance animations, prolog panning animation, and the
27632
+ * backward fade-out, so the target version's final state is shown right
27633
+ * away instead of playing every intervening operation's animations.
27634
+ * @param enabled - Whether to enable compressed time mode
27635
+ */
27636
+ setCompressedTimeModeEnabled(enabled) {
27637
+ this._compressedTimeMode = enabled;
27638
+ this._logger.info(`Compressed time mode ${enabled ? "enabled" : "disabled"}`);
27639
+ }
27640
+ /**
27641
+ * Get current compressed time mode state.
27642
+ */
27643
+ getCompressedTimeModeEnabled() {
27644
+ return this._compressedTimeMode;
27645
+ }
27399
27646
  /**
27400
27647
  * Setup keyboard shortcuts for navigation and zoom.
27401
27648
  * Shortcuts:
@@ -40952,7 +41199,7 @@ function requireD () {
40952
41199
  + 'pragma private protected public pure ref return scope shared static struct '
40953
41200
  + 'super switch synchronized template this throw try typedef typeid typeof union '
40954
41201
  + 'unittest version void volatile while with __FILE__ __LINE__ __gshared|10 '
40955
- + '__thread __traits __DATE__ __EOF__ __TIME__ __TIMESTAMP__ __VENDOR__ 2.0.9',
41202
+ + '__thread __traits __DATE__ __EOF__ __TIME__ __TIMESTAMP__ __VENDOR__ 2.0.11',
40956
41203
  built_in:
40957
41204
  'bool cdouble cent cfloat char creal dchar delegate double dstring float function '
40958
41205
  + 'idouble ifloat ireal long real short string ubyte ucent uint ulong ushort wchar '
@@ -90566,23 +90813,36 @@ class GveHintDesigner extends HTMLElement {
90566
90813
  this._isPlaying = false;
90567
90814
  // Create logger
90568
90815
  this._logger = new Logger("GVE-HintDesigner", this._settings.debug);
90569
- // Register GSAP plugins
90816
+ this.registerGsapPlugins();
90817
+ // Create shadow DOM
90818
+ this._shadow = this.attachShadow({ mode: "open" });
90819
+ this._logger.info("GveHintDesigner component created");
90820
+ }
90821
+ /**
90822
+ * Register GSAP plugins when GSAP is available. Retried in
90823
+ * connectedCallback because GSAP may be loaded after the component
90824
+ * class is constructed.
90825
+ */
90826
+ registerGsapPlugins() {
90570
90827
  const gsap = window.gsap;
90571
- if (gsap) {
90828
+ if (gsap && !gsap.plugins?.drawSVG) {
90572
90829
  gsap.registerPlugin(DrawSVGPlugin);
90573
90830
  this._logger.debug("Component", "DrawSVG plugin registered:", gsap.plugins.drawSVG !== undefined);
90574
90831
  }
90575
- // Create shadow DOM
90576
- this._shadow = this.attachShadow({ mode: "open" });
90577
- this._logger.info("GveHintDesigner component created");
90578
90832
  }
90579
90833
  /**
90580
90834
  * Called when component is added to the DOM.
90581
90835
  */
90582
90836
  connectedCallback() {
90583
90837
  this._logger.info("Component connected to DOM");
90838
+ this.registerGsapPlugins();
90584
90839
  this.render();
90585
90840
  this.initializeEventListeners();
90841
+ // Reflect any state assigned before the element was connected (a common
90842
+ // pattern in vanilla consumers: set data/settings/hintId first, append
90843
+ // later). render() builds empty dropdowns/tables, so populate them now.
90844
+ this.refreshUI();
90845
+ this.refreshVariablesTable();
90586
90846
  }
90587
90847
  /**
90588
90848
  * Called when component is removed from the DOM.
@@ -90644,9 +90904,10 @@ class GveHintDesigner extends HTMLElement {
90644
90904
  this._hintVariables = value || [];
90645
90905
  }
90646
90906
  this.refreshVariablesTable();
90647
- this.fireEvent("hintVariablesChange", {
90648
- hintVariables: this._hintVariables,
90649
- });
90907
+ // NOTE: no hintVariablesChange event here. Change events represent USER
90908
+ // edits (add/edit/delete/populate, which fire it themselves); firing from
90909
+ // the setter would echo programmatic assignments back to the consumer,
90910
+ // which can loop forever with two-way bindings that clone the array.
90650
90911
  }
90651
90912
  // ==================== RENDERING ====================
90652
90913
  /**
@@ -91261,7 +91522,11 @@ class GveHintDesigner extends HTMLElement {
91261
91522
  this._lastPanY = mouseEvent.clientY;
91262
91523
  panel.style.cursor = "grabbing";
91263
91524
  });
91264
- document.addEventListener("mousemove", (e) => {
91525
+ // Remove any handlers from a previous connect cycle before adding new
91526
+ // ones: connectedCallback re-runs initializeEventListeners each time the
91527
+ // element is (re)attached, and document-level listeners would accumulate.
91528
+ this.removeDocumentPanListeners();
91529
+ this._panMoveHandler = (e) => {
91265
91530
  if (!this._isPanning)
91266
91531
  return;
91267
91532
  const deltaX = e.clientX - this._lastPanX;
@@ -91271,17 +91536,34 @@ class GveHintDesigner extends HTMLElement {
91271
91536
  this._lastPanX = e.clientX;
91272
91537
  this._lastPanY = e.clientY;
91273
91538
  this.updateSvgTransform();
91274
- });
91275
- document.addEventListener("mouseup", () => {
91539
+ };
91540
+ document.addEventListener("mousemove", this._panMoveHandler);
91541
+ this._panUpHandler = () => {
91276
91542
  if (this._isPanning) {
91277
91543
  this._isPanning = false;
91278
- const panelEl = panel;
91279
- panelEl.style.cursor = "grab";
91544
+ const panelEl = this._shadow.querySelector(".svg-display-panel");
91545
+ if (panelEl) {
91546
+ panelEl.style.cursor = "grab";
91547
+ }
91280
91548
  }
91281
- });
91549
+ };
91550
+ document.addEventListener("mouseup", this._panUpHandler);
91282
91551
  // Set initial cursor
91283
91552
  panel.style.cursor = "grab";
91284
91553
  }
91554
+ /**
91555
+ * Remove the document-level pan listeners, if any.
91556
+ */
91557
+ removeDocumentPanListeners() {
91558
+ if (this._panMoveHandler) {
91559
+ document.removeEventListener("mousemove", this._panMoveHandler);
91560
+ this._panMoveHandler = undefined;
91561
+ }
91562
+ if (this._panUpHandler) {
91563
+ document.removeEventListener("mouseup", this._panUpHandler);
91564
+ this._panUpHandler = undefined;
91565
+ }
91566
+ }
91285
91567
  /**
91286
91568
  * Initialize zooming with mouse wheel.
91287
91569
  */
@@ -91325,6 +91607,8 @@ class GveHintDesigner extends HTMLElement {
91325
91607
  clearInterval(this._progressUpdateInterval);
91326
91608
  this._progressUpdateInterval = undefined;
91327
91609
  }
91610
+ this.removeDocumentPanListeners();
91611
+ this._isPanning = false;
91328
91612
  }
91329
91613
  // ==================== UI ACTIONS ====================
91330
91614
  /**
@@ -91693,13 +91977,14 @@ class GveHintDesigner extends HTMLElement {
91693
91977
  const confirm = window.confirm(`Delete hint "${this._hintId}"?`);
91694
91978
  if (!confirm)
91695
91979
  return;
91696
- delete this._data.hints[this._hintId];
91980
+ const deletedId = this._hintId;
91981
+ delete this._data.hints[deletedId];
91697
91982
  this.hintId = undefined;
91698
91983
  this.refreshHintsDropdown();
91699
91984
  this.clearHintForm();
91700
91985
  this.showMessage("Hint deleted", "success");
91701
91986
  this.fireEvent("dataChange", { data: this._data });
91702
- this._logger.info("Deleted hint", this._hintId);
91987
+ this._logger.info("Deleted hint", deletedId);
91703
91988
  }
91704
91989
  /**
91705
91990
  * Parse offset value (number or string with suffix).
@@ -91795,26 +92080,29 @@ class GveHintDesigner extends HTMLElement {
91795
92080
  }
91796
92081
  /**
91797
92082
  * Resolve variables in SVG code.
92083
+ * Delegates to the same resolver used by the renderer (shared semantics:
92084
+ * literal names — no regex injection — and "??" fallback chains like
92085
+ * {{r_fore-color ?? r_color}}).
91798
92086
  */
91799
92087
  resolveVariables(svgCode) {
91800
- let resolved = svgCode;
91801
- this._hintVariables.forEach((variable) => {
91802
- const placeholder = `{{${variable.name}}}`;
91803
- resolved = resolved.replace(new RegExp(placeholder, "g"), variable.value);
91804
- });
91805
- return resolved;
92088
+ const variables = new Map(this._hintVariables.map((v) => [v.name, v.value]));
92089
+ return resolvePlaceholders(svgCode, variables);
91806
92090
  }
91807
92091
  /**
91808
92092
  * Extract variable names from SVG code (variables in {{...}} placeholders).
92093
+ * Placeholders may contain "??" fallback chains: each name in a chain is
92094
+ * reported as a separate variable.
91809
92095
  */
91810
92096
  extractVariablesFromSvg(svgCode) {
91811
92097
  const variablePattern = /\{\{([^}]+)\}\}/g;
91812
92098
  const variables = [];
91813
92099
  let match;
91814
92100
  while ((match = variablePattern.exec(svgCode)) !== null) {
91815
- const varName = match[1].trim();
91816
- if (varName && !variables.includes(varName)) {
91817
- variables.push(varName);
92101
+ for (const name of match[1].split("??")) {
92102
+ const varName = name.trim();
92103
+ if (varName && !variables.includes(varName)) {
92104
+ variables.push(varName);
92105
+ }
91818
92106
  }
91819
92107
  }
91820
92108
  return variables;