diff --git a/CHANGELOG.md b/CHANGELOG.md index 35af9e3..98f3658 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## 1.15.5 β€” 2026-07-10 + +Saved graph files load unchanged; older files (and files saved by earlier versions) default the new opacity to fully opaque, so their appearance is identical. + +### Features + +* **Node and edge opacity.** The styling panel (under 🎨) now has an **Opacity** slider for both nodes and edges (1 = opaque, 0 = invisible). It folds into the element's color alpha, so it composes with a color that already carries transparency, and β€” like the other numeric style knobs β€” it can be **mapped by data** via the ∿ button to scale opacity from a numeric property. Opacity round-trips through both JSON and the Excel `Opacity` column (documented in the template's readme tab). Being an alpha effect, low opacity composites toward the background: light colors (e.g. the default slate edges) approach white sooner than saturated ones. + +### Fixes + +* **Exported images now match the on-screen bubble-group z-order.** PNG and SVG exports painted bubble-set hulls *underneath* the nodes and edges, but the live view draws them on top (since 1.15.1). On dense graphs the edge mesh buried the hulls, which also made edges read as more opaque than on screen. Both exporters now composite hulls above nodes/edges and below labels, matching the live layer. Sparse graphs were unaffected, so this was only visible on large graphs. + ## 1.15.4 β€” 2026-07-01 Saved graph files load unchanged; older files default the two new per-view settings to the previous behavior (OR, non-strict). diff --git a/package.json b/package.json index 9d3a8ca..c36c78a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "graph-lens-lite", - "version": "1.15.4", + "version": "1.15.5", "main": "src/package/electron_app.js", "description": "Visualise and explore property graphs in a lightweight desktop app.", "homepage": "https://github.com/Delta4AI/GraphLensLite", diff --git a/src/config.js b/src/config.js index 21aae4e..606d70a 100644 --- a/src/config.js +++ b/src/config.js @@ -1,11 +1,11 @@ /** * Defaults for the graph, layouts and UI */ -const VERSION = "1.15.4"; +const VERSION = "1.15.5"; const DEFAULTS = { NODE: { - FILL_COLOR: "#C33D35", SIZE: 20, LINE_WIDTH: 1, TYPE: "hexagon", STROKE_COLOR: null, + FILL_COLOR: "#C33D35", SIZE: 20, LINE_WIDTH: 1, TYPE: "hexagon", STROKE_COLOR: null, OPACITY: 1, BADGE: { FONT_SIZE: 8, COLOR: "#C33D35", SCALE_WITH_NODE: false }, @@ -32,7 +32,7 @@ const DEFAULTS = { }, }, EDGE: { - COLOR: "#403C5390", LINE_WIDTH: 0.75, LINE_DASH: 0, TYPE: "line", + COLOR: "#403C5390", LINE_WIDTH: 0.75, LINE_DASH: 0, TYPE: "line", OPACITY: 1, ARROWS: { START: false, END: false, START_SIZE: 8, START_TYPE: "arrow", END_SIZE: 8, END_TYPE: "arrow", // null fill β†’ marker inherits the edge stroke color; null border β†’ no border (transparent). diff --git a/src/graph/export_svg.js b/src/graph/export_svg.js index f918a39..9dbef1b 100644 --- a/src/graph/export_svg.js +++ b/src/graph/export_svg.js @@ -658,8 +658,9 @@ function bubbleLabelPrimitives({ points, opts = {}, defaults = {} }, measureText /** * Collect the full scene as a flat array of typed primitives, in draw order: - * bubble bodies β†’ edges β†’ nodes β†’ edge labels β†’ node labels (+badges) β†’ - * bubble labels. (The background rect is the serializer's job.) + * edges β†’ nodes β†’ bubble bodies β†’ edge labels β†’ node labels (+badges) β†’ + * bubble labels. Bubble bodies sit above nodes/edges to match the live layer + * (afterLayer:"nodes"), below labels. (The background rect is the serializer's job.) * * @param {object} args same shape as buildGraphSvg (background unused here) * @returns {Array} primitives ({kind: "rect"|"circle"|"path"| @@ -672,9 +673,9 @@ function collectSvgScene({ sigma, graph, bubbleGroups = [], measureText }) { const sortedNodes = sortByZIndex([...nodes.values()]); const prims = []; - for (const group of bubbleGroups) prims.push(...bubbleBodyPrimitives(group)); for (const edge of edges) prims.push(...edgePrimitives(edge, sigma)); for (const node of sortedNodes) prims.push(...nodePrimitives(node)); + for (const group of bubbleGroups) prims.push(...bubbleBodyPrimitives(group)); if (env.renderEdgeLabels) { for (const edge of edges) { if (env.edgeLabelSet && !env.edgeLabelSet.has(edge.id)) continue; diff --git a/src/graph/graph_model.js b/src/graph/graph_model.js index 3a1c2f4..1463b8e 100644 --- a/src/graph/graph_model.js +++ b/src/graph/graph_model.js @@ -52,8 +52,8 @@ function nodeAttributesFromStyle(style = {}, type = undefined) { // G6 size is a diameter (or [w, h]); sigma size is a radius. attrs.size = (Array.isArray(style.size) ? style.size[0] : style.size) / 2; } - if (style.fill !== undefined) attrs.color = style.fill; - if (style.stroke !== undefined) attrs.borderColor = style.stroke; + if (style.fill !== undefined) attrs.color = applyHexOpacity(style.fill, style.opacity); + if (style.stroke !== undefined) attrs.borderColor = applyHexOpacity(style.stroke, style.opacity); if (style.lineWidth !== undefined) attrs.borderSize = style.lineWidth; if (style.label === false) { @@ -142,8 +142,11 @@ function badgeScaleFactor(style) { * - native circle/square (color=fill, fillColor/image cleared, borderRatio 0) */ function nodeProgramAttributes(style = {}, type) { - const fill = style.fill ?? DEFAULTS.NODE.FILL_COLOR; - const stroke = style.stroke ?? null; + // Opacity folds into the fill/stroke alpha here so it reaches every program: + // the baked SVG texture (shape), the border rings (borderCircle) and the + // native circle/square fill alike. applyHexOpacity is null/opaque-safe. + const fill = applyHexOpacity(style.fill ?? DEFAULTS.NODE.FILL_COLOR, style.opacity); + const stroke = applyHexOpacity(style.stroke ?? null, style.opacity); const lineWidth = style.lineWidth ?? 0; const hasBorder = Boolean(stroke) && lineWidth > 0; const size = Array.isArray(style.size) ? style.size[0] : style.size; @@ -412,7 +415,7 @@ function edgeMarkerHaloAttributes(style) { function edgeAttributesFromStyle(style = {}, type = undefined) { const attrs = {}; if (style.lineWidth !== undefined) attrs.size = style.lineWidth; - if (style.stroke !== undefined) attrs.color = style.stroke; + if (style.stroke !== undefined) attrs.color = applyHexOpacity(style.stroke, style.opacity); if (style.label === false) { attrs.label = null; } else if (style.label && style.labelText !== undefined) { diff --git a/src/graph/sigma_adapter.js b/src/graph/sigma_adapter.js index 555e5b8..7f070f9 100644 --- a/src/graph/sigma_adapter.js +++ b/src/graph/sigma_adapter.js @@ -900,7 +900,8 @@ class SigmaAdapter { * scene on a temp renderer, which only carries sigma's own layers, so the * bubble-set hulls and group labels are re-painted here at the export * resolution (BubbleSetLayer.drawExport*) in their on-screen z-order: the - * body/outline UNDER the sigma image, the group labels OVER it. An opaque + * body/outline OVER the sigma image (the live layer is afterLayer:"nodes", + * i.e. above nodes/edges), then the group labels OVER that. An opaque * stage-background fill goes down first (export-image renders sigma * transparent). The minimap is a viewport control and stays out of exports. * @@ -958,8 +959,14 @@ class SigmaAdapter { // scaled exports blurry, and their zoom-bucketed outlines drift from // the freshly rendered nodes after a zoom). const bubbleGroups = this.bubbleLayer.exportOutlines(); - this.bubbleLayer.drawExportBodies(ctx, bubbleGroups, dpr * appliedScale); ctx.drawImage(sigmaImage, 0, 0); + // Bodies/outlines sit ABOVE nodes/edges on screen (afterLayer:"nodes"), + // so composite them over the sigma image, not under it β€” under-painting + // let a dense edge mesh bury the hulls and left edges looking un-softened. + // ponytail: the flattened sigma image also carries node labels, so a body + // (0.25 fill) faintly tints labels it overlaps; a separate label-only + // render pass would fix it β€” not worth it (dense graphs hide labels). + this.bubbleLayer.drawExportBodies(ctx, bubbleGroups, dpr * appliedScale); // Group labels sit above sigma's node labels on screen (their own canvas // at afterLayer "labels"); composite them last to keep that z-order. this.bubbleLayer.drawExportLabels(ctx, bubbleGroups, dpr * appliedScale); diff --git a/src/graph/style.js b/src/graph/style.js index f60b3cc..c98d98a 100644 --- a/src/graph/style.js +++ b/src/graph/style.js @@ -47,6 +47,7 @@ class GraphStyleManager { fill : src.fill ?? d.FILL_COLOR, stroke : src.stroke ?? d.STROKE_COLOR, lineWidth : src.lineWidth ?? d.LINE_WIDTH, + opacity : src.opacity ?? d.OPACITY, badge : src.badge ?? false, badges : src.badges ?? [], @@ -111,6 +112,7 @@ class GraphStyleManager { lineWidth : src.lineWidth ?? d.LINE_WIDTH, lineDash : src.lineDash ?? d.LINE_DASH, stroke : src.stroke ?? d.COLOR, + opacity : src.opacity ?? d.OPACITY, halo : src.halo ?? d.HALO.ENABLED, haloStroke : src.haloStroke ?? d.HALO.COLOR, diff --git a/src/managers/io.js b/src/managers/io.js index a401fc2..6224a2e 100644 --- a/src/managers/io.js +++ b/src/managers/io.js @@ -28,790 +28,7 @@ function writeExportScale(scale) { } // @formatter:off -let excelData = { - s: { - readme: { - d: [ - [0, 0, 'Color Codes Explained'], - [0, 1, 'Description'], - [1, 0, 'Required'], - [1, 1, 'Strictly required property'], - [2, 0, 'Optional'], - [2, 1, 'Optional property; Column name can not be re-used for user-defined data'], - [3, 0, 'User Data [group]'], - [ - 3, - 1, - 'Add custom properties in new columns. Use [brackets] for grouping (e.g., "Temperature [Celsius] [Physics]"). The last [bracket] becomes the group name.', - ], - [5, 0, 'Data Types Explained'], - [5, 1, 'Description'], - [6, 0, 'text'], - [6, 1, 'any text'], - [7, 0, 'number'], - [7, 1, 'whole decimal numbers or floating point numbers'], - [8, 0, 'boolean'], - [8, 1, 'true or TRUE or 1, false or FALSE or 0'], - [9, 0, 'RGBA'], - [9, 1, 'RGBA hex color code (e.g. #C33D3580 for 50% opacity) (last 2 digits are optional)'], - [10, 0, 'value 1 | value 2'], - [ - 10, - 1, - 'List of categorical values of which one must be matched (excluding optional information in brackets)', - ], - [11, 0, 'any'], - [ - 11, - 1, - 'Categorical view when text is given, slider-based view when numerical input is given', - ], - [13, 0, 'Node Properties Explained'], - [13, 1, 'Description'], - [13, 2, 'Default Value'], - [13, 3, 'Type'], - [14, 0, 'ID'], - [14, 1, 'Unique identifier for a node'], - [14, 2, '-'], - [14, 3, 'text (unique)'], - [15, 0, 'Label'], - [15, 1, 'Label of the node; if no Label is given, the ID is displayed per default'], - [15, 2, '-'], - [15, 3, 'text'], - [16, 0, 'Description'], - [16, 1, 'Description of the node, displayed in the tooltip text'], - [16, 2, '-'], - [16, 3, 'text'], - [17, 0, 'Shape'], - [17, 1, 'The shape of the node'], - [17, 2, 'hexagon'], - [17, 3, 'circle (●) | diamond (β—†) | hexagon (β¬’) | rect (β– ) | triangle (β–²) | star (β˜…)'], - [18, 0, 'Size'], - [18, 1, 'The size of the node'], - [18, 2, '20'], - [18, 3, 'number'], - [19, 0, 'Fill Color'], - [ - 19, - 1, - 'The fill color of the node in RGBA format (e.g. #FF000080 for a red node with 50% opacity)', - ], - [19, 2, '#C33D35'], - [19, 3, 'rgba'], - [20, 0, 'Border Size'], - [20, 1, 'The stroke width'], - [20, 2, '1'], - [20, 3, 'number'], - [21, 0, 'Border Color'], - [21, 1, 'The stroke color of the node in RGBA format'], - [21, 2, '-'], - [21, 3, 'rgba'], - [22, 0, 'Label Font Size'], - [22, 1, 'Label font size'], - [22, 2, '12'], - [22, 3, 'number'], - [23, 0, 'Label Placement'], - [23, 1, 'Label position relative to the main shape of the node'], - [23, 2, 'bottom'], - [ - 23, - 3, - 'left | right | top | bottom | left-top | left-bottom | right-top | right-bottom | top-left | top-right | bottom-left | bottom-right | center', - ], - [24, 0, 'Label Color'], - [24, 1, 'The color of the nodes label'], - [24, 2, '-'], - [24, 3, 'rgba'], - [25, 0, 'Label Background Color'], - [25, 1, 'Label background fill color'], - [25, 2, '-'], - [25, 3, 'rgba'], - [26, 0, 'X Coordinate'], - [26, 1, 'The x coordinate of the node'], - [26, 2, '-'], - [26, 3, 'number'], - [27, 0, 'Y Coordinate'], - [27, 1, 'The y coordinate of the node'], - [27, 2, '-'], - [27, 3, 'number'], - [28, 0, 'property A [group A]'], - [28, 1, 'User-defined custom node properties'], - [28, 2, '-'], - [28, 3, 'any'], - [30, 0, 'Edge Properties Explained'], - [30, 1, 'Description'], - [30, 2, 'Default Value'], - [30, 3, 'Type'], - [31, 0, 'Source ID'], - [31, 1, 'The ID of the source node'], - [31, 2, '-'], - [31, 3, 'text'], - [32, 0, 'Target ID'], - [32, 1, 'The ID of the target node'], - [32, 2, '-'], - [32, 3, 'text'], - [33, 0, 'Label'], - [ - 33, - 1, - 'Label of the edge; if no Label is given, the edge is only visible as line without any text', - ], - [33, 2, '-'], - [33, 3, 'text'], - [34, 0, 'Description'], - [34, 1, 'Description of the edge, displayed in the tooltip text'], - [34, 2, '-'], - [34, 3, 'text'], - [35, 0, 'Type'], - [35, 1, 'The edge type'], - [35, 2, 'line'], - [35, 3, 'line | cubic | quadratic | polyline'], - [36, 0, 'Line Width'], - [36, 1, 'The border width of the edge'], - [36, 2, '0.75'], - [36, 3, 'number'], - [37, 0, 'Line Dash'], - [37, 1, 'The dash offset of the edge line'], - [37, 2, '0'], - [37, 3, 'number'], - [38, 0, 'Color'], - [38, 1, 'The stroke color of the edge in RGBA format'], - [38, 2, '#403C5390'], - [38, 3, 'rgba'], - [39, 0, 'Label Font Size'], - [39, 1, 'The font size of the edges label'], - [39, 2, 'center'], - [39, 3, 'number'], - [40, 0, 'Label Placement'], - [40, 1, 'The position of the label relative to the edge'], - [40, 2, '#000000'], - [40, 3, 'start | center | end'], - [41, 0, 'Label Auto Rotate'], - [41, 1, 'Whether to automatically rotate the label to match the edge’s direction'], - [41, 2, '1'], - [41, 3, 'boolean'], - [42, 0, 'Label Offset X'], - [42, 1, 'The offset of the label on the X-Axis'], - [42, 2, '0'], - [42, 3, 'number'], - [43, 0, 'Label Offset Y'], - [43, 1, 'The offset of the label on the Y-Axis'], - [43, 2, '0'], - [43, 3, 'number'], - [44, 0, 'Label Color'], - [44, 1, 'The color of the edges label text'], - [44, 2, '-'], - [44, 3, 'rgba'], - [45, 0, 'Label Background Color'], - [45, 1, 'The color for the edge label’s background'], - [45, 2, '#E4E3EA'], - [45, 3, 'rgba'], - [46, 0, 'Start Arrow'], - [46, 1, 'Whether to display the start arrow on the edge'], - [46, 2, { formula: 'FALSE()' }], - [46, 3, 'boolean'], - [47, 0, 'Start Arrow Size'], - [47, 1, 'The size of the start arrow'], - [47, 2, '8'], - [47, 3, 'number'], - [48, 0, 'Start Arrow Type'], - [48, 1, 'The type of the start arrow'], - [48, 2, 'triangle'], - [48, 3, 'triangle | circle | diamond | vee | rect | triangleRect | simple'], - [49, 0, 'End Arrow'], - [49, 1, 'Whether to display the end arrow on the edge'], - [49, 2, { formula: 'FALSE()' }], - [49, 3, 'boolean'], - [50, 0, 'End Arrow Size'], - [50, 1, 'The size of the end arrow'], - [50, 2, '8'], - [50, 3, 'number'], - [51, 0, 'End Arrow Type'], - [51, 1, 'The type of the end arrow'], - [51, 2, 'triangle'], - [51, 3, 'triangle | circle | diamond | vee | rect | triangleRect | simple'], - ], - st: { - A1: 0, - B1: 0, - C1: 1, - D1: 2, - A2: 3, - B2: 2, - C2: 1, - D2: 2, - A3: 4, - B3: 2, - C3: 1, - D3: 2, - A4: 5, - B4: 2, - C4: 1, - D4: 2, - A5: 2, - B5: 2, - C5: 1, - D5: 2, - A6: 0, - B6: 0, - C6: 6, - D6: 7, - A7: 8, - B7: 2, - C7: 1, - D7: 2, - A8: 8, - B8: 2, - C8: 1, - D8: 2, - A9: 8, - B9: 2, - C9: 1, - D9: 2, - A10: 8, - B10: 2, - C10: 1, - D10: 2, - A11: 9, - B11: 2, - C11: 1, - D11: 2, - A12: 8, - B12: 2, - C12: 1, - D12: 2, - A13: 2, - B13: 2, - C13: 1, - D13: 2, - A14: 10, - B14: 10, - C14: 11, - D14: 10, - A15: 3, - B15: 2, - C15: 1, - D15: 2, - A16: 4, - B16: 2, - C16: 1, - D16: 2, - A17: 4, - B17: 2, - C17: 1, - D17: 2, - A18: 4, - B18: 2, - C18: 1, - D18: 12, - A19: 4, - B19: 2, - C19: 1, - D19: 2, - A20: 4, - B20: 2, - C20: 1, - D20: 2, - A21: 4, - B21: 2, - C21: 1, - D21: 2, - A22: 4, - B22: 2, - C22: 1, - D22: 2, - A23: 4, - B23: 2, - C23: 1, - D23: 2, - A24: 4, - B24: 2, - C24: 1, - D24: 12, - A25: 4, - B25: 2, - C25: 1, - D25: 2, - A26: 4, - B26: 2, - C26: 1, - D26: 2, - A27: 4, - B27: 2, - C27: 1, - D27: 2, - A28: 4, - B28: 2, - C28: 1, - D28: 2, - A29: 5, - B29: 2, - C29: 1, - D29: 2, - A30: 2, - B30: 2, - C30: 1, - D30: 2, - A31: 10, - B31: 10, - C31: 11, - D31: 10, - A32: 3, - B32: 2, - C32: 1, - D32: 2, - A33: 3, - B33: 2, - C33: 1, - D33: 2, - A34: 4, - B34: 2, - C34: 1, - D34: 2, - A35: 4, - B35: 2, - C35: 1, - D35: 2, - A36: 4, - B36: 2, - C36: 1, - D36: 12, - A37: 4, - B37: 2, - C37: 1, - D37: 2, - A38: 4, - B38: 2, - C38: 1, - D38: 2, - A39: 4, - B39: 2, - C39: 1, - D39: 2, - A40: 4, - B40: 2, - C40: 1, - D40: 2, - A41: 4, - B41: 2, - C41: 1, - D41: 12, - A42: 4, - B42: 2, - C42: 13, - D42: 2, - A43: 4, - B43: 2, - C43: 1, - D43: 2, - A44: 4, - B44: 2, - C44: 1, - D44: 2, - A45: 4, - B45: 2, - C45: 1, - D45: 2, - A46: 4, - B46: 2, - C46: 13, - D46: 2, - A47: 4, - B47: 2, - C47: 13, - D47: 2, - A48: 4, - B48: 2, - C48: 1, - D48: 2, - A49: 4, - B49: 2, - C49: 13, - D49: 12, - A50: 4, - B50: 2, - C50: 13, - D50: 2, - A51: 4, - B51: 2, - C51: 1, - D51: 14, - A52: 4, - B52: 2, - C52: 13, - D52: 12, - }, - dim: [52, 4], - }, - nodes: { - d: [ - [0, 0, 'ID'], - [0, 1, 'Label'], - [0, 2, 'Description'], - [0, 3, 'Shape'], - [0, 4, 'Size'], - [0, 5, 'Fill Color'], - [0, 6, 'Border Color'], - [0, 7, 'Feature X [group A]'], - [0, 8, 'Feature Y [nm] [group A]'], - [0, 9, 'Feature Z [group B]'], - [1, 0, 'A'], - [1, 1, 'Node 1'], - [1, 2, 'The first node'], - [1, 3, 'circle'], - [1, 4, '60'], - [1, 5, '#403C53'], - [1, 6, '#C33D35'], - [1, 7, '1'], - [1, 8, 'foo'], - [1, 9, '1'], - [2, 0, 'B'], - [2, 1, 'Node 2'], - [2, 2, 'The second node'], - [2, 7, '0.5'], - [2, 8, 'foo'], - [2, 9, '2'], - [3, 0, 'C'], - [3, 1, 'Node 3'], - [3, 2, 'The third node'], - [3, 7, '1.1'], - [3, 8, 'foo'], - [3, 9, '1'], - [4, 0, 'D'], - [4, 1, 'Node 4'], - [4, 2, 'The fourth node'], - [4, 7, '1.3'], - [4, 8, 'bar'], - [4, 9, '0'], - [5, 0, 'E'], - [5, 7, '0'], - [5, 8, 'bar'], - [5, 9, '-1'], - [6, 0, 'F'], - [6, 1, 'Lonely Node'], - [6, 7, '-1'], - ], - st: { - A1: 3, - B1: 4, - C1: 4, - D1: 4, - E1: 4, - F1: 4, - G1: 4, - H1: 5, - I1: 5, - J1: 5, - A2: 15, - B2: 16, - C2: 16, - D2: 16, - E2: 16, - F2: 16, - G2: 16, - H2: 17, - I2: 17, - J2: 17, - A3: 15, - B3: 16, - C3: 16, - D3: 16, - E3: 16, - F3: 16, - G3: 16, - H3: 17, - I3: 17, - J3: 17, - A4: 15, - B4: 16, - C4: 16, - D4: 16, - E4: 16, - F4: 16, - G4: 16, - H4: 17, - I4: 17, - J4: 17, - A5: 15, - B5: 16, - C5: 16, - D5: 16, - E5: 16, - F5: 16, - G5: 16, - H5: 17, - I5: 17, - J5: 17, - A6: 15, - B6: 16, - C6: 16, - D6: 16, - E6: 16, - F6: 16, - G6: 16, - H6: 17, - I6: 17, - J6: 17, - A7: 15, - B7: 16, - C7: 16, - D7: 16, - E7: 16, - F7: 16, - G7: 16, - H7: 17, - I7: 17, - J7: 17, - }, - dim: [7, 10], - }, - edges: { - d: [ - [0, 0, 'Source ID'], - [0, 1, 'Target ID'], - [0, 2, 'Color'], - [0, 3, 'Line Width'], - [0, 4, 'Label'], - [0, 5, 'Feature EX [group X]'], - [0, 6, 'Feature EY [group X]'], - [0, 7, 'Feature EZ [group Y]'], - [1, 0, 'A'], - [1, 1, 'B'], - [1, 2, '#FF0000'], - [1, 3, '0.75'], - [1, 4, 'foo'], - [1, 5, '1'], - [1, 6, 'Dummy Category 1'], - [1, 7, '1'], - [2, 0, 'A'], - [2, 1, 'C'], - [2, 5, '0.5'], - [2, 6, 'Dummy Category 2'], - [2, 7, '2'], - [3, 0, 'C'], - [3, 1, 'D'], - [3, 5, '1.1'], - [3, 6, 'Dummy Category 3'], - [3, 7, '1'], - [4, 0, 'D'], - [4, 1, 'E'], - [4, 5, '1.3'], - [4, 6, 'Dummy Category 4'], - [4, 7, '0'], - ], - st: { - A1: 3, - B1: 3, - C1: 4, - D1: 4, - E1: 4, - F1: 5, - G1: 5, - H1: 5, - A2: 15, - B2: 15, - C2: 16, - D2: 16, - E2: 16, - F2: 17, - G2: 17, - H2: 17, - A3: 15, - B3: 15, - C3: 16, - D3: 16, - E3: 16, - F3: 17, - G3: 17, - H3: 17, - A4: 15, - B4: 15, - C4: 16, - D4: 16, - E4: 16, - F4: 17, - G4: 17, - H4: 17, - A5: 15, - B5: 15, - C5: 16, - D5: 16, - E5: 16, - F5: 17, - G5: 17, - H5: 17, - }, - dim: [5, 8], - }, - }, - st: { - 0: { - f: { b: 1, sz: 12, n: 'Arial' }, - fill: { fg: 'E4E3EA', bg: 'FEFFE1' }, - b: { - t: ['thin', '000000'], - b: ['thin', '000000'], - l: ['thin', '000000'], - r: ['thin', '000000'], - }, - a: { h: 'general', v: 'bottom' }, - nf: 'General', - }, - 1: { - f: { sz: 10, n: 'Arial' }, - fill: { p: 'none' }, - a: { h: 'left', v: 'bottom' }, - nf: 'General', - }, - 2: { - f: { sz: 10, n: 'Arial' }, - fill: { p: 'none' }, - a: { h: 'general', v: 'bottom' }, - nf: 'General', - }, - 3: { - f: { b: 1, sz: 10, n: 'Arial' }, - fill: { fg: 'FF9A9A', bg: 'FF8080' }, - b: { - t: ['thin', '000000'], - b: ['thin', '000000'], - l: ['thin', '000000'], - r: ['thin', '000000'], - }, - a: { h: 'general', v: 'bottom' }, - nf: 'General', - }, - 4: { - f: { b: 1, sz: 10, n: 'Arial' }, - fill: { fg: 'FEFFE1', bg: 'FFFFFF' }, - b: { - t: ['thin', '000000'], - b: ['thin', '000000'], - l: ['thin', '000000'], - r: ['thin', '000000'], - }, - a: { h: 'general', v: 'bottom' }, - nf: 'General', - }, - 5: { - f: { b: 1, sz: 10, n: 'Arial' }, - fill: { fg: '81D41A', bg: '969696' }, - b: { - t: ['thin', '000000'], - b: ['thin', '000000'], - l: ['thin', '000000'], - r: ['thin', '000000'], - }, - a: { h: 'general', v: 'bottom' }, - nf: 'General', - }, - 6: { - f: { b: 1, sz: 12, n: 'Arial' }, - fill: { p: 'none' }, - a: { h: 'left', v: 'bottom' }, - nf: 'General', - }, - 7: { - f: { b: 1, sz: 12, n: 'Arial' }, - fill: { p: 'none' }, - a: { h: 'general', v: 'bottom' }, - nf: 'General', - }, - 8: { - f: { b: 1, sz: 10, n: 'Arial' }, - fill: { fg: 'E4E3EA', bg: 'FEFFE1' }, - b: { - t: ['thin', '000000'], - b: ['thin', '000000'], - l: ['thin', '000000'], - r: ['thin', '000000'], - }, - a: { h: 'general', v: 'bottom' }, - nf: 'General', - }, - 9: { - f: { b: 1, i: 1, sz: 10, n: 'Arial' }, - fill: { fg: 'E4E3EA', bg: 'FEFFE1' }, - b: { - t: ['thin', '000000'], - b: ['thin', '000000'], - l: ['thin', '000000'], - r: ['thin', '000000'], - }, - a: { h: 'general', v: 'bottom' }, - nf: 'General', - }, - 10: { - f: { b: 1, sz: 12, n: 'Arial' }, - fill: { fg: 'E4E3EA', bg: 'FEFFE1' }, - b: { t: ['thin', '000000'], b: ['thin', '000000'] }, - a: { h: 'general', v: 'bottom' }, - nf: 'General', - }, - 11: { - f: { b: 1, sz: 12, n: 'Arial' }, - fill: { fg: 'E4E3EA', bg: 'FEFFE1' }, - b: { t: ['thin', '000000'], b: ['thin', '000000'] }, - a: { h: 'left', v: 'bottom' }, - nf: 'General', - }, - 12: { - f: { i: 1, sz: 10, n: 'Arial' }, - fill: { p: 'none' }, - a: { h: 'general', v: 'bottom' }, - nf: 'General', - }, - 13: { - f: { sz: 10, n: 'Arial' }, - fill: { p: 'none' }, - a: { h: 'left', v: 'bottom' }, - nf: '"TRUE";"TRUE";"FALSE"', - }, - 14: { - f: { u: 1, sz: 10, n: 'Arial' }, - fill: { p: 'none' }, - a: { h: 'general', v: 'bottom' }, - nf: 'General', - }, - 15: { - f: { sz: 10, n: 'Arial' }, - fill: { fg: 'FF9A9A', bg: 'FF8080' }, - b: { - t: ['thin', '000000'], - b: ['thin', '000000'], - l: ['thin', '000000'], - r: ['thin', '000000'], - }, - a: { h: 'general', v: 'bottom' }, - nf: 'General', - }, - 16: { - f: { sz: 10, n: 'Arial' }, - fill: { fg: 'FEFFE1', bg: 'FFFFFF' }, - b: { - t: ['thin', '000000'], - b: ['thin', '000000'], - l: ['thin', '000000'], - r: ['thin', '000000'], - }, - a: { h: 'general', v: 'bottom' }, - nf: 'General', - }, - 17: { - f: { sz: 10, n: 'Arial' }, - fill: { fg: '81D41A', bg: '969696' }, - b: { - t: ['thin', '000000'], - b: ['thin', '000000'], - l: ['thin', '000000'], - r: ['thin', '000000'], - }, - a: { h: 'general', v: 'bottom' }, - nf: 'General', - }, - }, - sc: 18, -}; +let excelData = {"s":{"readme":{"d":[[0,0,"Color Codes Explained"],[0,1,"Description"],[1,0,"Required"],[1,1,"Strictly required property"],[2,0,"Optional"],[2,1,"Optional property; Column name can not be re-used for user-defined data"],[3,0,"User Data [group]"],[3,1,"Add custom properties in new columns. Use [brackets] for grouping (e.g., \"Temperature [Celsius] [Physics]\"). The last [bracket] becomes the group name."],[5,0,"Data Types Explained"],[5,1,"Description"],[6,0,"text"],[6,1,"any text"],[7,0,"number"],[7,1,"integers or floating-point numbers"],[8,0,"boolean"],[8,1,"true or TRUE or 1, false or FALSE or 0"],[9,0,"RGBA"],[9,1,"RGBA hex color code (e.g. #C33D3580 for 50% opacity) (last 2 digits are optional)"],[10,0,"value 1 | value 2"],[10,1,"List of categorical values of which one must be matched (excluding optional information in brackets)"],[11,0,"any"],[11,1,"Categorical view when text is given, slider-based view when numerical input is given"],[13,0,"Node Properties Explained"],[13,1,"Description"],[13,2,"Default Value"],[13,3,"Type"],[14,0,"ID"],[14,1,"Unique identifier for a node"],[14,2,"-"],[14,3,"text (unique)"],[15,0,"Label"],[15,1,"Label of the node; if no Label is given, the ID is displayed per default"],[15,2,"-"],[15,3,"text"],[16,0,"Description"],[16,1,"Description of the node, displayed in the tooltip text"],[16,2,"-"],[16,3,"text"],[17,0,"Shape"],[17,1,"The shape of the node"],[17,2,"hexagon"],[17,3,"circle (●) | diamond (β—†) | hexagon (β¬’) | rect (β– ) | triangle (β–²) | star (β˜…)"],[18,0,"Size"],[18,1,"The size of the node"],[18,2,"20"],[18,3,"number"],[19,0,"Fill Color"],[19,1,"The fill color of the node in RGBA format (e.g. #FF000080 for a red node with 50% opacity)"],[19,2,"#C33D35"],[19,3,"rgba"],[20,0,"Border Size"],[20,1,"The stroke width"],[20,2,"1"],[20,3,"number"],[21,0,"Border Color"],[21,1,"The stroke color of the node in RGBA format"],[21,2,"-"],[21,3,"rgba"],[22,0,"Opacity"],[22,1,"The opacity of the node, applied to its fill and border (1 = opaque, 0 = transparent). Multiplies into the color’s own alpha, so it composes with an RGBA fill/border color."],[22,2,"1"],[22,3,"number"],[23,0,"Label Font Size"],[23,1,"Label font size"],[23,2,"12"],[23,3,"number"],[24,0,"Label Placement"],[24,1,"Label position relative to the main shape of the node"],[24,2,"bottom"],[24,3,"left | right | top | bottom | left-top | left-bottom | right-top | right-bottom | top-left | top-right | bottom-left | bottom-right | center"],[25,0,"Label Color"],[25,1,"The color of the nodes label"],[25,2,"-"],[25,3,"rgba"],[26,0,"Label Background Color"],[26,1,"Label background fill color"],[26,2,"-"],[26,3,"rgba"],[27,0,"X Coordinate"],[27,1,"The x coordinate of the node"],[27,2,"-"],[27,3,"number"],[28,0,"Y Coordinate"],[28,1,"The y coordinate of the node"],[28,2,"-"],[28,3,"number"],[29,0,"property A [group A]"],[29,1,"User-defined custom node properties"],[29,2,"-"],[29,3,"any"],[31,0,"Edge Properties Explained"],[31,1,"Description"],[31,2,"Default Value"],[31,3,"Type"],[32,0,"Source ID"],[32,1,"The ID of the source node"],[32,2,"-"],[32,3,"text"],[33,0,"Target ID"],[33,1,"The ID of the target node"],[33,2,"-"],[33,3,"text"],[34,0,"Label"],[34,1,"Label of the edge; if no Label is given, the edge is only visible as line without any text"],[34,2,"-"],[34,3,"text"],[35,0,"Description"],[35,1,"Description of the edge, displayed in the tooltip text"],[35,2,"-"],[35,3,"text"],[36,0,"Type"],[36,1,"The edge type"],[36,2,"line"],[36,3,"line | cubic | quadratic | polyline"],[37,0,"Line Width"],[37,1,"The border width of the edge"],[37,2,"0.75"],[37,3,"number"],[38,0,"Line Dash"],[38,1,"The dash offset of the edge line"],[38,2,"0"],[38,3,"number"],[39,0,"Color"],[39,1,"The stroke color of the edge in RGBA format"],[39,2,"#403C5390"],[39,3,"rgba"],[40,0,"Opacity"],[40,1,"The opacity of the edge (1 = opaque, 0 = transparent). Multiplies into the color’s own alpha, so it composes with an RGBA edge color."],[40,2,"1"],[40,3,"number"],[41,0,"Label Font Size"],[41,1,"The font size of the edges label"],[41,2,"12"],[41,3,"number"],[42,0,"Label Placement"],[42,1,"The position of the label relative to the edge"],[42,2,"center"],[42,3,"start | center | end"],[43,0,"Label Auto Rotate"],[43,1,"Whether to automatically rotate the label to match the edge’s direction"],[43,2,{"formula":"FALSE()"}],[43,3,"boolean"],[44,0,"Label Offset X"],[44,1,"The offset of the label on the X-Axis"],[44,2,"4"],[44,3,"number"],[45,0,"Label Offset Y"],[45,1,"The offset of the label on the Y-Axis"],[45,2,"0"],[45,3,"number"],[46,0,"Label Color"],[46,1,"The color of the edges label text"],[46,2,"#000000"],[46,3,"rgba"],[47,0,"Label Background Color"],[47,1,"The color for the edge label’s background"],[47,2,"-"],[47,3,"rgba"],[48,0,"Start Arrow"],[48,1,"Whether to display the start arrow on the edge"],[48,2,{"formula":"FALSE()"}],[48,3,"boolean"],[49,0,"Start Arrow Size"],[49,1,"The size of the start arrow"],[49,2,"8"],[49,3,"number"],[50,0,"Start Arrow Type"],[50,1,"The type of the start arrow"],[50,2,"arrow"],[50,3,"arrow | rect | diamond | circle | tee | triangle | vee | triangleRect | simple | square"],[51,0,"Start Arrow Color"],[51,1,"The fill color of the start arrow; inherits the edge color if unset"],[51,2,"-"],[51,3,"rgba"],[52,0,"Start Arrow Border Color"],[52,1,"The border color of the start arrow; no border if unset"],[52,2,"-"],[52,3,"rgba"],[53,0,"Start Arrow Border Size"],[53,1,"The border width of the start arrow (0 = auto, ~20% of the marker)"],[53,2,"0"],[53,3,"number"],[54,0,"End Arrow"],[54,1,"Whether to display the end arrow on the edge"],[54,2,{"formula":"FALSE()"}],[54,3,"boolean"],[55,0,"End Arrow Size"],[55,1,"The size of the end arrow"],[55,2,"8"],[55,3,"number"],[56,0,"End Arrow Type"],[56,1,"The type of the end arrow"],[56,2,"arrow"],[56,3,"arrow | rect | diamond | circle | tee | triangle | vee | triangleRect | simple | square"],[57,0,"End Arrow Color"],[57,1,"The fill color of the end arrow; inherits the edge color if unset"],[57,2,"-"],[57,3,"rgba"],[58,0,"End Arrow Border Color"],[58,1,"The border color of the end arrow; no border if unset"],[58,2,"-"],[58,3,"rgba"],[59,0,"End Arrow Border Size"],[59,1,"The border width of the end arrow (0 = auto, ~20% of the marker)"],[59,2,"0"],[59,3,"number"]],"st":{"A1":0,"B1":0,"C1":1,"D1":2,"A2":3,"B2":2,"C2":1,"D2":2,"A3":4,"B3":2,"C3":1,"D3":2,"A4":5,"B4":2,"C4":1,"D4":2,"A5":2,"B5":2,"C5":1,"D5":2,"A6":0,"B6":0,"C6":6,"D6":7,"A7":8,"B7":2,"C7":1,"D7":2,"A8":8,"B8":2,"C8":1,"D8":2,"A9":8,"B9":2,"C9":1,"D9":2,"A10":8,"B10":2,"C10":1,"D10":2,"A11":9,"B11":2,"C11":1,"D11":2,"A12":8,"B12":2,"C12":1,"D12":2,"A13":2,"B13":2,"C13":1,"D13":2,"A14":10,"B14":10,"C14":11,"D14":10,"A15":3,"B15":2,"C15":1,"D15":2,"A16":4,"B16":2,"C16":1,"D16":2,"A17":4,"B17":2,"C17":1,"D17":2,"A18":4,"B18":2,"C18":1,"D18":12,"A19":4,"B19":2,"C19":1,"D19":2,"A20":4,"B20":2,"C20":1,"D20":2,"A21":4,"B21":2,"C21":1,"D21":2,"A22":4,"B22":2,"C22":1,"D22":2,"A23":4,"B23":2,"C23":1,"D23":2,"A24":4,"B24":2,"C24":1,"D24":2,"A25":4,"B25":2,"C25":1,"D25":12,"A26":4,"B26":2,"C26":1,"D26":2,"A27":4,"B27":2,"C27":1,"D27":2,"A28":4,"B28":2,"C28":1,"D28":2,"A29":4,"B29":2,"C29":1,"D29":2,"A30":5,"B30":2,"C30":1,"D30":2,"A31":2,"B31":2,"C31":1,"D31":2,"A32":10,"B32":10,"C32":11,"D32":10,"A33":3,"B33":2,"C33":1,"D33":2,"A34":3,"B34":2,"C34":1,"D34":2,"A35":4,"B35":2,"C35":1,"D35":2,"A36":4,"B36":2,"C36":1,"D36":2,"A37":4,"B37":2,"C37":1,"D37":12,"A38":4,"B38":2,"C38":1,"D38":2,"A39":4,"B39":2,"C39":1,"D39":2,"A40":4,"B40":2,"C40":1,"D40":2,"A41":4,"B41":2,"C41":1,"D41":2,"A42":4,"B42":2,"C42":1,"D42":2,"A43":4,"B43":2,"C43":1,"D43":12,"A44":4,"B44":2,"C44":13,"D44":2,"A45":4,"B45":2,"C45":1,"D45":2,"A46":4,"B46":2,"C46":1,"D46":2,"A47":4,"B47":2,"C47":1,"D47":2,"A48":4,"B48":2,"C48":13,"D48":2,"A49":4,"B49":2,"C49":13,"D49":2,"A50":4,"B50":2,"C50":1,"D50":2,"A51":4,"B51":2,"C51":13,"D51":12,"A52":4,"B52":2,"C52":13,"D52":12,"A53":4,"B53":2,"C53":13,"D53":12,"A54":4,"B54":2,"C54":1,"D54":12,"A55":4,"B55":2,"C55":13,"D55":2,"A56":4,"B56":2,"C56":1,"D56":14,"A57":4,"B57":2,"C57":13,"D57":12,"A58":4,"B58":2,"C58":13,"D58":12,"A59":4,"B59":2,"C59":13,"D59":12,"A60":4,"B60":2,"C60":1,"D60":12},"dim":[60,4]},"nodes":{"d":[[0,0,"ID"],[0,1,"Label"],[0,2,"Description"],[0,3,"Shape"],[0,4,"Size"],[0,5,"Fill Color"],[0,6,"Border Color"],[0,7,"Feature X [group A]"],[0,8,"Feature Y [nm] [group A]"],[0,9,"Feature Z [group B]"],[1,0,"A"],[1,1,"Node 1"],[1,2,"The first node"],[1,3,"circle"],[1,4,"60"],[1,5,"#403C53"],[1,6,"#C33D35"],[1,7,"1"],[1,8,"foo"],[1,9,"1"],[2,0,"B"],[2,1,"Node 2"],[2,2,"The second node"],[2,7,"0.5"],[2,8,"foo"],[2,9,"2"],[3,0,"C"],[3,1,"Node 3"],[3,2,"The third node"],[3,7,"1.1"],[3,8,"foo"],[3,9,"1"],[4,0,"D"],[4,1,"Node 4"],[4,2,"The fourth node"],[4,7,"1.3"],[4,8,"bar"],[4,9,"0"],[5,0,"E"],[5,7,"0"],[5,8,"bar"],[5,9,"-1"],[6,0,"F"],[6,1,"Lonely Node"],[6,7,"-1"]],"st":{"A1":3,"B1":4,"C1":4,"D1":4,"E1":4,"F1":4,"G1":4,"H1":5,"I1":5,"J1":5,"A2":15,"B2":16,"C2":16,"D2":16,"E2":16,"F2":16,"G2":16,"H2":17,"I2":17,"J2":17,"A3":15,"B3":16,"C3":16,"D3":16,"E3":16,"F3":16,"G3":16,"H3":17,"I3":17,"J3":17,"A4":15,"B4":16,"C4":16,"D4":16,"E4":16,"F4":16,"G4":16,"H4":17,"I4":17,"J4":17,"A5":15,"B5":16,"C5":16,"D5":16,"E5":16,"F5":16,"G5":16,"H5":17,"I5":17,"J5":17,"A6":15,"B6":16,"C6":16,"D6":16,"E6":16,"F6":16,"G6":16,"H6":17,"I6":17,"J6":17,"A7":15,"B7":16,"C7":16,"D7":16,"E7":16,"F7":16,"G7":16,"H7":17,"I7":17,"J7":17},"dim":[7,10]},"edges":{"d":[[0,0,"Source ID"],[0,1,"Target ID"],[0,2,"Color"],[0,3,"Line Width"],[0,4,"Label"],[0,5,"Feature EX [group X]"],[0,6,"Feature EY [group X]"],[0,7,"Feature EZ [group Y]"],[1,0,"A"],[1,1,"B"],[1,2,"#FF0000"],[1,3,"0.75"],[1,4,"foo"],[1,5,"1"],[1,6,"Dummy Category 1"],[1,7,"1"],[2,0,"A"],[2,1,"C"],[2,5,"0.5"],[2,6,"Dummy Category 2"],[2,7,"2"],[3,0,"C"],[3,1,"D"],[3,5,"1.1"],[3,6,"Dummy Category 3"],[3,7,"1"],[4,0,"D"],[4,1,"E"],[4,5,"1.3"],[4,6,"Dummy Category 4"],[4,7,"0"]],"st":{"A1":3,"B1":3,"C1":4,"D1":4,"E1":4,"F1":5,"G1":5,"H1":5,"A2":15,"B2":15,"C2":16,"D2":16,"E2":16,"F2":17,"G2":17,"H2":17,"A3":15,"B3":15,"C3":16,"D3":16,"E3":16,"F3":17,"G3":17,"H3":17,"A4":15,"B4":15,"C4":16,"D4":16,"E4":16,"F4":17,"G4":17,"H4":17,"A5":15,"B5":15,"C5":16,"D5":16,"E5":16,"F5":17,"G5":17,"H5":17},"dim":[5,8]}},"st":{"0":{"f":{"b":1,"sz":12,"n":"Arial"},"fill":{"fg":"E4E3EA","bg":"FEFFE1"},"b":{"t":["thin","000000"],"b":["thin","000000"],"l":["thin","000000"],"r":["thin","000000"]},"a":{"v":"bottom"}},"1":{"f":{"sz":10,"n":"Arial"},"fill":{"p":"none"},"a":{"h":"left","v":"bottom"}},"2":{"f":{"sz":10,"n":"Arial"},"fill":{"p":"none"},"a":{"v":"bottom"}},"3":{"f":{"b":1,"sz":10,"n":"Arial"},"fill":{"fg":"FF9A9A","bg":"FF8080"},"b":{"t":["thin","000000"],"b":["thin","000000"],"l":["thin","000000"],"r":["thin","000000"]},"a":{"v":"bottom"}},"4":{"f":{"b":1,"sz":10,"n":"Arial"},"fill":{"fg":"FEFFE1","bg":"FFFFFF"},"b":{"t":["thin","000000"],"b":["thin","000000"],"l":["thin","000000"],"r":["thin","000000"]},"a":{"v":"bottom"}},"5":{"f":{"b":1,"sz":10,"n":"Arial"},"fill":{"fg":"81D41A","bg":"969696"},"b":{"t":["thin","000000"],"b":["thin","000000"],"l":["thin","000000"],"r":["thin","000000"]},"a":{"v":"bottom"}},"6":{"f":{"b":1,"sz":12,"n":"Arial"},"fill":{"p":"none"},"a":{"h":"left","v":"bottom"}},"7":{"f":{"b":1,"sz":12,"n":"Arial"},"fill":{"p":"none"},"a":{"v":"bottom"}},"8":{"f":{"b":1,"sz":10,"n":"Arial"},"fill":{"fg":"E4E3EA","bg":"FEFFE1"},"b":{"t":["thin","000000"],"b":["thin","000000"],"l":["thin","000000"],"r":["thin","000000"]},"a":{"v":"bottom"}},"9":{"f":{"b":1,"i":1,"sz":10,"n":"Arial"},"fill":{"fg":"E4E3EA","bg":"FEFFE1"},"b":{"t":["thin","000000"],"b":["thin","000000"],"l":["thin","000000"],"r":["thin","000000"]},"a":{"v":"bottom"}},"10":{"f":{"b":1,"sz":12,"n":"Arial"},"fill":{"fg":"E4E3EA","bg":"FEFFE1"},"b":{"t":["thin","000000"],"b":["thin","000000"]},"a":{"v":"bottom"}},"11":{"f":{"b":1,"sz":12,"n":"Arial"},"fill":{"fg":"E4E3EA","bg":"FEFFE1"},"b":{"t":["thin","000000"],"b":["thin","000000"]},"a":{"h":"left","v":"bottom"}},"12":{"f":{"i":1,"sz":10,"n":"Arial"},"fill":{"p":"none"},"a":{"v":"bottom"}},"13":{"f":{"sz":10,"n":"Arial"},"fill":{"p":"none"},"a":{"h":"left","v":"bottom"},"nf":"\"TRUE\";\"TRUE\";\"FALSE\""},"14":{"f":{"u":1,"sz":10,"n":"Arial"},"fill":{"p":"none"},"a":{"v":"bottom"}},"15":{"f":{"sz":10,"n":"Arial"},"fill":{"fg":"FF9A9A","bg":"FF8080"},"b":{"t":["thin","000000"],"b":["thin","000000"],"l":["thin","000000"],"r":["thin","000000"]},"a":{"v":"bottom"}},"16":{"f":{"sz":10,"n":"Arial"},"fill":{"fg":"FEFFE1","bg":"FFFFFF"},"b":{"t":["thin","000000"],"b":["thin","000000"],"l":["thin","000000"],"r":["thin","000000"]},"a":{"v":"bottom"}},"17":{"f":{"sz":10,"n":"Arial"},"fill":{"fg":"81D41A","bg":"969696"},"b":{"t":["thin","000000"],"b":["thin","000000"],"l":["thin","000000"],"r":["thin","000000"]},"a":{"v":"bottom"}}},"sc":18}; // @formatter:on // The following constants define the columns in the Excel template for mapping node and edge properties @@ -903,6 +120,16 @@ const EXCEL_NODE_PROPERTIES = [ return n.style.stroke; }, }, + { + column: 'Opacity', + type: 'num', + apply: (n, v) => { + n.style.opacity = v; + }, + get: (n) => { + return n.style.opacity; + }, + }, { column: 'Label Font Size', type: 'num', @@ -1053,6 +280,16 @@ const EXCEL_EDGE_PROPERTIES = [ return e.style.stroke; }, }, + { + column: 'Opacity', + type: 'num', + apply: (e, v) => { + e.style.opacity = v; + }, + get: (e) => { + return e.style.opacity; + }, + }, { column: 'Label Font Size', type: 'num', diff --git a/src/managers/ui_style_div.js b/src/managers/ui_style_div.js index 4b32e53..1d14549 100644 --- a/src/managers/ui_style_div.js +++ b/src/managers/ui_style_div.js @@ -133,6 +133,9 @@ function createStyleDiv(cache) { case "Node Border Color": await cache.gcm.updateNodes({style: {stroke: value}}, commands); break; + case "Node Opacity": + await cache.gcm.updateNodes({style: {opacity: value}}, commands); + break; case "Node Label Color": await cache.gcm.updateNodes({style: {labelFill: value}}, commands); break; @@ -151,6 +154,9 @@ function createStyleDiv(cache) { case "Edge Width": await cache.gcm.updateEdges({style: {lineWidth: value}}, commands); break; + case "Edge Opacity": + await cache.gcm.updateEdges({style: {opacity: value}}, commands); + break; case "Edge Dash": await cache.gcm.updateEdges({style: {lineDash: value}}, commands); break; @@ -877,6 +883,14 @@ function createStyleDiv(cache) { createColorControls(rowFive, "Node Border Color", cache.DEFAULTS.NODE.STROKE_COLOR, cache.DEFAULTS.STYLES.NODE_BORDER_COLORS); + const rowFiveOpacity = createNewRow(nodeDiv); + appendLabel(rowFiveOpacity, "Opacity", + "Transparency of the selected nodes (1 = opaque, 0 = invisible). Composes with the fill/border color's own alpha."); + createNumericalSlider(rowFiveOpacity, "Node Opacity", cache.DEFAULTS.NODE.OPACITY, + {min: 0, max: 1, step: 0.05}, + "Transparency of the selected nodes (1 = opaque, 0 = invisible). Composes with the fill/border color's own alpha.", + true); + appendHorizontalRule(nodeDiv); const rowSix = createNewRow(nodeDiv); @@ -944,6 +958,14 @@ function createStyleDiv(cache) { appendLabel(rowFour, "Color", "Define the selected edges color."); createColorControls(rowFour, "Edge Color", cache.DEFAULTS.EDGE.COLOR, cache.DEFAULTS.STYLES.EDGE_COLORS); + const rowFourOpacity = createNewRow(edgeDiv); + appendLabel(rowFourOpacity, "Opacity", + "Transparency of the selected edges (1 = opaque, 0 = invisible). Composes with the edge color's own alpha."); + createNumericalSlider(rowFourOpacity, "Edge Opacity", cache.DEFAULTS.EDGE.OPACITY, + {min: 0, max: 1, step: 0.05}, + "Transparency of the selected edges (1 = opaque, 0 = invisible). Composes with the edge color's own alpha.", + true); + appendHorizontalRule(edgeDiv); const rowFive = createNewRow(edgeDiv); diff --git a/src/utilities/numeric_scale_picker.js b/src/utilities/numeric_scale_picker.js index b286a1a..cc3de22 100644 --- a/src/utilities/numeric_scale_picker.js +++ b/src/utilities/numeric_scale_picker.js @@ -138,6 +138,10 @@ class NumericScalePicker { this.minOutput = defaults.LABEL.FONT_SIZE; this.maxOutput = 30; break; + case 'Node Opacity': + this.minOutput = 0.2; + this.maxOutput = defaults.OPACITY; + break; default: this.minOutput = 0; this.maxOutput = 100; @@ -161,6 +165,10 @@ class NumericScalePicker { this.minOutput = defaults.HALO.WIDTH; this.maxOutput = 10; break; + case 'Edge Opacity': + this.minOutput = 0.2; + this.maxOutput = defaults.OPACITY; + break; default: this.minOutput = 0; this.maxOutput = 100; diff --git a/templates/simple-template.xlsx b/templates/simple-template.xlsx index 5a079bc..879429d 100644 Binary files a/templates/simple-template.xlsx and b/templates/simple-template.xlsx differ diff --git a/tests/excel-style-roundtrip.test.js b/tests/excel-style-roundtrip.test.js index e2d295c..d15bd3d 100644 --- a/tests/excel-style-roundtrip.test.js +++ b/tests/excel-style-roundtrip.test.js @@ -148,6 +148,20 @@ describe("Excel style round-trip β€” nodes", () => { expect(reimported.type).toBe(DEFAULTS.NODE.TYPE); expect(reimported.style).toEqual(original.style); }); + + it("a column-less node defaults opacity to fully opaque", () => { + expect(importNode({ "ID": "n1" }).style.opacity).toBe(DEFAULTS.NODE.OPACITY); + }); + + it("Opacity column survives the round-trip and fades the sigma fill", () => { + const original = importNode({ "ID": "n1", "Shape": "circle", "Fill Color": "#403C53", "Opacity": "0.5" }); + const row = exportToRow(original, EXCEL_NODE_PROPERTIES); + + expect(row["Opacity"]).toBe(0.5); + const reimported = importNode(row); + expect(reimported.style.opacity).toBe(0.5); + expect(nodeAttributesFromStyle(reimported.style, reimported.type).color).toBe("#403c5380"); + }); }); describe("Excel style round-trip β€” edges", () => { @@ -227,4 +241,19 @@ describe("Excel style round-trip β€” edges", () => { expect(reimported.type).toBe(DEFAULTS.EDGE.TYPE); expect(reimported.style).toEqual(original.style); }); + + it("a column-less edge defaults opacity to fully opaque", () => { + expect(importEdge({ "Source ID": "A", "Target ID": "B" }).style.opacity).toBe(DEFAULTS.EDGE.OPACITY); + }); + + it("Opacity column survives the round-trip and composes with the color alpha", () => { + const original = importEdge({ "Source ID": "A", "Target ID": "B", "Color": "#403C5390", "Opacity": "0.5" }); + const row = exportToRow(original, EXCEL_EDGE_PROPERTIES); + + expect(row["Opacity"]).toBe(0.5); + const reimported = importEdge(row); + expect(reimported.style.opacity).toBe(0.5); + // 0x90 (144) Γ— 0.5 = 72 = 0x48. + expect(edgeAttributesFromStyle(reimported.style, reimported.type).color).toBe("#403c5348"); + }); }); diff --git a/tests/export-svg.test.js b/tests/export-svg.test.js index 95c5795..06f66ae 100644 --- a/tests/export-svg.test.js +++ b/tests/export-svg.test.js @@ -564,6 +564,26 @@ describe("bubble groups", () => { expect(svg).toContain(' { + const scene = makeScene({ + nodes: [{ id: "a", x: 1, y: 1, label: "NodeLbl" }], + edges: [], + }); + + const svg = build(scene, { + bubbleGroups: [ + { group: "g", points: SQUARE, opts: { fill: "#e74c3c", stroke: "#111111" }, defaults: {} }, + ], + }); + + const nodeAt = svg.indexOf("NodeLbl"); + expect(nodeAt).toBeGreaterThanOrEqual(0); + expect(nodeAt).toBeLessThan(bodyAt); + expect(bodyAt).toBeLessThan(labelAt); + }); + it("skips groups with fewer than 2 outline points", () => { const scene = makeScene(); diff --git a/tests/graph-model.test.js b/tests/graph-model.test.js index d141889..1951098 100644 --- a/tests/graph-model.test.js +++ b/tests/graph-model.test.js @@ -632,6 +632,76 @@ describe("attribute mapping helpers", () => { }); }); +describe("element opacity β†’ color alpha mapping", () => { + const TRANSPARENT = "#00000000"; + + it("node opacity fades the native circle/square fill", () => { + expect(nodeAttributesFromStyle({ fill: "#403c53", opacity: 0.5 }, "circle").color).toBe( + "#403c5380", + ); + expect(nodeAttributesFromStyle({ fill: "#403c53", opacity: 0.5 }, "rect").color).toBe( + "#403c5380", + ); + }); + + it("node opacity fades both the texture fill and the baked-in border", () => { + const attrs = nodeAttributesFromStyle( + { fill: "#403c53", stroke: "#123456", lineWidth: 2, opacity: 0.5, size: 20 }, + "hexagon", + ); + // Texture program: real color lives in fillColor + the SVG image, the + // quad color stays transparent. + expect(attrs.type).toBe("shape"); + expect(attrs.color).toBe(TRANSPARENT); + expect(attrs.fillColor).toBe("#403c5380"); + expect(attrs.borderColor).toBe("#12345680"); + expect(attrs.image).toContain(encodeURIComponent("#403c5380")); + expect(attrs.image).toContain(encodeURIComponent("#12345680")); + }); + + it("node opacity fades the bordered-circle fill and border ring", () => { + const attrs = nodeAttributesFromStyle( + { fill: "#403c53", stroke: "#123456", lineWidth: 2, opacity: 0.5 }, + "circle", + ); + expect(attrs.type).toBe("borderCircle"); + expect(attrs.color).toBe("#403c5380"); + expect(attrs.borderColor).toBe("#12345680"); + }); + + it("edge opacity folds into the stroke color alpha and composes with existing alpha", () => { + // Opaque stroke gains an alpha pair. + expect(edgeAttributesFromStyle({ stroke: "#403c53", opacity: 0.5 }, "line").color).toBe( + "#403c5380", + ); + // A stroke that already carries alpha (the default #403C5390) multiplies: + // 0x90 (144) Γ— 0.5 = 72 = 0x48. + expect(edgeAttributesFromStyle({ stroke: "#403c5390", opacity: 0.5 }, "line").color).toBe( + "#403c5348", + ); + }); + + it("opacity of 1 or absent is the identity β€” no alpha rewrite (backward compatible)", () => { + expect(nodeAttributesFromStyle({ fill: "#403c53" }, "circle").color).toBe("#403c53"); + expect(nodeAttributesFromStyle({ fill: "#403c53", opacity: 1 }, "circle").color).toBe( + "#403c53", + ); + expect(edgeAttributesFromStyle({ stroke: "#403c5390" }, "line").color).toBe("#403c5390"); + expect(edgeAttributesFromStyle({ stroke: "#403c5390", opacity: 1 }, "line").color).toBe( + "#403c5390", + ); + }); + + it("opacity of 0 makes the element fully transparent", () => { + expect(nodeAttributesFromStyle({ fill: "#403c53", opacity: 0 }, "circle").color).toBe( + "#403c5300", + ); + expect(edgeAttributesFromStyle({ stroke: "#403c53", opacity: 0 }, "line").color).toBe( + "#403c5300", + ); + }); +}); + describe("edge marker + halo attribute mapping", () => { // The adapter applies updates via mergeEdgeAttributes, so the full // marker/halo set must be emitted on every type-bearing mapping β€” off diff --git a/tests/numeric-scale-picker.test.js b/tests/numeric-scale-picker.test.js new file mode 100644 index 0000000..72db9fa --- /dev/null +++ b/tests/numeric-scale-picker.test.js @@ -0,0 +1,34 @@ +import { describe, it, expect } from "vitest"; +import { NumericScalePicker } from "../src/utilities/numeric_scale_picker.js"; +import { DEFAULTS } from "../src/config.js"; + +// setDefaultOutputRange is pure logic: it maps the (elementType, propertyName) +// pair to the picker's default output range. The render layer (applyHexOpacity) +// clamps opacity to [0, 1] regardless, so these ranges are UI defaults only. +function rangeFor(elementType, propertyName) { + const picker = new NumericScalePicker({ DEFAULTS }); + picker.elementType = elementType; + picker.propertyName = propertyName; + picker.setDefaultOutputRange(); + return { min: picker.minOutput, max: picker.maxOutput }; +} + +describe("NumericScalePicker.setDefaultOutputRange", () => { + it("maps Node Opacity to a visible-to-opaque range", () => { + expect(rangeFor("nodes", "Node Opacity")).toEqual({ min: 0.2, max: DEFAULTS.NODE.OPACITY }); + }); + + it("maps Edge Opacity to a visible-to-opaque range", () => { + expect(rangeFor("edges", "Edge Opacity")).toEqual({ min: 0.2, max: DEFAULTS.EDGE.OPACITY }); + }); + + it("keeps the existing size ranges intact", () => { + expect(rangeFor("nodes", "Node Size")).toEqual({ min: DEFAULTS.NODE.SIZE, max: 50 }); + expect(rangeFor("edges", "Edge Width")).toEqual({ min: DEFAULTS.EDGE.LINE_WIDTH, max: 10 }); + }); + + it("falls back to the 0–100 default for unknown properties", () => { + expect(rangeFor("nodes", "Unknown")).toEqual({ min: 0, max: 100 }); + expect(rangeFor("edges", "Unknown")).toEqual({ min: 0, max: 100 }); + }); +});