Musson Industrial's Embr-Periscope Module

Embr-Periscope

An Ignition module that includes design extensions and enhancements for Perspective.

A periscope provides a new perspective by allowing users to see over, around, or through obstacles, offering a unique view otherwise obstructed.

NOTE: This modules is still in pre-release. All versions before v1.0.0 should be considered unstable.

Components

Embedded View +

This component is a enhancement of the default Embedded View component with server-side property mapping. In testing, this reduced property transit time on a locally hosted gateway from ~100ms per view layer to ~0.220ms.

Flex Repeater +

This component is a enhancement of the default Flex Repeater component with server-side property mapping.

Additionally, it supports per-instance view paths, sharing of common parameters into all views, manually specified instance keys, and component functions for removing, adding, and inserting instances.

Swiper

Swiper is the most modern free and open source mobile touch slider with hardware accelerated transitions and amazing native behavior.

This component merges Swiper's functionality with that of a Flex Repeater.

Docs

https://docs.mussonindustrial.com/ignition/embr-periscope

Sponsorship

If you benefit from this module for commercial use, we ask you to consider sponsoring our efforts through GitHub sponsors . Your support will allow us to continue development of open-source modules and tools that benefit the entire community (plus there are some bonuses for sponsors :slightly_smiling_face:).

Download

Embr-Periscope-0.4.1.modl
The latest release can always been found on GitHub:

17 Likes

Here's a gun, don't shoot yourself with it. This uses the same function constructor as my Chart.js module.

system.perspective.runJavaScriptBlocking(function, [args])

Runs a JavaScript function in the client's page and returns the result, blocking for the result. function is a literal string containing a single arrow function. args is an optional dictionary, where the keys correspond to the names of the function arguments.

system.perspective.runJavaScriptAsync(function, [args], [callback])

Same as above, but calls a callback with the result (non-blocking).


Examples

Blocking Example

# Button [onActionPerformed]
def runAction(self, event):
	code = '''(count) =>
	console.log(`The current count is ${count}`);
	return count + 1;
	'''
	
	count = 0
	
	for i in range(500):
		
		count = system.perspective.runJavaScriptBlocking(code, { 
			'count': count
		})
		
		self.props.text = count

Async Example

# Button [onActionPerformed]
def runAction(self, event):
	code = '''(count) =>
	const start = window.performance.now();
	
	sum = 0
	for (let i = 0; i < count; i++) {
		sum += i;
		console.log(i);
	}
	
	const end = window.performance.now();
	return {
		sum,
		duration: end - start
	}
	'''
		
	count = 500000
		
	def cb(result):
		message = 'Total: {sum}, Duration: {duration:.2f}ms'.format(**result)
		self.props.text = message
		
	system.perspective.runJavaScriptAsync(code, { 'count': count }, cb)

Function Globals

All functions have access to the Perspective client store through a globally scoped variable client.

Function Globals Example

# Button [onActionPerformed]
def runAction(self, event):
	code = '''() =>
	return client.projectName;
	'''
	
	self.props.text = system.perspective.runJavaScriptBlocking(code)

Lightly tested.

Embr-Periscope-0.4.2-BETA.modl (2.0 MB)

6 Likes

runJavaScriptAsync and runJavaScriptBlocking are fully merged.

Embr-Periscope-0.4.3.modl
(Oops, changed timeout units without updating the value. A 30 second timeout became a 30,000 second timeout :upside_down_face:)
Embr-Periscope-0.4.4.modl

Improvements:

  • Support for async JavaScript function defintions. You can either directly specify your function definition as async:
 async (param1, param2) => {
  const promise = new Promise((resolve) => {
    setTimeout(() => {
      resolve(param1 + param2)
    }, 3000)
  })

  return await promise
}	

Or you can return a promise:

(param1, param2) => {
  const promise = new Promise((resolve) => {
    setTimeout(() => {
      resolve(param1 + param2)
    }, 3000)
  })
  return promise
}

Either way, once the promise is resolved the gateway will be notified (unblocking or running the callback, depending on which version you called).

  • If any errors are thrown in your JavaScript, the stack trace is displayed in the designer.
  • Support for specifying sessionId/pageId for execution on arbitrary pages.

Documentation site will be updated soon.

(For the folks paying attention, this idea will be the basis accessing methods of the Chart.js object in my embr-charts module. You will have access to a ClientChartProxy object that you can manipulate fairly directly, calling methods/updating properties.)

5 Likes

Hi @bmusson I want to get the event.touches.length using system.perspective.runJavaScriptAsync since touch events in ignition do not expose this property , is this possible?

Sure.

In a component startup script, you can use runJavaScriptAsync/Blocking to install whatever custom listeners you want.

Edit: These scripting functions are provided by my Periscope module, if someone could move this thread over there it’d be the most appropriate.

1 Like

Thank you for the reply,

When you say install, what is meant by this?

Just for reference I am trying to implement a pinch and zoom,

My idea would be to eventually update the transform here after performing the calculations.

But considering what you just said, this native JavaScript run can update it directly and will persist while the page is open?

const pinchZoom = (element) => {
  let scale = 1;
  let start = { x: 0, y: 0, distance: 0 };

  const targetComponent = document.querySelector('#touchElement');

  targetComponent.addEventListener('touchstart', (e) => {
    if (e.touches.length === 2) {
      e.preventDefault();
      start.x = (e.touches[0].pageX + e.touches[1].pageX) / 2;
      start.y = (e.touches[0].pageY + e.touches[1].pageY) / 2;
      start.distance = distance(e);
    }
  });

  targetComponent.addEventListener('touchmove', (e) => {
    if (e.touches.length === 2) {
      e.preventDefault();
      const currentDistance = distance(e);
      const newScale = Math.min(Math.max(1, currentDistance / start.distance), 4);
      
      const deltaX = ((e.touches[0].pageX + e.touches[1].pageX)/2 - start.x) * 2;
      const deltaY = ((e.touches[0].pageY + e.touches[1].pageY)/2 - start.y) * 2;

      targetComponent.style.transform = `scale(${newScale}) translate(${deltaX}px, ${deltaY}px) `;
    }
  });

  targetComponent.addEventListener('touchend', () => {
    targetComponent.style.transform = '';
  });
};

This isn’t going to go well using gateway-side properties. You’ll want to directly update the position of the elements in the client without relying on a gateway roundtrip.

Yes, exactly. Run your script when your view/component starts, installing the event listeners. The event listeners will persist for the lifetime of the client.

Worth noting, there is a branch of Periscope with a work-in-progress pan/pinch/zoom container. You’d have to build it from source yourself.

It’s about 75% done, but it’s been put on the back burner by other tools that are more immediately useful for me.

1 Like

Okay this working,

`

js_function='''
	() => {
	    // State variables for transform properties
	    let currentScale = 1,
	      currentX = 0,
	      currentY = 0;
	    const sensitivity = 0.65,
	      minScale = 0.5,
	      maxScale = 4;
	
	    // Elements: canvasView is our main container.
	    // Its immediate child contains the inline dimensions.
	    const targetComponent = document.querySelector('#touchElement');
	    const canvasView = document.querySelector('#canvasView');
	    const target = targetComponent; // using the canvasView as target for events
	    const child = document.querySelector("#canvasView > .ia_container--primary.view");
	
	    
	
	    // Function to update the transform style of canvasView.
	    function updateTransform() {
	        canvasView.style.transition = "transform 100ms linear";
	        canvasView.style.transform = `scale(${currentScale}) translate(${currentX}px, ${currentY}px)`;
	    }
	
	    // A helper function that limits a value to a given [min, max] range.
	    function clamp(value, min, max) {
	      return Math.min(Math.max(value, min), max);
	    }
	  
	    // ------------------------------
	    // Touch Events for Pinch‑Zoom and Panning
	
	    let touchStart = { x: 0, y: 0, distance: 0 };
	
	    // Calculate the distance between two touch points.
	    function getDistance(t1, t2) {
	      const dx = t1.pageX - t2.pageX;
	      const dy = t1.pageY - t2.pageY;
	      return Math.hypot(dx, dy);
	    }
	
	
	    // On touchstart, if two fingers are placed then store the midpoint and distance.
	    target.addEventListener(
	      "touchstart",
	      (e) => {
	        if (e.touches.length === 2) {
	          const mid = getMidpoint(e.touches[0], e.touches[1]);
	          touchStart.x = mid.x;
	          touchStart.y = mid.y;
	          touchStart.distance = getDistance(e.touches[0], e.touches[1]);
	        }
	      },
	      { passive: false }
	    );
	
	    // On touchmove, update the scale and translation based on pinch movement.
	    target.addEventListener(
	      "touchmove",
	      (e) => {
	        if (e.touches.length === 2) {
	          e.preventDefault();
	          const mid = getMidpoint(e.touches[0], e.touches[1]);
	          const currentDistance = getDistance(e.touches[0], e.touches[1]);
	          if (!touchStart.distance) return;
	
	          const scaleDelta =
	            ((currentDistance / touchStart.distance) - 1) * sensitivity + 1;
	          currentScale = clamp(currentScale * scaleDelta, minScale, maxScale);
	
	          // Adjust translation based on how the midpoint has moved.
	          const dx = mid.x - touchStart.x;
	          const dy = mid.y - touchStart.y;
	          currentX += dx / currentScale;
	          currentY += dy / currentScale;
	
	          // Update starting values for the next event.
	          touchStart.x = mid.x;
	          touchStart.y = mid.y;
	          touchStart.distance = currentDistance;
	
	          updateTransform();
	        }
	      },
	      { passive: false }
	    );
	
	    // ------------------------------
	    // Pointer (mouse/stylus) Drag for Panning
	    target.addEventListener("pointermove", (e) => {
	      if (e.buttons > 0) {
	        e.preventDefault();
	        // Adjust translation relative to the current scale so pan speed stays consistent.
	        currentX += e.movementX / currentScale;
	        currentY += e.movementY / currentScale;
	        updateTransform();
	      }
	    });
	
	    // ------------------------------
	    // Wheel Event for Zooming
	    target.addEventListener("wheel", (e) => {
	      e.preventDefault();
	      const zoomFactor = e.deltaY < 0 ? 1.1 : 0.9;
	      currentScale = clamp(currentScale * zoomFactor, minScale, maxScale);
	      console.log('wheel event');
	      updateTransform();
	    });
	  }
'''
	
system.perspective.runJavaScriptAsync(js_function)`

Is there of passing up the style transform state changes back up to the ignition scope so I can use the event changes for some other scripting functionality. Is that what the call back is for ? Or will that call back only run once on startup?

The callback runs after the JavaScript code completes, and is supplied with the return value of the JavaScript function. Not useful for your use case.

You can write the transform to component/view/page/session properties and then work with them from there. In a function called by runJavaScriptAsync, you have access to a global scoped object named perspective that has a context that you can use to access the view, page, or session that’s available.

Edit: looks like runPerspectiveAsync only has access to the session context currently.

1 Like

Json View Component

The latest release of Periscope (0.5.4) now includes a Json View component.

This component allows you to render a view by providing view.json content into the component's properties.

Example View JSON
{
  "custom": {
    "text": "Neat!",
    "type": "ia.input.text-field"
  },
  "params": {},
  "propConfig": {
    "custom.text": {
      "binding": {
        "config": {
          "bidirectional": true,
          "path": "/root/Text/TextField.props.text"
        },
        "type": "property"
      },
      "persistent": true
    },
    "custom.type": {
      "binding": {
        "config": {
          "bidirectional": true,
          "path": "/root/Type/TextField.props.text"
        },
        "type": "property"
      },
      "persistent": true
    }
  },
  "props": {
    "defaultSize": {
      "width": 325
    }
  },
  "root": {
    "children": [
      {
        "meta": {
          "name": "JsonView"
        },
        "position": {
          "basis": "300px"
        },
        "propConfig": {
          "props.viewJson.root.children[1].type": {
            "binding": {
              "config": {
                "path": "view.custom.type"
              },
              "type": "property"
            },
            "persistent": true
          },
          "props.viewParams.text": {
            "binding": {
              "config": {
                "bidirectional": true,
                "path": "view.custom.text"
              },
              "type": "property"
            }
          }
        },
        "props": {
          "viewJson": {
            "custom": {},
            "params": {
              "text": "Text"
            },
            "propConfig": {
              "params.text": {
                "paramDirection": "inout",
                "persistent": true
              }
            },
            "props": {},
            "root": {
              "children": [
                {
                  "meta": {
                    "name": "Label"
                  },
                  "position": {
                    "basis": "auto"
                  },
                  "propConfig": {
                    "props.text": {
                      "binding": {
                        "config": {
                          "path": "view.params.text"
                        },
                        "type": "property"
                      }
                    }
                  },
                  "type": "ia.display.label"
                },
                {
                  "meta": {
                    "name": "TextField"
                  },
                  "position": {
                    "basis": "auto"
                  },
                  "propConfig": {
                    "props.text": {
                      "binding": {
                        "config": {
                          "bidirectional": true,
                          "path": "view.params.text"
                        },
                        "type": "property"
                      }
                    }
                  },
                  "props": {
                    "style": {
                      "minHeight": "50px"
                    }
                  },
                  "type": "ia.input.text-field"
                }
              ],
              "meta": {
                "name": "root"
              },
              "props": {
                "direction": "column",
                "style": {
                  "gap": "1rem"
                }
              },
              "type": "ia.container.flex"
            }
          }
        },
        "type": "embr.periscope.embedding.json-view"
      },
      {
        "children": [
          {
            "meta": {
              "name": "Label"
            },
            "position": {
              "basis": "50px"
            },
            "props": {
              "text": "Text"
            },
            "type": "ia.display.label"
          },
          {
            "meta": {
              "name": "TextField"
            },
            "position": {
              "grow": 1
            },
            "props": {
              "text": "Neat!"
            },
            "type": "ia.input.text-field"
          }
        ],
        "meta": {
          "name": "Text"
        },
        "position": {
          "basis": "32px"
        },
        "type": "ia.container.flex"
      },
      {
        "children": [
          {
            "meta": {
              "name": "Label"
            },
            "position": {
              "basis": "50px"
            },
            "props": {
              "text": "Type"
            },
            "type": "ia.display.label"
          },
          {
            "meta": {
              "name": "TextField"
            },
            "position": {
              "grow": 1
            },
            "props": {
              "text": "ia.input.text-field"
            },
            "type": "ia.input.text-field"
          }
        ],
        "meta": {
          "name": "Type"
        },
        "position": {
          "basis": "32px"
        },
        "type": "ia.container.flex"
      }
    ],
    "meta": {
      "name": "root"
    },
    "props": {
      "direction": "column"
    },
    "type": "ia.container.flex"
  }
}

But Why?

This started as an exercise in trying to understand the full process of view loading. Then, I realized this component was possible and that I already had solved the hardest parts during the development of the Embedded View + component.

What Does This Enable?

This component allows you to programmatically generate views from within Ignition. You've always been able to modify the view.json files from an external editor, but you don't get any visual feedback until you import them; this component re-renders as the viewJson property changes. The idea is:

  1. Generate the viewJson structure however you want (scripting, bindings, database queries, manually, etc.).
  2. Copy the viewJson property and paste it to a view using the Paste JSON option.
  3. Enjoy your new view.

The component does work in the client, you could generate the viewJson at runtime to have completely dynamic view structure. I don't think this is a good idea, but I can't stop you, so do whatever you want :man_shrugging:.

If you do decide you want to use dynamic views in the client, you can provide parameters using the viewParams property. Passing parameters works like any other view (input, output, in-out, etc.).

Known Issues

  1. The property tree does not allow you to use periods in object keys. This causes an issue when defining a component's propConfig properties. (I'd like to recommend that IA drop this limitation, as well as the limitation against string numbers as keys. JSON keys are strings, and strings containing periods and numbers are strings).

Download

5 Likes

I will argue against lifting this limitation. The further you get from keeping keys valid identifiers in all contexts, the further you get from any hope of intuitive interoperability.

That’s exactly why I think they should drop it :upside_down_face:.

If I can make both a Python dictionary a JavaScript object with keys (and tags with the same name):

{
  “key”: “value”,
  “20”: “key is a string”,
  “prop.test”: “key is still a string”
}

then I would say it’s more surprising that they are invalid in this context.

Assign that dictionary to a perspective custom property and see what happens.

What do you get from JSON.parse() of that in a browser?

If you could make that work in Perspective, how would you expect to access prop.test in a script?

{ It is my not-so-humble opinion that the square bracket operator is not intuitive for object properties. }

The property tree complains, but works with expression bindings.

It does not work with property bindings. When you select using the property editor, it does apply square bracket notation (which should work IMO), but it only evaluates to an invalid path syntax error.

// JavaScript
const string = `{
  "key": "value",
  "20": "key of numeric string",
  "prop.test": "key with a period"
}`

const obj = JSON.parse(string)

console.log(obj["20"]) // key of numeric string
console.log(obj["prop.test"]) // key with a period
# Python
string = `{
  "key": "value",
  "20": "key of numeric string",
  "prop.test": "key with a period"
}`

obj = system.util.jsonDecode(string)

print obj["20"] # key of numeric string
print obj["prop.test"] # key with a period

The exact same way that it works now.

Oh I totally agree with you, I'm not saying that I want to start using weird and unusual keys everywhere. The dot-accessible syntax is nice.

But, string keys are string keys, and it should be possible to use strings containing periods and numbers, even if the dot-access syntax breaks.


(I kid.)

Edit: It's worth noting that I had to paste the object structure in. The designer does not let you manually add the "invalid" keys.

I'm a big fan of not breaking the dot operator, and the status quo helps not break the dot operator.

What concrete use-case do you have, now, that requires non-identifier keys?

I'd like to use numeric keys ("10", "20", "50", "100") for defining gradients.

Like:

{
  '10': '#222222',
  '20': '#444444',
  '50': '#aaaaaa',
  '100': '#ffffff'
}
2 Likes

I ran into this just yesterday...

Something like this isn't possible currently in the designer:

view.custom.lookup = [
    "0": {...},
    "1": {...}
]

There are situations where this would be useful for constant-time lookups when you know the index, but if I want to do that now, I do something like "_0" and then when I do the lookup, I prepend the _ character.

1 Like

For those cases, consider using lists of pairs (two-element lists). Those can be converted on the fly to more tolerate maps with my toolkit's asMap() expression function. The numeric-ish keys can remain actual numbers in that case.

1 Like

Portal Component

This update adds a Portal component, that uses React's createPortal to add children wherever you want them.

This allows you to:

  • Add elements inside existing components/views.
  • Decouple the design time structure of your views from the runtime structure of the HTML elements.
  • Develop re-usable views from the outside in.

Examples

Example, Basic Usage

This example demonstrates how the elements of the Portal are added to the target.

Example, Alert Information

This example shows a portal configured to add a warning border around the target element by using absolute positioning on the Portal's child elements

Example, Target onMount and onUnmount

This example uses the onMount and onUnmount callbacks to modify the properties of the target element, adding a border when mounted and removing a border when unmounted.

Element Selection

The element property can either be:

  1. A string containing the domId of the component/element you want to target.
  2. A JavaScript arrow function that returns an Element, allowing you to specify exactly where in the DOM you want to render your elements, even if it doesn't have an domId
5 Likes

Hi Ben,

I think your latest addition is interesting, but I'm kind of struggling to have a viable use case for it in the industry.

Could you perhaps give us real-life applications of when your Portal component would be handy ? (if you have examples, otherwise that's completely fine! More options for everyone to use and play with :slight_smile: )

1 Like