Created
January 23, 2026 09:45
-
-
Save romualdrichard/31023136003b6e15f74f790a9b190d5b to your computer and use it in GitHub Desktop.
#jarchi Export Single page html interactive
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // Generate Single-page HTML Export - Enhanced with Interactive Views | |
| // | |
| // Based on: https://github.com/archi-contribs/jarchi-single-page-html-export | |
| // Enhanced by: Claude (2026) - Added interactive SVG overlays | |
| // STABLE VERSION - Based on v1.9 + v2.0 uncheck fix | |
| // | |
| // Requires jArchi - https://www.archimatetool.com/blog/2018/07/02/jarchi/ | |
| // Add this script to the repertory of jarchi-single-page-html-export | |
| // === CONFIGURATION === | |
| var DEBUG = false; // Set to true for detailed logging | |
| const USE_REQUIRE = true; | |
| load(__DIR__ + 'libs/nashorn-polyfills.js'); | |
| if(USE_REQUIRE) { | |
| _ = require(__DIR__ + "libs/underscore-min.js"); | |
| marked = require(__DIR__ + "libs/marked.min.js"); | |
| } | |
| else { | |
| load(__DIR__ + 'libs/underscore-min.js'); | |
| load(__DIR__ + 'libs/marked.min.js'); | |
| } | |
| console.show(); | |
| console.clear(); | |
| // Helper function for conditional logging | |
| function debug(msg) { | |
| if(DEBUG) console.log(msg); | |
| } | |
| function info(msg) { | |
| console.log(msg); | |
| } | |
| _.templateSettings = { | |
| interpolate: /\{\{(.+?)\}\}/g | |
| }; | |
| var mdOptions = { | |
| gfm: true, | |
| breaks: true, | |
| smartLists: true, | |
| smartypants: true | |
| }; | |
| var roboto = readFully(__DIR__ + 'resources/roboto.css', 'UTF-8'); | |
| var icon = readFully(__DIR__ + 'resources/icon.css', 'UTF-8'); | |
| var picnic = readFully(__DIR__ + 'resources/picnic-custom.css', 'UTF-8'); | |
| var tplMainReport = _.template(readFully(__DIR__ + 'templates/main-report.html', 'UTF-8')); | |
| var tplVisibilityRuleBold = _.template(readFully(__DIR__ + 'templates/visibility-rule-bold.tpl', 'UTF-8')); | |
| var tplVisibilityRuleReveal = _.template(readFully(__DIR__ + 'templates/visibility-rule-reveal.tpl', 'UTF-8')); | |
| var tplTreeView = _.template(readFully(__DIR__ + 'templates/model-tree-view.tpl', 'UTF-8')); | |
| var tplTreeFolder = _.template(readFully(__DIR__ + 'templates/model-tree-folder.tpl', 'UTF-8')); | |
| var tplViewTitle = _.template(readFully(__DIR__ + 'templates/view-title.tpl', 'UTF-8')); | |
| var tplViewDiagram = _.template(readFully(__DIR__ + 'templates/view-diagram.tpl', 'UTF-8')); | |
| var tplDocumentation = _.template(readFully(__DIR__ + 'templates/documentation.tpl', 'UTF-8')); | |
| var tplElement = _.template(readFully(__DIR__ + 'templates/element.tpl', 'UTF-8')); | |
| var tplRelationship = _.template(readFully(__DIR__ + 'templates/relationship.tpl', 'UTF-8')); | |
| var visibilityRulesBold = ''; | |
| var visibilityRulesReveal = ''; | |
| var inputCheckbox = ''; | |
| var treeFolder = ''; | |
| var treeContent = ''; | |
| var viewTitles = ''; | |
| var viewDiagrams = ''; | |
| var viewDocumentations = ''; | |
| var viewsIdsByConceptId = {}; | |
| var elements = ''; | |
| var elementsCollection; | |
| var relationships = ''; | |
| var relationshipsCollection; | |
| var interactiveSVGOverlays = ''; | |
| var interactiveElementData = {}; | |
| var exportedViewIds = []; | |
| var allFolders = $('folder'); | |
| var viewsFolder = $(model).children().filter('folder.Views'); | |
| var viewFolders = viewsFolder.find('folder'); | |
| var nonViewFolders = allFolders.not(viewsFolder).not(viewFolders); | |
| var folders = selection.filter("folder").not(nonViewFolders); | |
| if(! folders.size()) { | |
| folders = viewsFolder; | |
| debug("All views have been selected."); | |
| } | |
| var filePath = window.promptSaveFile({ title: "Save as HTML file", filterExtensions : [ "*.html" , "*.*"], fileName: model.name + "-interactive.html" }); | |
| if(!filePath) { | |
| info('Export cancelled by user.'); | |
| exit(); | |
| } | |
| info(''); | |
| info('=== GENERATING INTERACTIVE HTML EXPORT ==='); | |
| info(''); | |
| _.chain(folders).sortBy(function(f) { return f.name; }).each(function(f) { | |
| exportViews(f); | |
| }); | |
| info(''); | |
| info('=== EXPORT COMPLETE ==='); | |
| info('Total views exported: ' + exportedViewIds.length); | |
| var checkboxCount = 0; | |
| exportedViewIds.forEach(function(viewId) { | |
| inputCheckbox += '<input type="checkbox" id="id-' + viewId + '" class="view-checkbox" />\n'; | |
| checkboxCount++; | |
| }); | |
| info('Generated ' + checkboxCount + ' checkboxes'); | |
| function generateInteractiveSVG(view, viewImageBase64) { | |
| var visualObjects = []; | |
| var elementDataForView = {}; | |
| var minX = 999999, minY = 999999, maxX = -999999, maxY = -999999; | |
| var hasBendPointsOutOfBounds = false; // Track if bend points extend beyond object bounds | |
| var pngWidth = 800; | |
| var pngHeight = 600; | |
| try { | |
| var bytes = java.util.Base64.getDecoder().decode(viewImageBase64); | |
| var bais = new java.io.ByteArrayInputStream(bytes); | |
| var ImageIO = Java.type("javax.imageio.ImageIO"); | |
| var image = ImageIO.read(bais); | |
| pngWidth = image.getWidth(); | |
| pngHeight = image.getHeight(); | |
| bais.close(); | |
| } catch(e) { | |
| debug(" Warning: Could not read PNG dimensions"); | |
| } | |
| // Function to calculate absolute coordinates by traversing visual parent hierarchy | |
| function getAbsoluteCoordinates(visualObj) { | |
| var absX = visualObj.bounds.x; | |
| var absY = visualObj.bounds.y; | |
| // Traverse up the visual parent hierarchy | |
| var parent = $(visualObj).parent().first(); | |
| while(parent && parent.type !== 'archimate-diagram-model') { | |
| if(parent.bounds) { | |
| absX += parent.bounds.x; | |
| absY += parent.bounds.y; | |
| } | |
| parent = $(parent).parent().first(); | |
| } | |
| return { x: absX, y: absY }; | |
| } | |
| // CRITICAL FIX: Detect negative coordinates - check ALL visual objects (including notes) | |
| var negativeOffsetX = 0; | |
| var negativeOffsetY = 0; | |
| $(view).find().each(function(obj) { | |
| if(obj.bounds) { | |
| // Check ALL objects, including notes, to get true view bounds | |
| var absCoords = getAbsoluteCoordinates(obj); | |
| // Track most negative coordinates | |
| if(absCoords.x < negativeOffsetX) { | |
| negativeOffsetX = absCoords.x; | |
| } | |
| if(absCoords.y < negativeOffsetY) { | |
| negativeOffsetY = absCoords.y; | |
| } | |
| } | |
| }); | |
| // CRITICAL: Check bend points with correct weight-based calculation | |
| // Based on: https://github.com/archimatetool/archi-scripting-plugin/issues/42 | |
| $(view).find('relationship').each(function(conn) { | |
| if(conn.relativeBendpoints && conn.relativeBendpoints.length > 0) { | |
| var sourceObj = conn.source; | |
| var targetObj = conn.target; | |
| // Skip connection-to-connection (not supported) | |
| if(!sourceObj || !sourceObj.bounds || !targetObj || !targetObj.bounds) { | |
| return; | |
| } | |
| // Skip if bounds are invalid | |
| if(!sourceObj.bounds.width || !sourceObj.bounds.height || !targetObj.bounds.width || !targetObj.bounds.height) { | |
| return; | |
| } | |
| var srcAbsCoords = getAbsoluteCoordinates(sourceObj); | |
| var tgtAbsCoords = getAbsoluteCoordinates(targetObj); | |
| // Validate absolute coordinates | |
| if(isNaN(srcAbsCoords.x) || isNaN(srcAbsCoords.y) || isNaN(tgtAbsCoords.x) || isNaN(tgtAbsCoords.y)) { | |
| return; | |
| } | |
| var bpcount = conn.relativeBendpoints.length + 1; | |
| // Use classic for loop instead of forEach (jArchi/Nashorn compatibility) | |
| for(var i = 0; i < conn.relativeBendpoints.length; i++) { | |
| var bp = conn.relativeBendpoints[i]; | |
| // Validate bendpoint values | |
| if(isNaN(bp.startX) || isNaN(bp.startY) || isNaN(bp.endX) || isNaN(bp.endY)) { | |
| continue; | |
| } | |
| var bpindex = i + 1; | |
| var bpweight = bpindex / bpcount; | |
| // Start component (relative to source center) | |
| var startX = (srcAbsCoords.x + (sourceObj.bounds.width / 2)) + bp.startX; | |
| startX *= (1.0 - bpweight); | |
| var startY = (srcAbsCoords.y + (sourceObj.bounds.height / 2)) + bp.startY; | |
| startY *= (1.0 - bpweight); | |
| // End component (relative to target center) | |
| var endX = (tgtAbsCoords.x + (targetObj.bounds.width / 2)) + bp.endX; | |
| endX *= bpweight; | |
| var endY = (tgtAbsCoords.y + (targetObj.bounds.height / 2)) + bp.endY; | |
| endY *= bpweight; | |
| // Absolute position | |
| var absX = startX + endX; | |
| var absY = startY + endY; | |
| // Final validation | |
| if(!isNaN(absX) && !isNaN(absY)) { | |
| if(absX < negativeOffsetX) negativeOffsetX = absX; | |
| if(absY < negativeOffsetY) negativeOffsetY = absY; | |
| } | |
| } | |
| } | |
| }); | |
| // Convert negative offsets to positive values to add to all coordinates | |
| negativeOffsetX = Math.abs(negativeOffsetX); | |
| negativeOffsetY = Math.abs(negativeOffsetY); | |
| if(negativeOffsetX > 0 || negativeOffsetY > 0) { | |
| debug(' ⚠️ Negative coordinates detected! Applying offset: x+' + negativeOffsetX + ', y+' + negativeOffsetY); | |
| } | |
| // First pass: calculate bounds on ALL visual objects (notes, groups, everything) | |
| // But only collect objects with concepts OR view references for interactive mapping | |
| $(view).find().each(function(obj) { | |
| if(obj.bounds && obj.bounds.width && obj.bounds.height) { | |
| // Calculate absolute coordinates and apply negative offset correction | |
| var absCoords = getAbsoluteCoordinates(obj); | |
| var x = absCoords.x + negativeOffsetX; | |
| var y = absCoords.y + negativeOffsetY; | |
| var width = obj.bounds.width; | |
| var height = obj.bounds.height; | |
| // CRITICAL: Update bounds with ALL visual objects | |
| // This ensures bounds match exactly what Archi uses for PNG generation | |
| minX = Math.min(minX, x); | |
| minY = Math.min(minY, y); | |
| maxX = Math.max(maxX, x + width); | |
| maxY = Math.max(maxY, y + height); | |
| // Collect objects for interactive mapping: | |
| // - Objects with ArchiMate concepts (elements, relationships) | |
| // - View references (archimate-diagram-model, diagram-model-reference) | |
| var hasArchiMateConcept = obj.concept && obj.concept.id; | |
| var isViewReference = obj.type === 'archimate-diagram-model' || obj.type === 'diagram-model-reference'; | |
| if(hasArchiMateConcept || isViewReference) { | |
| visualObjects.push(obj); | |
| } | |
| } | |
| }); | |
| // Save object bounds before adding bend points | |
| var objectMinX = minX; | |
| var objectMinY = minY; | |
| var objectMaxX = maxX; | |
| var objectMaxY = maxY; | |
| // CRITICAL: Include bend points in bounds with correct weight-based calculation | |
| $(view).find('relationship').each(function(conn) { | |
| if(conn.relativeBendpoints && conn.relativeBendpoints.length > 0) { | |
| var sourceObj = conn.source; | |
| var targetObj = conn.target; | |
| if(!sourceObj || !sourceObj.bounds || !targetObj || !targetObj.bounds) { | |
| return; | |
| } | |
| if(!sourceObj.bounds.width || !sourceObj.bounds.height || !targetObj.bounds.width || !targetObj.bounds.height) { | |
| return; | |
| } | |
| var srcAbsCoords = getAbsoluteCoordinates(sourceObj); | |
| var tgtAbsCoords = getAbsoluteCoordinates(targetObj); | |
| if(isNaN(srcAbsCoords.x) || isNaN(srcAbsCoords.y) || isNaN(tgtAbsCoords.x) || isNaN(tgtAbsCoords.y)) { | |
| return; | |
| } | |
| // Apply negative offset to source and target centers | |
| var srcCenterX = srcAbsCoords.x + negativeOffsetX + (sourceObj.bounds.width / 2); | |
| var srcCenterY = srcAbsCoords.y + negativeOffsetY + (sourceObj.bounds.height / 2); | |
| var tgtCenterX = tgtAbsCoords.x + negativeOffsetX + (targetObj.bounds.width / 2); | |
| var tgtCenterY = tgtAbsCoords.y + negativeOffsetY + (targetObj.bounds.height / 2); | |
| var bpcount = conn.relativeBendpoints.length + 1; | |
| // Use classic for loop instead of forEach (jArchi/Nashorn compatibility) | |
| for(var i = 0; i < conn.relativeBendpoints.length; i++) { | |
| var bp = conn.relativeBendpoints[i]; | |
| if(isNaN(bp.startX) || isNaN(bp.startY) || isNaN(bp.endX) || isNaN(bp.endY)) { | |
| continue; | |
| } | |
| var bpindex = i + 1; | |
| var bpweight = bpindex / bpcount; | |
| // Start component | |
| var startX = (srcCenterX + bp.startX) * (1.0 - bpweight); | |
| var startY = (srcCenterY + bp.startY) * (1.0 - bpweight); | |
| // End component | |
| var endX = (tgtCenterX + bp.endX) * bpweight; | |
| var endY = (tgtCenterY + bp.endY) * bpweight; | |
| // Absolute position | |
| var absX = startX + endX; | |
| var absY = startY + endY; | |
| if(!isNaN(absX) && !isNaN(absY)) { | |
| // Add margin for connection line width and arrow heads | |
| // Observed: 22px total difference, so ~11px per side | |
| var connectionMargin = 11; | |
| var bpMinX = absX - connectionMargin; | |
| var bpMinY = absY - connectionMargin; | |
| var bpMaxX = absX + connectionMargin; | |
| var bpMaxY = absY + connectionMargin; | |
| // Check if bend point extends beyond object bounds | |
| if(bpMinX < objectMinX || bpMinY < objectMinY || bpMaxX > objectMaxX || bpMaxY > objectMaxY) { | |
| hasBendPointsOutOfBounds = true; | |
| } | |
| minX = Math.min(minX, bpMinX); | |
| minY = Math.min(minY, bpMinY); | |
| maxX = Math.max(maxX, bpMaxX); | |
| maxY = Math.max(maxY, bpMaxY); | |
| } | |
| } | |
| } | |
| }); | |
| // CRITICAL FIX: After calculating bounds, if minX or minY are not 0, we need to normalize | |
| // This happens when all objects are shifted positively after negative offset correction | |
| debug(' Initial bounds: minX=' + minX + ', minY=' + minY + ', maxX=' + maxX + ', maxY=' + maxY); | |
| // CRITICAL FIX: Reverse Z-order so foreground objects are clickable first | |
| visualObjects.reverse(); | |
| // CRITICAL FIX: Sort by nesting depth - containers first, children last (clickable on top) | |
| // Calculate absolute coordinates and detect nesting | |
| var objectsWithDepth = []; | |
| visualObjects.forEach(function(obj) { | |
| var absCoords = getAbsoluteCoordinates(obj); | |
| // Apply negative offset correction | |
| var absX = absCoords.x + negativeOffsetX; | |
| var absY = absCoords.y + negativeOffsetY; | |
| var absRight = absX + obj.bounds.width; | |
| var absBottom = absY + obj.bounds.height; | |
| // Count how many objects are inside this one (depth = container level) | |
| var childrenCount = 0; | |
| visualObjects.forEach(function(other) { | |
| if(obj.id !== other.id) { | |
| var otherAbs = getAbsoluteCoordinates(other); | |
| var otherX = otherAbs.x + negativeOffsetX; | |
| var otherY = otherAbs.y + negativeOffsetY; | |
| var otherRight = otherX + other.bounds.width; | |
| var otherBottom = otherY + other.bounds.height; | |
| // Check if 'other' is completely inside 'obj' | |
| if(otherX >= absX && otherY >= absY && | |
| otherRight <= absRight && otherBottom <= absBottom) { | |
| childrenCount++; | |
| } | |
| } | |
| }); | |
| objectsWithDepth.push({ | |
| obj: obj, | |
| absCoords: { x: absX, y: absY }, | |
| childrenCount: childrenCount | |
| }); | |
| }); | |
| // Sort: objects with MORE children first (containers at bottom) | |
| // objects with FEWER children last (leaf elements clickable on top) | |
| objectsWithDepth.sort(function(a, b) { | |
| return b.childrenCount - a.childrenCount; | |
| }); | |
| debug(' Objects sorted by depth (containers first): ' + objectsWithDepth.length); | |
| // Fine-tune minX ONLY if bend points extend beyond object bounds | |
| if(hasBendPointsOutOfBounds) { | |
| minX -= 5; | |
| debug(' Bend points extend beyond objects - applied offset correction: minX -= 5'); | |
| } | |
| var boundsWidth = maxX - minX; | |
| var boundsHeight = maxY - minY; | |
| var scaleX = pngWidth / boundsWidth; | |
| var scaleY = pngHeight / boundsHeight; | |
| debug(' Final bounds for SVG: ' + boundsWidth.toFixed(1) + 'x' + boundsHeight.toFixed(1) + ' (scale: ' + scaleX.toFixed(6) + ', ' + scaleY.toFixed(6) + ')'); | |
| var svgRects = ''; | |
| for(var i = 0; i < objectsWithDepth.length; i++) { | |
| var item = objectsWithDepth[i]; | |
| var visualObj = item.obj; | |
| // Use pre-calculated absolute coordinates | |
| var x = item.absCoords.x; | |
| var y = item.absCoords.y; | |
| var width = visualObj.bounds.width; | |
| var height = visualObj.bounds.height; | |
| var scaledX = (x - minX) * scaleX; | |
| var scaledY = (y - minY) * scaleY; | |
| var scaledWidth = width * scaleX; | |
| var scaledHeight = height * scaleY; | |
| var concept = visualObj.concept || visualObj; | |
| var props = {}; | |
| if(concept.prop) { | |
| var propNames = concept.prop(); | |
| for(var j = 0; j < propNames.length; j++) { | |
| var propName = propNames[j]; | |
| props[propName] = concept.prop(propName); | |
| } | |
| } | |
| elementDataForView[concept.id] = { | |
| name: concept.name || 'Sans nom', | |
| type: concept.type, | |
| documentation: concept.documentation || '', | |
| properties: props | |
| }; | |
| var targetView = ''; | |
| var targetViewId = null; | |
| if(visualObj.type === 'archimate-diagram-model') { | |
| if(visualObj.getRefView && visualObj.getRefView()) { | |
| var refView = visualObj.getRefView(); | |
| targetViewId = refView.id; | |
| debug(' ✓ View ref: "' + refView.name + '" (ID: ' + targetViewId + ')'); | |
| } | |
| } | |
| else if(visualObj.type === 'diagram-model-reference') { | |
| if(visualObj.getRefView && visualObj.getRefView()) { | |
| var refView = visualObj.getRefView(); | |
| targetViewId = refView.id; | |
| debug(' ✓ Diagram ref: "' + refView.name + '" (ID: ' + targetViewId + ')'); | |
| } | |
| } | |
| if(targetViewId) { | |
| targetView = ' data-target-view="' + targetViewId + '"'; | |
| } | |
| svgRects += '<rect class="interactive-element" '; | |
| svgRects += 'x="' + scaledX.toFixed(2) + '" y="' + scaledY.toFixed(2) + '" '; | |
| svgRects += 'width="' + scaledWidth.toFixed(2) + '" height="' + scaledHeight.toFixed(2) + '" '; | |
| svgRects += 'data-element-id="' + concept.id + '" '; | |
| svgRects += 'data-element-name="' + _.escape(concept.name || '') + '" '; | |
| svgRects += 'data-element-type="' + concept.type + '"'; | |
| svgRects += targetView + '/>\n'; | |
| } | |
| // CRITICAL: Also add relationships to elementDataStore (they don't have bounds so weren't added above) | |
| $(view).find('relationship').each(function(rel) { | |
| var concept = rel.concept; | |
| if(concept) { | |
| var props = {}; | |
| if(concept.prop) { | |
| var propNames = concept.prop(); | |
| for(var j = 0; j < propNames.length; j++) { | |
| var propName = propNames[j]; | |
| props[propName] = concept.prop(propName); | |
| } | |
| } | |
| elementDataForView[concept.id] = { | |
| name: concept.name || '', | |
| type: concept.type, | |
| documentation: concept.documentation || '', | |
| properties: props | |
| }; | |
| } | |
| }); | |
| interactiveElementData['id-' + view.id] = elementDataForView; | |
| var svg = '<svg class="view-overlay" '; | |
| svg += 'width="100%" height="100%" '; | |
| svg += 'viewBox="0 0 ' + pngWidth + ' ' + pngHeight + '" '; | |
| svg += 'preserveAspectRatio="xMidYMid meet" '; | |
| svg += 'xmlns="http://www.w3.org/2000/svg">\n'; | |
| svg += svgRects; | |
| svg += '</svg>'; | |
| return svg; | |
| } | |
| function exportViews(folder) { | |
| debug('Exporting folder: ' + folder.name); | |
| var previousContent = treeContent; | |
| treeContent = ''; | |
| _.chain($(folder).children('folder')).sortBy(function(f) { return f.name; }).each(function(f) { | |
| exportViews(f) | |
| }); | |
| var views = _.chain($(folder).children('view')).sortBy(function(v) { return v.name; }).value(); | |
| var totalViews = views.length; | |
| var viewCount = 0; | |
| views.forEach(function(v) { | |
| viewCount++; | |
| exportedViewIds.push(v.id); | |
| info('Processing view ' + viewCount + '/' + totalViews + ': ' + v.name); | |
| treeContent += tplTreeView({viewId: 'id-'+v.id, viewName: _.escape(v.name)}); | |
| visibilityRulesBold += tplVisibilityRuleBold({viewId: 'id-'+v.id}); | |
| visibilityRulesReveal += tplVisibilityRuleReveal({viewId: 'id-'+v.id}); | |
| viewTitles += tplViewTitle({viewId: 'id-'+v.id, viewName: _.escape(v.name)}); | |
| var viewImage = $.model.renderViewAsBase64(v, "PNG", {margin: 0}); | |
| var interactiveSVG = generateInteractiveSVG(v, viewImage); | |
| viewDiagrams += '<div class="interactive-view-wrapper" data-view-id="id-' + v.id + '">\n'; | |
| viewDiagrams += tplViewDiagram({viewId: 'id-'+v.id, viewImage: viewImage}); | |
| viewDiagrams += interactiveSVG; | |
| viewDiagrams += '</div>\n'; | |
| viewDocumentations += tplDocumentation({ | |
| viewId: 'id-'+v.id, | |
| documentationText: _.escape(v.documentation).replace(/\n/g, '<br>'), | |
| documentationMarkdown: marked(_.escape(v.documentation), mdOptions) | |
| }); | |
| $(v).find('element').each(function(e) { | |
| if(elementsCollection) { | |
| if(!elementsCollection.contains(e.concept)) elementsCollection.add($(e.concept)); | |
| } else { | |
| elementsCollection = $(e.concept); | |
| } | |
| viewsIdsByConceptId[e.concept.id] += ' id-'+v.id; | |
| }); | |
| $(v).find('relationship').each(function(r) { | |
| if(relationshipsCollection) { | |
| if(!relationshipsCollection.contains(r.concept)) relationshipsCollection.add($(r.concept)); | |
| } else { | |
| relationshipsCollection = $(r.concept); | |
| } | |
| if(!viewsIdsByConceptId[r.concept.id]) viewsIdsByConceptId[r.concept.id] = ''; | |
| viewsIdsByConceptId[r.concept.id] += ' id-'+v.id; | |
| }); | |
| }); | |
| treeContent = tplTreeFolder({folderId: 'id-'+folder.id, folderName: _.escape(folder.name), folderContent: treeContent}); | |
| treeContent = previousContent + treeContent; | |
| } | |
| _.chain(elementsCollection).sortBy(function(e) { return e.name; }).each(function(e) { | |
| elements += tplElement({ | |
| viewsIds: viewsIdsByConceptId[e.id], | |
| elementName: _.escape(e.name), | |
| elementType: properCase(e.type), | |
| elementDocumentationText: _.escape(e.documentation).replace(/\n/g, '<br>'), | |
| elementDocumentationMarkdown: marked(_.escape(e.documentation), mdOptions) | |
| }); | |
| }); | |
| _.chain(relationshipsCollection).sortBy(function(r) { return r.name; }).each(function(r) { | |
| var relHtml = tplRelationship({ | |
| viewsIds: viewsIdsByConceptId[r.id], | |
| relationshipName: _.escape(r.name), | |
| relationshipType: properCase(r.type), | |
| relationshipSource: _.escape(r.source.name), | |
| relationshipTarget: _.escape(r.target.name), | |
| relationshipDocumentationText: _.escape(r.documentation).replace(/\n/g, '<br>'), | |
| relationshipDocumentationMarkdown: marked(_.escape(r.documentation), mdOptions) | |
| }); | |
| // Add data-relationship-id with the concept ID | |
| relHtml = relHtml.replace('<tr class="hidden', '<tr data-relationship-id="' + r.id + '" class="hidden'); | |
| relationships += relHtml; | |
| }); | |
| var interactiveCSS = ` | |
| <style> | |
| .interactive-view-wrapper { position: relative; display: inline-block; } | |
| .view-overlay { position: absolute; top: 10px; left: 0; width: 100%; height: 100%; pointer-events: none; } | |
| /* Prevent text overflow in table cells */ | |
| .tabs table td { | |
| overflow: hidden; | |
| word-wrap: break-word; | |
| word-break: break-word; | |
| } | |
| .tabs table td div { | |
| overflow: hidden; | |
| word-wrap: break-word; | |
| word-break: break-word; | |
| max-height: 200px; | |
| overflow-y: auto; | |
| } | |
| .interactive-element { fill: transparent; stroke: transparent; stroke-width: 0; cursor: pointer; pointer-events: all; transition: all 0.2s ease; } | |
| .interactive-element:hover { fill: rgba(66, 165, 245, 0.25); stroke: #42A5F5; stroke-width: 2; } | |
| .interactive-element[data-target-view]:hover { fill: rgba(76, 175, 80, 0.25); stroke: #4CAF50; stroke-width: 3; cursor: alias; } | |
| .element-modal { display: none; position: fixed; z-index: 10000; left: 0; top: 0; width: 100%; height: 100%; background-color: rgba(0,0,0,0.7); overflow: auto; } | |
| .element-modal-content { background-color: #fefefe; margin: 5% auto; padding: 0; width: 90%; max-width: 700px; border-radius: 12px; box-shadow: 0 10px 40px rgba(0,0,0,0.3); } | |
| .modal-header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 20px; border-radius: 12px 12px 0 0; position: relative; } | |
| .modal-close { position: absolute; top: 15px; right: 20px; color: white; font-size: 32px; font-weight: bold; cursor: pointer; } | |
| .modal-close:hover { color: #ddd; } | |
| .modal-body { padding: 20px; } | |
| .element-type-badge { display: inline-block; padding: 5px 12px; background-color: rgba(255,255,255,0.2); border-radius: 15px; font-size: 12px; margin-top: 5px; } | |
| .element-documentation { background-color: #f8f9fa; padding: 15px; border-radius: 6px; margin: 15px 0; border-left: 4px solid #667eea; } | |
| .properties-table { width: 100%; border-collapse: collapse; margin-top: 10px; } | |
| .properties-table th, .properties-table td { padding: 10px; text-align: left; border-bottom: 1px solid #ddd; } | |
| .properties-table th { background-color: #667eea; color: white; } | |
| .view-navigation-btn { display: inline-block; margin-top: 15px; padding: 10px 20px; background: linear-gradient(135deg, #4CAF50 0%, #45a049 100%); color: white; border: none; border-radius: 6px; cursor: pointer; font-size: 14px; } | |
| .view-navigation-btn:hover { background: linear-gradient(135deg, #45a049 0%, #4CAF50 100%); } | |
| .element-tooltip { position: absolute; background-color: rgba(0, 0, 0, 0.9); color: white; padding: 8px 12px; border-radius: 4px; font-size: 12px; pointer-events: none; z-index: 9999; display: none; max-width: 250px; } | |
| .view-checkbox { display: none; } | |
| </style> | |
| `; | |
| var elementDataJSON = JSON.stringify(interactiveElementData).replace(/'/g, "\\'"); | |
| var interactiveJS = ` | |
| <script> | |
| // Make these globally accessible | |
| window.elementDataStore = ` + elementDataJSON + `; | |
| window.viewIdCache = null; | |
| var elementDataStore = window.elementDataStore; | |
| var viewIdCache = window.viewIdCache; | |
| document.addEventListener('DOMContentLoaded', function() { | |
| console.log('=== Interactive Features (STABLE) ==='); | |
| // CRITICAL: At startup, check id-model to show model documentation | |
| var modelCheckbox = document.getElementById('id-model'); | |
| if(modelCheckbox) { | |
| modelCheckbox.checked = true; | |
| console.log('Model checkbox checked at startup'); | |
| } | |
| // Uncheck all view checkboxes | |
| document.querySelectorAll('input[type="checkbox"].view-checkbox').forEach(function(cb) { | |
| cb.checked = false; | |
| }); | |
| console.log('All view checkboxes unchecked at startup'); | |
| buildViewIdCache(); | |
| // CRITICAL: Uncheck other views AND id-model when a view is checked | |
| document.querySelectorAll('input[type="checkbox"].view-checkbox').forEach(function(checkbox) { | |
| checkbox.addEventListener('change', function() { | |
| if(this.checked) { | |
| // Uncheck id-model to hide model documentation | |
| var modelCheckbox = document.getElementById('id-model'); | |
| if(modelCheckbox && modelCheckbox.checked) { | |
| modelCheckbox.checked = false; | |
| } | |
| // Uncheck all other view checkboxes | |
| document.querySelectorAll('input[type="checkbox"].view-checkbox').forEach(function(cb) { | |
| if(cb !== checkbox && cb.checked) cb.checked = false; | |
| }); | |
| // Refresh clickable elements/relationships for new view | |
| setTimeout(function() { | |
| addClickableElementsAndRelationships(); | |
| }, 100); | |
| } | |
| }); | |
| }); | |
| // CRITICAL: Also add clickable elements when clicking on tree labels (first interaction) | |
| document.querySelectorAll('label[for^="id-id-"]').forEach(function(label) { | |
| label.addEventListener('click', function() { | |
| setTimeout(function() { | |
| addClickableElementsAndRelationships(); | |
| }, 200); | |
| }); | |
| }); | |
| // CRITICAL: Also refresh when switching tabs (Documentation/Elements/Relationships) | |
| document.querySelectorAll('input[name="view-detail"]').forEach(function(tabRadio) { | |
| tabRadio.addEventListener('change', function() { | |
| setTimeout(function() { | |
| addClickableElementsAndRelationships(); | |
| }, 100); | |
| }); | |
| }); | |
| var modal = document.createElement('div'); | |
| modal.className = 'element-modal'; | |
| modal.id = 'elementModal'; | |
| modal.innerHTML = '<div class="element-modal-content"><div class="modal-header"><span class="modal-close">×</span><div id="modalHeader"></div></div><div class="modal-body" id="modalBody"></div></div>'; | |
| document.body.appendChild(modal); | |
| var tooltip = document.createElement('div'); | |
| tooltip.className = 'element-tooltip'; | |
| tooltip.id = 'elementTooltip'; | |
| document.body.appendChild(tooltip); | |
| modal.querySelector('.modal-close').onclick = function() { modal.style.display = 'none'; }; | |
| window.onclick = function(e) { if(e.target == modal) modal.style.display = 'none'; }; | |
| document.querySelectorAll('.interactive-element').forEach(function(el) { | |
| el.addEventListener('click', function(e) { | |
| e.stopPropagation(); | |
| var tv = this.getAttribute('data-target-view'); | |
| tv ? navigateToView(tv) : showElementModal(this); | |
| }); | |
| el.addEventListener('mouseenter', function() { | |
| tooltip.textContent = this.getAttribute('data-element-name') + ' (' + formatType(this.getAttribute('data-element-type')) + ')'; | |
| tooltip.style.display = 'block'; | |
| }); | |
| el.addEventListener('mousemove', function(e) { | |
| tooltip.style.left = (e.pageX + 10) + 'px'; | |
| tooltip.style.top = (e.pageY + 10) + 'px'; | |
| }); | |
| el.addEventListener('mouseleave', function() { tooltip.style.display = 'none'; }); | |
| }); | |
| // CRITICAL: Make element names and relationship types clickable | |
| addClickableElementsAndRelationships(); | |
| }); | |
| // Make elements in Elements table and relationships in Relationships table clickable | |
| function addClickableElementsAndRelationships() { | |
| // Elements table - make element names clickable | |
| var elementRows = document.querySelectorAll('#view-elements:checked ~ .row table tbody tr'); | |
| elementRows.forEach(function(row) { | |
| if(row.style.display === 'none') return; // Skip hidden rows | |
| var cells = row.querySelectorAll('td'); | |
| if(cells.length >= 3) { | |
| var nameCell = cells[0]; | |
| var typeCell = cells[1]; | |
| // Make name clickable | |
| nameCell.style.cursor = 'pointer'; | |
| nameCell.style.color = '#0074D9'; | |
| nameCell.style.textDecoration = 'underline'; | |
| nameCell.addEventListener('click', function() { | |
| var elementName = nameCell.textContent.trim(); | |
| var elementType = typeCell.textContent.trim(); | |
| // Find element in elementDataStore by name | |
| showElementFromTable(elementName, elementType); | |
| }); | |
| } | |
| }); | |
| // Relationships table - make relationship types clickable | |
| var relationshipRows = document.querySelectorAll('#view-relationships:checked ~ .row table tbody tr'); | |
| relationshipRows.forEach(function(row) { | |
| if(row.style.display === 'none') return; // Skip hidden rows | |
| var relationshipId = row.getAttribute('data-relationship-id'); | |
| var cells = row.querySelectorAll('td'); | |
| if(cells.length >= 5) { | |
| var nameCell = cells[0]; | |
| var typeCell = cells[1]; | |
| var sourceCell = cells[2]; | |
| var targetCell = cells[3]; | |
| // Make type clickable | |
| typeCell.style.cursor = 'pointer'; | |
| typeCell.style.color = '#0074D9'; | |
| typeCell.style.textDecoration = 'underline'; | |
| typeCell.addEventListener('click', function() { | |
| var relName = nameCell.textContent.trim(); | |
| var relType = typeCell.textContent.trim(); | |
| var relSource = sourceCell.textContent.trim(); | |
| var relTarget = targetCell.textContent.trim(); | |
| showRelationshipFromTable(relationshipId, relName, relType, relSource, relTarget); | |
| }); | |
| } | |
| }); | |
| } | |
| // Show element from table click - find it in elementDataStore | |
| function showElementFromTable(elementName, elementType) { | |
| // Find current active view | |
| var activeCheckbox = document.querySelector('.view-checkbox:checked'); | |
| if(!activeCheckbox) return; | |
| var viewId = activeCheckbox.id; | |
| // Find element in elementDataStore by name | |
| var elementData = null; | |
| var elementId = null; | |
| if(elementDataStore[viewId]) { | |
| for(var id in elementDataStore[viewId]) { | |
| var data = elementDataStore[viewId][id]; | |
| if(data.name === elementName) { | |
| elementData = data; | |
| elementId = id; | |
| break; | |
| } | |
| } | |
| } | |
| // Show modal | |
| var modal = document.getElementById('elementModal'); | |
| var modalHeader = document.getElementById('modalHeader'); | |
| var modalBody = document.getElementById('modalBody'); | |
| modalHeader.innerHTML = '<h2>' + elementName + '</h2>' + | |
| '<span class="element-type-badge">' + elementType + '</span>'; | |
| var content = ''; | |
| if(elementData) { | |
| if(elementData.documentation) { | |
| content += '<div class="element-documentation">' + elementData.documentation + '</div>'; | |
| } | |
| var propKeys = Object.keys(elementData.properties || {}); | |
| if(propKeys.length > 0) { | |
| content += '<h3>Propriétés</h3>'; | |
| content += '<table class="properties-table">'; | |
| content += '<tr><th>Propriété</th><th>Valeur</th></tr>'; | |
| propKeys.forEach(function(key) { | |
| content += '<tr><td>' + key + '</td><td>' + elementData.properties[key] + '</td></tr>'; | |
| }); | |
| content += '</table>'; | |
| } | |
| } else { | |
| content += '<p>Propriétés non disponibles</p>'; | |
| } | |
| modalBody.innerHTML = content; | |
| modal.style.display = 'block'; | |
| } | |
| // Show relationship from table click | |
| function showRelationshipFromTable(relationshipId, relName, relType, relSource, relTarget) { | |
| // Find current active view | |
| var activeCheckbox = document.querySelector('.view-checkbox:checked'); | |
| if(!activeCheckbox) return; | |
| var viewId = activeCheckbox.id; | |
| // Find relationship in elementDataStore by ArchiMate ID | |
| var relData = null; | |
| if(elementDataStore[viewId]) { | |
| // Search by the ArchiMate concept ID | |
| relData = elementDataStore[viewId][relationshipId]; | |
| // If not found directly, it might be stored under a visual object ID | |
| // We need to search through all items | |
| if(!relData) { | |
| for(var id in elementDataStore[viewId]) { | |
| var data = elementDataStore[viewId][id]; | |
| // The visual object might have the relationship ID as its concept | |
| if(data.type && data.type.includes('relationship')) { | |
| // This is a relationship visual object | |
| // The ID we're looking for matches the concept ID | |
| relData = data; | |
| console.log('Found relationship by type search:', id, data.name); | |
| break; | |
| } | |
| } | |
| } | |
| } | |
| // Show modal | |
| var modal = document.getElementById('elementModal'); | |
| var modalHeader = document.getElementById('modalHeader'); | |
| var modalBody = document.getElementById('modalBody'); | |
| modalHeader.innerHTML = '<h2>' + (relName || relType) + '</h2>' + | |
| '<span class="element-type-badge">' + relType + '</span>'; | |
| var content = ''; | |
| // Documentation first | |
| if(relData && relData.documentation) { | |
| content += '<div class="element-documentation">' + relData.documentation + '</div>'; | |
| } | |
| // Properties table | |
| content += '<h3>Relation</h3>'; | |
| content += '<table class="properties-table">'; | |
| content += '<tr><th>Propriété</th><th>Valeur</th></tr>'; | |
| content += '<tr><td>Source</td><td>' + relSource + '</td></tr>'; | |
| content += '<tr><td>Target</td><td>' + relTarget + '</td></tr>'; | |
| // Add custom properties | |
| if(relData && relData.properties) { | |
| var propKeys = Object.keys(relData.properties); | |
| if(propKeys.length > 0) { | |
| propKeys.forEach(function(key) { | |
| content += '<tr><td>' + key + '</td><td>' + relData.properties[key] + '</td></tr>'; | |
| }); | |
| } | |
| } | |
| content += '</table>'; | |
| // Debug | |
| console.log('Relationship lookup:'); | |
| console.log(' ID:', relationshipId); | |
| console.log(' Name:', relName); | |
| console.log(' View:', viewId); | |
| console.log(' Found:', !!relData); | |
| if(relData) { | |
| console.log(' Props:', Object.keys(relData.properties || {}).length); | |
| } else { | |
| console.log(' Available IDs:', Object.keys(elementDataStore[viewId] || {})); | |
| } | |
| content += '</table>'; | |
| modalBody.innerHTML = content; | |
| modal.style.display = 'block'; | |
| } | |
| function buildViewIdCache() { | |
| viewIdCache = {}; | |
| var checkboxes = document.querySelectorAll('input[type="checkbox"].view-checkbox'); | |
| console.log('Found ' + checkboxes.length + ' view checkboxes'); | |
| checkboxes.forEach(function(cb) { | |
| var id = cb.id; | |
| if(id && id.indexOf('-') !== -1) { | |
| viewIdCache[id] = id; | |
| var cleanId = id; | |
| if(cleanId.startsWith('id-')) { | |
| cleanId = cleanId.substring(3); | |
| viewIdCache[cleanId] = id; | |
| } | |
| if(cleanId.startsWith('id-')) { | |
| var doubleClean = cleanId.substring(3); | |
| viewIdCache[doubleClean] = id; | |
| viewIdCache['id-' + doubleClean] = id; | |
| } | |
| viewIdCache['id-' + id] = id; | |
| } | |
| }); | |
| console.log('Cache: ' + Object.keys(viewIdCache).length + ' mappings'); | |
| } | |
| function formatType(type) { | |
| return type.replace('archimate-', '').replace(/-/g, ' ').split(' ').map(function(w) { | |
| return w.charAt(0).toUpperCase() + w.slice(1); | |
| }).join(' '); | |
| } | |
| function showElementModal(element) { | |
| var elementId = element.getAttribute('data-element-id'); | |
| var elementName = element.getAttribute('data-element-name'); | |
| var elementType = element.getAttribute('data-element-type'); | |
| var targetView = element.getAttribute('data-target-view'); | |
| var viewWrapper = element.closest('.interactive-view-wrapper'); | |
| var viewId = viewWrapper.getAttribute('data-view-id'); | |
| var elementData = elementDataStore[viewId] ? elementDataStore[viewId][elementId] : null; | |
| document.getElementById('modalHeader').innerHTML = '<h2>' + elementName + '</h2><span class="element-type-badge">' + formatType(elementType) + '</span>'; | |
| var content = ''; | |
| if(elementData) { | |
| if(elementData.documentation) content += '<div class="element-documentation">' + elementData.documentation + '</div>'; | |
| var propKeys = Object.keys(elementData.properties); | |
| if(propKeys.length > 0) { | |
| content += '<h3>Propriétés</h3><table class="properties-table"><tr><th>Propriété</th><th>Valeur</th></tr>'; | |
| propKeys.forEach(function(key) { | |
| content += '<tr><td>' + key + '</td><td>' + elementData.properties[key] + '</td></tr>'; | |
| }); | |
| content += '</table>'; | |
| } | |
| } | |
| if(targetView) content += '<button class="view-navigation-btn" onclick="navigateToView(\\'' + targetView + '\\')">Naviguer vers la vue →</button>'; | |
| document.getElementById('modalBody').innerHTML = content; | |
| document.getElementById('elementModal').style.display = 'block'; | |
| } | |
| function navigateToView(rawViewId) { | |
| console.log('Navigation: ' + rawViewId); | |
| var modal = document.getElementById('elementModal'); | |
| if(modal) modal.style.display = 'none'; | |
| var actualCheckboxId = viewIdCache[rawViewId]; | |
| if(!actualCheckboxId) { | |
| var variations = [rawViewId, 'id-' + rawViewId, 'id-id-' + rawViewId, rawViewId.replace(/^id-/, ''), 'id-id-' + rawViewId.replace(/^id-/, '')]; | |
| for(var i = 0; i < variations.length; i++) { | |
| if(document.getElementById(variations[i])) { | |
| actualCheckboxId = variations[i]; | |
| break; | |
| } | |
| } | |
| } | |
| if(!actualCheckboxId) { | |
| console.error('Checkbox not found for: ' + rawViewId); | |
| return; | |
| } | |
| // Uncheck all views and id-model | |
| var modelCheckbox = document.getElementById('id-model'); | |
| if(modelCheckbox && modelCheckbox.checked) { | |
| modelCheckbox.checked = false; | |
| } | |
| document.querySelectorAll('input[type="checkbox"].view-checkbox').forEach(function(cb) { | |
| if(cb.checked) cb.checked = false; | |
| }); | |
| var checkbox = document.getElementById(actualCheckboxId); | |
| checkbox.checked = true; | |
| checkbox.dispatchEvent(new Event('change', { bubbles: true })); | |
| // Refresh clickable elements/relationships | |
| setTimeout(function() { | |
| addClickableElementsAndRelationships(); | |
| }, 100); | |
| setTimeout(function() { | |
| var viewSection = document.querySelector('[data-view-id="' + actualCheckboxId + '"]'); | |
| if(viewSection) viewSection.scrollIntoView({ behavior: 'smooth', block: 'start' }); | |
| }, 150); | |
| } | |
| </script> | |
| `; | |
| var mainReport = tplMainReport({ | |
| roboto: roboto, icon: icon, picnic: picnic, modelTitle: _.escape(model.name), | |
| visibilityRulesBold: visibilityRulesBold, visibilityRulesReveal: visibilityRulesReveal, | |
| inputCheckbox: inputCheckbox, treeContent: treeContent, viewTitles: viewTitles, viewDiagrams: viewDiagrams, | |
| modelPurposeText: _.escape(model.purpose).replace(/\n/g, '<br>'), | |
| modelPurposeMarkdown: marked(_.escape(model.purpose), mdOptions), | |
| viewDocumentations: viewDocumentations, elements: elements, relationships: relationships, | |
| sidebarBgColor: '#37474f', sidebarColor: '#DDDDDD', sidebarWidth: '350px', sidebarMargin: '10px', | |
| sidebarFooterHeight: '0px', headerHeight: '60px', headerBgColor: '#0074D9', headerColor: '#fff', | |
| mainBgColor: '#fff', mainColor: '#000', mainMargin: '20px', mainHeaderMargin: '35px', | |
| mainHeaderBgColor: '#eceff1', mainHeaderColor: '#546e7a', treeMargin: '1.3em' | |
| }); | |
| mainReport = mainReport.replace('</head>', interactiveCSS + '\n</head>'); | |
| mainReport = mainReport.replace('</body>', interactiveJS + '\n</body>'); | |
| $.fs.writeFile(filePath, mainReport, 'UTF-8'); | |
| info(''); | |
| info('🎉 EXPORT SUCCESSFUL!'); | |
| info(' File: ' + filePath); | |
| info(' Views: ' + checkboxCount); | |
| info(' Elements: ' + (elementsCollection ? elementsCollection.size() : 0)); | |
| info(' Relationships: ' + (relationshipsCollection ? relationshipsCollection.size() : 0)); | |
| info(''); | |
| info('Features:'); | |
| info(' ✓ Interactive SVG overlays with clickable elements'); | |
| info(' ✓ View navigation (tree + links)'); | |
| info(' ✓ Element and relationship properties'); | |
| info(' ✓ Correct mapping with bend points support'); | |
| info(''); | |
| function properCase(str) { | |
| return str.replace(/\w*/g, function(txt) { | |
| return txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase(); | |
| }).replace('-', ' '); | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment