Problems with my script to create legends for a view.

Started by risc12, February 27, 2025, 15:52:38 PM

Previous topic - Next topic

risc12

Per the discussion on Github: https://github.com/archimatetool/archi/issues/397

I was looking into creating legends with jArchi by creating notes and images that refer to icons, that way the model doesn't get cluttered with "placeholder"elements, I attached a screenshot of the resulting legend.

This script works okay, but when I push the diagram using coArchi the images don't come along. They do come along when I manually add images to the Archi project.

I don't really like how hacky the script is, accessing private methods and such, I went down a rabbit-hole...

I was wondering if you have some insights how to either do the legend in a better way or maybe you can take a peek at this hacky abomination and see if I missed some things...

This is the script (it's actually is divided into a couple of files and is somewhat more structured but I tried to get it as short as possible to share):

/* The problem is mainly with how I handle images, which starts at line 70*/

const IArchiImages = Java.type('com.archimatetool.editor.ui.IArchiImages');
const SWT = Java.type('org.eclipse.swt.SWT');
const ImageLoader = Java.type('org.eclipse.swt.graphics.ImageLoader');
const ByteArrayOutputStream = Java.type('java.io.ByteArrayOutputStream');
const ModelUtil = Java.type('com.archimatetool.script.dom.model.ModelUtil');
const HashMap = Java.type('java.util.HashMap');
const Base64 = Java.type('java.util.Base64');


const RELATIONSHIPS = ["composition", "aggregation", "assignment", "realization", "serving", "access",
  "association", "influence", "triggering", "flow", "specialization", "junction"];

const ELEMENTS = ["capability", "course-of-action", "resource", "value-stream",
  "business-actor", "business-role", "business-collaboration", "business-interface",
  "business-process", "business-function", "business-interaction", "business-event",
  "business-service", "business-object", "contract", "product", "representation",
  "application-component", "application-collaboration", "application-interface",
  "application-function", "application-interaction", "application-process",
  "application-event", "application-service", "data-object",
  "node", "device", "system-software", "technology-collaboration", "technology-interface",
  "path", "communication-network", "technology-function", "technology-process",
  "technology-interaction", "technology-event", "technology-service", "artifact",
  "equipment", "facility", "distribution-network", "material",
  "workpackage", "deliverable", "implementation-event", "plateau", "gap",
  "stakeholder", "driver", "assessment", "goal", "outcome", "principle",
  "requirement", "meaning", "value", "constraint",
  "grouping", "location"];


// -- Init -----------------------------------------------------------------------------------------
function main () {
  // Get the current view (there isn't really a better way to do this, right?)
  let view;
  if (selection[0].getType() === "archimate-diagram-model") {
    view = selection[0];
  } else {
    view = selection[0].getView();
  }
  if (!view) return alert("No current view");

  // Layout settings
  const viewBounds = getViewBounds(view);
  const padding = 20;
  const gap = 10;
  const legendX = viewBounds.minX;
  const legendY = viewBounds.maxY + padding;
  const entryWidth = 200;
  const legendWidth = entryWidth + 2 * padding;

  // -- Removing and adding elements to the view -----------------------------------------------------------------
  // Remove existing legend
  $(view).children("diagram-model-group").filter(group => group.prop("IAmLegend") === "true").delete();

  // Filter only element/relationship types that exist in the current view
  const entriesToAdd = [...getAllElementTypes(), ...getAllRelationshipTypes()]
    .filter(elementType => $(view).children(elementType.id).length > 0);

  // Create legend group
  const group = view.createObject("group", legendX, legendY, legendWidth, 0);
  group.borderType = 1;
  group.fillColor = "#FFFFFF";
  group.name = "";
  group.prop("IAmLegend", "true");

  // Add entries to legend
  let heightTillNow = 0;
  entriesToAdd.forEach(elementType => {
    const imgData = addIconToArchiveManager(elementType.iconPath, elementType.icon);
    //              ^^^^^^^^^^^^^^^^^^^^^^ This is where the problem is, it is defined below at line 123
   
    const height = imgData.height + gap;
   
    // Create note with icon and label
    var entry = group.createObject("note", padding, padding + heightTillNow, entryWidth, height);
    entry.image = imgData;
    entry.imagePosition = 5; // Middle Right
    entry.textPosition = 1; // Middle
    entry.text = elementType.name;
    entry.borderType = 2; // NONE
   
    heightTillNow += height;
  });

  // Resize group to fit content
  group.bounds = { ...group.getBounds, height: heightTillNow + 2 * padding };
}

main();

/* -- Helper Functions ------------------------------------------------------------------ */
// Convert camelCase to UPPER_CASE for image constants
function toScreamingSnakeCase(str) { return str.replaceAll("-", "_").replace(/([a-z])([A-Z])/g, '$1_$2').toUpperCase(); }
function toTitleCase(str) { return str.split('-').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' '); }

function getImageForElementType(elementType) { return IArchiImages.ImageFactory.getImage(getImagePath(elementType)); }
function getImagePath(elementType) { return IArchiImages[`ICON_${toScreamingSnakeCase(elementType)}`] || IArchiImages.ICON_CANCEL_SEARCH;
}

function getAllElementTypes() {
  return ELEMENTS.map(id => ({
    id,
    name: toTitleCase(id),
    icon: getImageForElementType(id),
    iconId: id,
    iconPath: getImagePath(id)
  }));
}

function getAllRelationshipTypes() {
  return RELATIONSHIPS.map(id => ({
    id: `${id}-relationship`,
    name: toTitleCase(id) + " Relationship",
    icon: getImageForElementType(`${id}-relation`),
    iconId: `${id}-relation`,
    iconPath: getImagePath(`${id}-relation`),
  }));
}

/* -->  ArchiveManager is defined here ------------------------------------------------- */
function addIconToArchiveManager(path, image) {
  const eObj = invokePrivateMethod(model, "getEObject")(); // Im so sorry...
  const archiveManager = invokePrivateMethod(ModelUtil, "getArchiveManager", true)(eObj);

  try {
    // Convert image to PNG bytes
    const imageData = image.getImageData();
    const loader = new ImageLoader();
    loader.data = [imageData];
    loader.compression = 0;
   
    const stream = new ByteArrayOutputStream();
    loader.save(stream, SWT.IMAGE_PNG);
    const imageBytes = stream.toByteArray();

    // Add to archive manager
    archiveManager.addByteContentEntry(path, imageBytes);
    stream.close();

    // Save to disk for coArchi compatibility               //  <-- This doesnt actually help at all
    const modelPath = model.getPath();                      //
    const repoPath = modelPath.replace(/\/.git\/.*$/, '/'); //
    const fullPath = repoPath + path;                       //
   
    const base64Bytes = Base64.getEncoder().encodeToString(imageBytes);
    $.fs.writeFile(fullPath, base64Bytes, "BASE64");

    // Return image metadata
    const imgData = new HashMap();
    imgData.put("path", path);
    imgData.put("width", imageData.width);
    imgData.put("height", imageData.height);
    return imgData;
  } catch (e) {
    console.error(`Error adding image to archive: ${e}`);
  }
}

// Helper to invoke private Java methods
// Again, I'm very sorry...
function invokePrivateMethod(obj, methodName, staticMethod = false) {
  return function (...args) {
    try {
      const clazz = staticMethod ? obj.class : obj.getClass();
      const methods = clazz.getDeclaredMethods();
      const method = Array.from(methods).find(m => m.getName() === methodName);
      method.setAccessible(true);
      return method.invoke(staticMethod ? null : obj, Java.to(args, "java.lang.Object[]"));
    } catch (e) {
      console.error(`Error invoking method ${methodName}: ${e}`);
    }
  }
}

// Calculate the bounds of all elements in the view
function getViewBounds(view) {
  let minX, minY, maxX, maxY;

  $(view).children().each(child => {
    if (!child.getBounds) return;
    const bounds = child.getBounds();
    if (minX === undefined || bounds.x < minX) minX = bounds.x;
    if (minY === undefined || bounds.y < minY) minY = bounds.y;
    if (maxX === undefined || bounds.x + bounds.width > maxX) maxX = bounds.x + bounds.width;
    if (maxY === undefined || bounds.y + bounds.height > maxY) maxY = bounds.y + bounds.height;
  });

  return { minX, minY, maxX, maxY, width: maxX - minX, height: maxY - minY };
}

Alberto

Not sure about your script, but here's another legend generator script.  It probably generates placeholder elements thou.  Hope it helps.

https://gist.github.com/projetnumero9/21ec861797dd6f3fcc193f8221922e8e

Jean-Baptiste Sarrodie

#2
Hi,

Here is a script which adds such legend. You first have to copy the following folder alongside the script: "Archi/plugins/com.archimatetool.editor_x.y.z.timestamp/img/archimate" (so the script must be able to find an "archimate" folder next to it).

// Create Legend Box
//
// Requires jArchi - https://www.archimatetool.com/plugins/#jArchi
//
// This script creates a visual group named "Legend" containing
// the icons and names for the concepts used in the current view
//
// (c) 2025 Jean-Baptiste Sarrodie

console.clear();

// You can change the following constants
const LEGEND_X = 10;
const LEGEND_COLOR = '#ffffff';
const LEGEND_TOP_MARGIN = 20;
const LEGEND_PADDING = 10;
const LEGEND_WIDTH = 250;
const LEGEND_ITEM_HEIGHT = 25;
const LEGEND_TITLE = 'Legend';

// Assumes our selection is either the current view or one of its child objects
const view = selection.add(selection.parents()).filter('view').first();

if (!view) {
  window.alert('No view selected');
  exit();
}

// Get list of all elements types in several steps
// 1. Get all elements and relationships
var elTypes = $(view).find('element');
// 2. Convert this jArchi Collection (which is in fact a Java class) to a real JS Array
elTypes = Java.from(elTypes);
// 3. Now use the map() function to extract concepts types
elTypes = elTypes.map(el => el.type);
// 4. Remove duplicates by converting the Array to a Set and back to an Array
elTypes = [...new Set(elTypes)];

// Do the same for relationships types
var relTypes = $(view).find('relationship');
relTypes = Java.from(relTypes);
relTypes = relTypes.map(rel => rel.type.replace('-relationship', ''));
relTypes = [...new Set(relTypes)];

// Now lets find the bottom of the diagram
const bottom = Java.from($(view).find('element')).reduce((max, el) => Math.max(max, el.bounds.y + el.bounds.height), 0)

// Add the legend box
var legend = view.createObject('diagram-model-group', LEGEND_X, bottom + LEGEND_TOP_MARGIN, LEGEND_WIDTH, LEGEND_ITEM_HEIGHT * (1 + elTypes.length + relTypes.length) + LEGEND_PADDING);
legend.borderType = BORDER.RECTANGLE;
legend.name = LEGEND_TITLE;
legend.fillColor = LEGEND_COLOR;
legend.textAlignment = TEXT_ALIGNMENT.CENTER;
legend.fontStyle = "bold";

// Iterate on each elements and relationships types and add them to the Legend
var currentPosition = LEGEND_ITEM_HEIGHT;

elTypes.forEach(addItem);
relTypes.forEach(addItem);

function addItem(type) {
  var item = legend.createObject('diagram-model-note', LEGEND_PADDING, currentPosition, LEGEND_WIDTH - 2*LEGEND_PADDING, LEGEND_ITEM_HEIGHT);
  currentPosition += LEGEND_ITEM_HEIGHT;
  item.text = capitalize(type.replace('-', ' '));
  item.textPosition = TEXT_POSITION.CENTER;
  item.borderType = BORDER.NONE;
  item.opacity = 0;
  item.imageSource = IMAGE_SOURCE.CUSTOM;
  item.image = model.createImage(__DIR__ + 'archimate/' + type + '.png');
  item.imagePosition = IMAGE_POSITION.MIDDLE_RIGHT;
}

function capitalize(s)
{
    return String(s[0]).toUpperCase() + String(s).slice(1);
}

You'll find some answers to your questions in it, and some simpler way of doing things.

Regards,

JB
If you value and use Archi, please consider making a donation!
Ask your ArchiMate related questions to the ArchiMate Community's Discussion Board.

Jean-Baptiste Sarrodie

Hi,

Some remarks and tips based on your script:

No need to create a main() function.

Don't use array notation (selection[n]) to access collection members, use the proper method (selection.get(n)). For the first element, you have the .first() method.

While working on objects, don't use Java accessors (e.g. .getType()) but JS attributes (.type).

Getting the current view from the selection is a current need, you can do it by concatenating the selection itself with the parents of selection members. By doing this you are sure that your selection contains the view even if a visual object (element, relationship, note...) was in fact selected. You can then filter to keep only views and pick the first one:
const view = selection.add(selection.parents()).filter('view').first();

Adding icon to a model must be done using the model.createImage(path) function. It will take care of importing it into the model, but won't create duplicates if the image was already there.

Something often overlooked is that you can convert a jArchi collection to a real JS Array to then use reduce/map methods. This makes it quite easy to get the list of concepts types in the view: get the list of descendants ($(view).find()) and then map it (Java.from($(view).find()).map(el => el.type)). It can also be used to get the bounds of the view and some other otherwise difficult things.

Hope this helps.

Regards,

JB
If you value and use Archi, please consider making a donation!
Ask your ArchiMate related questions to the ArchiMate Community's Discussion Board.

risc12

Thanks for the script and the explanation!

I did spent some time before trying to find the folder so thanks for pointing me to the img-folder (on MacOS I had to "Open Package Contents" and then browse to `Eclipse/plugins/com.archimatetool.editor_x.y.z.timestamp/img/archimate`).

Accessing the images directly also gets rid of most of the hacky stuff and most of the string transformations so that is very nice.

The main function was there to be able to use returns, but as I saw in your example I can use exit() instead.

Thanks once more not only for the script, but really for the insights!

Btw, should I mark the thread as solved or something?

Jean-Baptiste Sarrodie

Hi,

Quote from: risc12 on March 04, 2025, 10:05:24 AMBtw, should I mark the thread as solved or something?

Not here on the forum, but you can close the issue you opened on GitHub.

Btw, I did update the script a bit to better find the bottommost position (taking visual notes into account), and also to remove the legend if it exist, before re-creating it. I'll post the newer version here soon.

Regards,

JB
If you value and use Archi, please consider making a donation!
Ask your ArchiMate related questions to the ArchiMate Community's Discussion Board.