Customer Banners (Ads) - SpiceUp. AX and SpotfireX Disclaimer



If you find this site useful and you want to support, buy me a coffee   to keep this site alive and without ads.

Tooltips for column headers

 Enable html tooltips with this simple script and power up your cross table, graphical table or data tables



(() => {
    let i = 0;
    let tooltipTimeout = null;

    // Tooltips dictionary with lowercase keys
    const tooltips = {
      'city': 'Another big city!',
      'state': 'The inner text or textContent for this item is "state"',
      'population': 'Population in millions<br>Look, we can use images!<br><img style="background:white" src="https://img.icons8.com/ios-filled/100/000000/crowd.png">',
      'country': 'Tooltip for country',
      'latitude': 'Geographical latitude.',
      'longitude': 'Geographical longitude.',
      'average temperature (f)': 'Average temperature data.',
      'air quality index': 'Current air quality index.',
      'observation date': 'Date of observation.',
      'bullet graph':'This bullet graph represnts the air quality index min and max'
    };

    const tooltip = document.createElement('div');
    tooltip.id = 'tooltip';
    tooltip.style.position = 'absolute';
    tooltip.style.backgroundColor = 'black';
    tooltip.style.color = 'white';
    tooltip.style.padding = '5px';
    tooltip.style.borderRadius = '5px'; 
    tooltip.style.fontSize = '12px';
    tooltip.style.visibility = 'hidden'; 
    tooltip.style.opacity = '0';
    tooltip.style.transition = 'opacity 0.2s';
    tooltip.style.pointerEvents = 'none'; // Ensures the tooltip doesn't trigger any events

    document.body.appendChild(tooltip);

    const hasColumnHeaderClass = (element) => {
      while (element) {
        if ([...element.classList].some(cls => cls.includes('column-header'))) {
          return true;
        }
        element = element.parentElement;
      }
      return false;
    };

    // Function to handle mouseover event
    const handleMouseOver = (event) => {
      const target = event.target;
      const textContent = target.textContent.trim().toLowerCase();

      // Clear any existing timeout to avoid overlapping timeouts
      if (tooltipTimeout) {
        clearTimeout(tooltipTimeout);
      }

      tooltipTimeout = setTimeout(() => {
        if (tooltips[textContent] && hasColumnHeaderClass(target)) {
          tooltip.innerHTML = tooltips[textContent];
          tooltip.style.visibility = 'visible';
          tooltip.style.opacity = '1';
          i++;
          console.log(i);
        } else {
          tooltip.style.visibility = 'hidden';
          tooltip.style.opacity = '0';
        }
      }, 800); // 0.8 seconds delay
    };

    // Function to handle mousemove event
    const handleMouseMove = (event) => {
      tooltip.style.left = `${event.pageX + 10}px`;
      tooltip.style.top = `${event.pageY + 10}px`;
    };

    // Function to handle mouseout event
    const handleMouseOut = () => {
      // Clear any existing timeout when mouse leaves
      if (tooltipTimeout) {
        clearTimeout(tooltipTimeout);
      }
      tooltip.style.visibility = 'hidden';
      tooltip.style.opacity = '0';
    };

    // Attach event handlers using 'on' properties
    document.body.onmouseover = handleMouseOver;
    document.body.onmousemove = handleMouseMove;
    document.body.onmouseout = handleMouseOut;
  })();

Range Slider

Convert a regular Spotfire Input field Property Control as a Range Slider in Spotfire text area



html

<div id='slider'>

   <SpotfireControl id="Input Field Property Control" />   <SpotfireControl id="label Property Control" />

</div>

js

(()=>{

  const slider=document.querySelector('#slider input');
  slider.type="range";

  slider.min = 10;
  slider.max = 50;
  slider.step = 0.5;

  slider.oninput  = () => {
    slider.blur();
    slider.focus();
  }

})()

Confirmation Dialog

Prompts user before execution of an Action Control that runs a Data Function or script



html

<div id="confirmExecute">

   <SpotfireControl id="2fe15f7fd4174c08b40c0ee27c5591c1" />

</div>

<div id="actualExecute" style="position:fixed;top:-100px">

   <SpotfireControl id="7475a74b91ae474c80bf4f979bd1f56a" />

</div>


JavaScript

//setup the first spotfire sleep button (that does nothing) show a confirm dialog
document.querySelector("#confirmExecute input").onclick=function(){
   Spotfire.ConfirmDialog.showYesNoCancelDialog("Confirm Execution","Do you really want to execute?",okClick,noClick,null)
}

//programatically click on spotfire control when confirming

okClick = function(x){
   document.querySelector("#actualExecute input").click();
}

//display a friendly message if
noClick = function(x){
//alert("OK, no worries")
    Spotfire.ConfirmDialog.showDialog("OK","No worries!",[])



Get Image Layer dimensions on Map Visualization

 Just uncheck the marker layer or remove the Positioning markers (geocoding or coordinate columns) from the marker layer, go back to the image layer and hit the reset button




Autocomplete

 Add autocomplete to an existing Spotfire input control (webplayer and cliente )


html

<div id="autocomplete"> 
<SpotfireControl id="spotfire_Input" />
</div>

<div id="autocomplete-data" hidden>
John Doe,Jane Smith,Robert Johnson,Michael Brown,Emily Davis,Sarah Miller,James Wilson,Patricia Moore,Richard Taylor,Linda Anderson
</div>

note: replace the autocomplete-csv-values with a uniqueConcatenate([column]) calculated value, but you can use as is to test the script



javascript

function setupAutocomplete(id){
    let currentFocus = -1;
    let isScriptTriggered = false;

    const autoCompleteDiv = document.getElementById(id);
    autoCompleteDiv.position = "relative";
    const spotfireInput = autoCompleteDiv.firstElementChild;
    const autocompleteInput = document.createElement('input');
    autocompleteInput.id = id + "Input";
    autocompleteInput.style.display = 'none';

    // Copy style from SpotfireControl to input
    for (let style in spotfireInput.style) {
        if (spotfireInput.style.hasOwnProperty(style) && spotfireInput.style[style]) {
            autocompleteInput.style[style] = spotfireInput.style[style];
        }
    }
    autoCompleteDiv.appendChild(autocompleteInput);

    //popup for autocomplete items
    const popup = document.createElement('div');
    popup.style.width = `${spotfireInput.offsetWidth}px`;

    popup.style.position = 'absolute';
    popup.style.zIndex = '100';
    popup.className = 'autocomplete-items';
    popup.style.display = 'none';
    autoCompleteDiv.appendChild(popup);

    //observer for csv values as it comes from a calculated value
    const csvObserver = new MutationObserver(() => {
        csvValues = document.getElementById(id + '-data').innerText.split(',');
    });

    csvObserver.observe(document.getElementById(id + '-data'), {
        childList: true,
        characterData: true,
        subtree: true
    });

    let csvValues = document.getElementById(id + '-data').innerText.split(',');


    function toggleInputDisplay() {
        spotfireInput.style.display = '';
        autocompleteInput.style.display = 'none';
        spotfireInput.value = autocompleteInput.value;
        isScriptTriggered = true;
        spotfireInput.focus();
        spotfireInput.blur();
        isScriptTriggered = false;

    }

    autocompleteInput.addEventListener('click', () => {
        autocompleteInput.value = "";
    });

    autocompleteInput.addEventListener('input', () => {
        const inputValue = autocompleteInput.value;
        const filteredValues = csvValues.filter(name => name.toLowerCase().includes(inputValue.toLowerCase()));
        popup.innerHTML = '';

        filteredValues.forEach(value => {
            const div = document.createElement('div');
            div.className = "sf-element-dropdown-list-item";
            div.innerHTML = value.replace(new RegExp(inputValue, 'gi'), match => "<span class='highlight'>" + match + "</span>");

            // Copy style from src input to div
            for (var style in autocompleteInput.style) {
                if (autocompleteInput.style.hasOwnProperty(style) && autocompleteInput.style[style]) {
                    div.style[style] = autocompleteInput.style[style];
                }
            }

            div.addEventListener('click', () => {
                autocompleteInput.value = value;
                popup.style.display = 'none';
                spotfireInput.value = value;
                currentFocus = -1;

                toggleInputDisplay();


            });
            popup.appendChild(div);
        });
        popup.style.display = filteredValues.length ? 'block' : 'none';
    });




    autocompleteInput.addEventListener('keydown', (e) => {
        const divs = popup.getElementsByTagName('div');
        if (e.keyCode == 40) { // Down arrow
            currentFocus++;
            if (currentFocus >= divs.length) currentFocus = 0;
        } else if (e.keyCode == 38) { // Up arrow
            currentFocus--;
            if (currentFocus < 0) currentFocus = divs.length - 1;
        } else if (e.keyCode == 13) { // Enter
            if (currentFocus > -1) {
                divs[currentFocus].click();
            } else {
                spotfireInput.value = autocompleteInput.value;
                isScriptTriggered = true;
                toggleInputDisplay();
                isScriptTriggered = false;
            }
        } else if (e.keyCode == 27) { // Escape key
            isScriptTriggered = true;
            toggleInputDisplay();
            isScriptTriggered = false;

        }
        for (let i = 0; i < divs.length; i++) {
            divs[i].classList.remove('over');
        }
        if (currentFocus > -1) {
            divs[currentFocus].classList.add('over');
        }
    });

    spotfireInput.addEventListener('focus', () => {
        if (isScriptTriggered) return;
        // When spotfireInput gets focus, hide it and show input
        spotfireInput.style.display = 'none';
        autocompleteInput.style.display = '';
        autocompleteInput.value = "";
        autocompleteInput.focus();
    });


    autocompleteInput.addEventListener('blur', () => {
        setTimeout(() => {
            popup.style.display = 'none';
            toggleInputDisplay();
        }, 200);
    });



    //styles
    const styleTag = `
    <style>

    #autocompleteInput{
        outline: none; 
    }

        .autocomplete-items{
        background-color: #fff;
        border:1px solid #c6c8cc;
        }

        .autocomplete-items div:hover, 
        .autocomplete-items div.over {
        color: #FFFFFF !important;
        background-color: #7289F9;
        cursor:default;
        }

        .highlight {
        font-weight: bold;
        text-decoration: underline;
        }
    </style>`

    autoCompleteDiv.insertAdjacentHTML('afterEnd', styleTag)

}

setupAutocomplete("autocomplete")




Preventing zoom when scrolling with the mouse wheel on visuals with zoom sliders

Here is how to prevent zoom on visualization that have zoom sliders on to zoom when scrolling the page down using the mouse wheel





preventMouseScrollFromZoom.js

divsWithZoomSliders = [...document.querySelectorAll(".sf-element.sf-element-visual")].filter(div => div.querySelector(".sf-element-zoom-slider"));
divsToDisable = divsWithZoomSliders.map(div => div.querySelector(".sf-element.sf-element-canvas-visualization"));
divsToDisable.forEach(div => {div.firstChild.addEventListener('wheel', function(event) {event.preventDefault()}, { passive: true })});


Highlight Visual based on Document Property

 When a document property changes, it highlights a visual. This can be useful for data analysis to pay close attention to visuals that require focus



html

<pre id=docPropValues xhidden>

 "Indexed price charter":"<SpotfireControl id="5858b9bd6d344a98ba87c742af3d9f05" />", 
 "Top holders by percent held":"<SpotfireControl id="96f46c37e0ab4731a43124c827f3956f" />",
 "Historical data":"<SpotfireControl id="5302059ba4724d1f8e45c6a1b95bcfe6" />",
 "Calendar quarter estimates":"<SpotfireControl id="21331969168d4e2fb600d4ed1e0004be" />"
}
</pre>


JavaScript

//finds visuals in which title contains visualTitle (use *= for contains, ^= starts with, $= ends with or = exact match)
elements = Array.from(document.querySelectorAll(".sf-element.sf-element-visual"));
function highlighVisual(visualTitle){

//set background for those visuals found
elementWithChild = elements.filter(element => element.querySelector(
[title*='"+visualTitle+"']") !== null); //<-- change here for search operator
elementWithChild.forEach(x=>x.style.background="red")
}


element = document.querySelector('#docPropValues'); 

observer = new MutationObserver(_ => {
json = JSON.parse(element.innerText);

//reset visual backgrounds
elements.forEach(x=>{x.style.background=""})
Object.entries(json)
.filter(([key, value]) => value === "True")
.map(([key, value]) => key)
.forEach(visualTitle => {highlighVisual(visualTitle)});
});

observer.observe(element, {
    childList: true,
    characterData: true,
    subtree: true
});

IronPython Show/Hide Items

from Spotfire.Dxp.Application.Visuals import BarChart
barChart = vis.As[BarChart]()

#get filter rules
gotFilerRules,filterRuleCollection  = barChart.TryGetFilterRules()

#delete all filter rules
if gotFilerRules:
for filterRule in filterRuleCollection:
filterRuleCollection.Remove(filterRule)
#print filterRule.DisplayName, filterRule.Expression

#add a filter rule collection to show top 5 axis values
#filterRuleCollection.AddTopNRule("THEN [Y.FinalValue]",5,True) 



Create trellised visualizations based on marking



from Spotfire.Dxp.Application.Visuals import BarChart, VisualContent, VisualTypeIdentifiers, LabelOrientation, BarChartOrientation  

from Spotfire.Dxp.Application.Layout import LayoutDefinition

#script params
dataTable = Document.Data.Tables["Best predictors"]

#delete all barchart visuals
page = Document.ActivePageReference
for vis in page.Visuals:
if vis.TypeId == VisualTypeIdentifiers.BarChart:
page.Visuals.Remove(vis)

#The last visual left should be the treemap
tm = next(iter(page.Visuals))

#create a barchart template
bc = Application.Document.ActivePageReference.Visuals.AddNew[BarChart]()
bc.Data.DataTableReference = dataTable
bc.Title = "${DataTable.DisplayName}"
bc.Legend.Visible= False
bc.YAxis.Expression = "Sum([p-value])"
bc.XAxis.Expression = "<[Case Name]>"
bc.SortedBars=True
bc.Orientation = BarChartOrientation.Horizontal

#duplicate as many barcharts as selected sites from marking

siteNames = Document.Properties["markedSites"]
sites = [s.strip() for s in siteNames.split(',')]

#setup first barchart
firstSite = sites.pop()
bc.Title = firstSite
siteVisuals = [bc]

bc.Data.WhereClauseExpression = "[Site_No] = '"+firstSite+"'"

#create visuals
for site in sites:
vis = page.Visuals.AddDuplicate(bc.Visual)
vis.Title =site
bc = vis.As[BarChart]()
bc.Data.WhereClauseExpression = "[Site_No] = '"+site+"'"
siteVisuals.append(vis.As[BarChart]())

#arrange visuals
#tm is the existing treemap and will take 10% of the screen
ld = LayoutDefinition()
ld.BeginSideBySideSection()
ld.Add(tm, 10)

# Begin a stacked section for the second column at 70% of the screen
ld.BeginStackedSection(70)

i = 0
for bc in siteVisuals:
    if i % 3 == 0:
        if i > 0: ld.EndSection()
        ld.BeginSideBySideSection()
    ld.Add(bc.Visual)
    i += 1
ld.EndSection()
ld.EndSection()
ld.EndSection()

page.ApplyLayout(ld)


To trigger this script when marking changes, create a bypass data function. The script definition is simply an 'x' and so is the input and output. Make sure it runs automatically. The script parameters for the 'x' input is "UniqueConcatenate([Explore_YieldData - Explore_YieldData].[Site])" limited by the blue Marking. The output is a document property called "markedSites" that must be setup to trigger the above script when its value changes.

ColorPicker



html

<span id="color">
   <SpotfireControl id="Input Filed goes here" />
</span>




ColorPicker.js

colorInput = document.querySelector("#color input")
colorInput.type="color"




Here is a use case on how to use it. 

changeColor.py
from Spotfire.Dxp.Application.Visuals import CombinationChart, CategoryKey
from System.Drawing import Color

# cast visual to combo chart
combinationChart = vis.As[CombinationChart]()

# get script parameters from doc props
category = CategoryKey("Banana") 

# get the color from the color picker doc prop
hcolor = Document.Properties["color"] #hexadecimal color
color = Color.FromArgb(int(hcolor[1:3], 16), int(hcolor[3:5], 16), int(hcolor[5:7], 16))


# change the color for the corresponding category
combinationChart.ColorAxis.Coloring.SetColorForCategory(category,color)


Change Series on Combination Chart






Each document property triggers the corresponding iron python script when the value changes



for the category doc prop: reads the combination chart color and series type from the selected category

from Spotfire.Dxp.Application.Visuals import CombinationChart, CategoryKey

#cast visual to combo chart
combinationChart = vis.As[CombinationChart]()
category = CategoryKey(Document.Properties["category"])

#get type
Document.Properties["type"]  = str(combinationChart.IndexedSeriesType[category])

#get color
import re
color = str(combinationChart.ColorAxis.Coloring.GetColorForCategory(category))
color = re.findall(r"\[([^\[\]]+)\]", color)[0]
Document.Properties["color"] = color.lower()


for the color doc prop: changes the color for the selected category

from Spotfire.Dxp.Application.Visuals import CombinationChart, CategoryKey
from System.Drawing import Color

#cast visual to combo chart
combinationChart = vis.As[CombinationChart]()

#get script parameters from doc props
category = CategoryKey(Document.Properties["category"]) #string representing a category from the series

color = Color.FromName(Document.Properties["color"]) #named color such as blue, green, magenta, beige...

#color = Color.FromArgb(255,0,0) #if you know the rgb values
#if hexadecimal color (hc) comes from a color picker (#FF0000)
#color = Color.FromArgb(int(hc[1:3], 16), int(hc[3:5], 16), int(hc[5:7], 16)) 




# change the color for the corresponding category
combinationChart.ColorAxis.Coloring.SetColorForCategory(category,color)



and for the type dropdown that changes the series type for the selected category:

from Spotfire.Dxp.Application.Visuals import CombinationChart, CombinationChartSeriesType, CategoryKey

#cast visual to combo chart
combinationChart = vis.As[CombinationChart]()

#get script parameters from doc props
category = CategoryKey(Document.Properties["category"])

#string representing a category from the series
type = CombinationChartSeriesType.Bar if Document.Properties["type"] == "Bar" else CombinationChartSeriesType.Line 

# change series type as Bar or line
combinationChart.IndexedSeriesType[category] = type




Reset Visible Filters

#1. Take the filter panel from the desired page in filter panel
#filterPanel = Document.Pages[1].FilterPanel
filterPanel = Document.ActivePageReference.FilterPanel

#1.1 Select the filtering scheme to use (optional)
#filterringScheme = Document.FilteringSchemes[Document.Data.Filterings["Filtering scheme"]]
#filterPanel.FilteringSchemeReference = filteringScheme 

#2. Reset only visible filters
for tableGroup in filterPanel.TableGroups:
   for filterHandle in tableGroup.FilterHandles:
      if filterHandle.Visible:
         filterHandle.FilterReference.Reset()


Sync zoom sliders

 sync the zoom sliders to the exact same range between different visuals.


The style attribute of the activeVisual tag hides the input visibility. It has an input property control that holds the visual title. This title is updated by the javascript that takes the last active visual on mouse over. This property control is then passed as a parameter for the sync.ip to take the last active visual as a reference.

html

<div style="position:fixed;left:-1000px" id="activeVisual">
   <SpotfireControl id="input button" />
</div>
<br>
<span id="syncBtn">
   <SpotfireControl id="replace with button to run sync.ip" />
</span>
 <SpotfireControl id="optional reset button that runs reset.ip" />

sync.ip

from Spotfire.Dxp.Application.Visuals import AxisRange, ScatterPlot

# We detect which visual is "active"
sourceVisualTitle = Document.Properties["activeVisualTitle"]

visX = None

# Iterate through all visuals on the active page
for visual in Document.ActivePageReference.Visuals:
    if visual.Title == sourceVisualTitle:
visX = visual

# We need to cast the visual script parameters visA, visB and visC to ScatterPlot object or whatever visual you are using in your analysis

scatterX = visX.As[ScatterPlot]()
scatterA = visA.As[ScatterPlot]()
scatterB = visB.As[ScatterPlot]()
scatterC = visC.As[ScatterPlot]()

# We create a reference to the Y axis ZoomRange from the first visual (A)
zoomXy = scatterX.YAxis.ZoomRange
 

# We need to create an AxisRange object based on visual X range settings for Y axis
axisRangeX = AxisRange(zoomXy.Low, zoomXy.High)

# Apply scatterA,B and C to the selected axisRange
scatterA.YAxis.ZoomRange = axisRangeX
scatterB.YAxis.ZoomRange = axisRangeX
scatterC.YAxis.ZoomRange = axisRangeX


reset.ip

from Spotfire.Dxp.Application.Visuals import AxisRange, ScatterPlot

# We need to cast the visual parameters visA, visB and visC to ScatterPlot object or whatever visual you are using in your analysis

scatterA = visA.As[ScatterPlot]()
scatterB = visB.As[ScatterPlot]()
scatterC = visC.As[ScatterPlot]()

#reset scatterA,B and C ranges
scatterA.YAxis.ZoomRange=AxisRange.DefaultRange
scatterB.YAxis.ZoomRange=AxisRange.DefaultRange
scatterC.YAxis.ZoomRange=AxisRange.DefaultRange


js

function getActiveVisual(){
 vis = document.querySelector(".sfpc-active .sf-element-visual-title").innerText.trim();
 inp = document.querySelector("#activeVisual input");
 inp.value = vis;
 inp.focus();
 inp.blur();
}

document.getElementById("syncBtn").onmouseover = getActiveVisual

Loop through pages



html

<span id="startButton" style="cursor:default">[Start]</span>
<span id="stopButton" style="cursor:pointer">[Stop]</span>

JavaScript

//parameters
var pages = [0, 1, 3];   //◄ Select the pages you want to cycle through. 0 is the first one
var timeout = 10000;      //◄ Select the time in ms to delay between pages. 10000 is 10 seconds

//maintain a registry of interval in case multiple are triggered to stop them all at once with the stop button

window["intervalIds"]??=[];
tmp=[...pages]

function startCycle() {
(function cycle(){
page = tmp.shift();
 
if(!tmp.length) tmp=[...pages]
goToPage(page);
window.intervalIds.push(setTimeout(cycle, timeout));
})();
}


function stopCycle() {
  console.log("Slideshow Ended")
  window.intervalIds.forEach(clearInterval);
}


function goToPage(x) {
  document.querySelectorAll(".sf-element-page-tab")[x].click();
}


// Hook html buttons to start and stop functions
document.getElementById("startButton").onclick = startCycle;
document.getElementById("stopButton").onclick = stopCycle;

List table columns

# Replace with the actual table name
table_name = "Data Table"  

# Get the table by name
table = Document.Data.Tables[table_name]

# Iterate through the columns and add their names to the list
for column in table.Columns:
print (column.Name)


# Iterate through all columns and add their column names and data types
for t in Document.Data.Tables:
   print t.Name
   columnCollection=t.Columns
   for col in columnCollection:
      print "\t", col.Name, "(",col.Properties.DataType,")"